// 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::{BufRead, BufReader, Write}; use std::process::{Command, Stdio}; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; use tauri::{AppHandle, Emitter, Manager, State}; /// App handle stashed at startup so the low-level p4 helpers can emit a live /// command log to the frontend (a Perforce-style "Log" panel). static APP: OnceLock = OnceLock::new(); /// Emit one p4-log entry to the UI: the command line, how many items/lines it /// returned, whether it succeeded, and any error text. fn log_p4(args: &[&str], count: i64, ok: bool, err: Option<&str>) { if let Some(app) = APP.get() { let _ = app.emit( "p4-log", serde_json::json!({ "cmd": format!("p4 {}", args.join(" ")), "count": count, "ok": ok, "err": err, }), ); } } #[derive(Default, Clone)] struct Conn { port: String, user: String, client: String, root: String, // local client root (populated after login/restore/switch) } #[derive(Default)] struct AppState(Mutex); /// Decode p4 output bytes: strict UTF-8 first, else the Windows ANSI codepage /// (cp1251 on Russian systems) so Cyrillic file paths survive round-trips /// instead of turning into U+FFFD and breaking later revert/submit calls. fn decode(bytes: &[u8]) -> String { match std::str::from_utf8(bytes) { Ok(s) => s.to_string(), Err(_) => encoding_rs::WINDOWS_1251.decode(bytes).0.into_owned(), } } /// Preview/transfer size limit: reads above this are rejected instead of /// buffering a multi-GB .pak/.uasset in memory and shipping it over IPC. const MAX_READ_BYTES: u64 = 64 * 1024 * 1024; fn check_local_size(path: &str) -> Result<(), String> { if let Ok(m) = std::fs::metadata(path) { if m.len() > MAX_READ_BYTES { return Err(format!( "File is too large to preview ({} MB, limit {} MB)", m.len() / (1024 * 1024), MAX_READ_BYTES / (1024 * 1024) )); } } Ok(()) } /// Reject local paths outside the client root (workspace confinement for /// filesystem-touching IPC commands). fn ensure_under_root(conn: &Conn, path: &str) -> Result<(), String> { if conn.root.is_empty() { return Err("No workspace root known for this connection".into()); } let canon_root = std::fs::canonicalize(&conn.root) .map_err(|e| format!("Workspace root not accessible: {e}"))?; let canon = std::fs::canonicalize(path).map_err(|e| format!("File not found: {e}"))?; if !canon.starts_with(&canon_root) { return Err("Path is outside the workspace".into()); } Ok(()) } /// 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. /// stdin defaults to null so a command that unexpectedly prompts (expired /// ticket, interactive subcommand) errors out instead of hanging forever; /// commands that feed stdin (login, change -i) override it with piped(). 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 } c.stdin(Stdio::null()); 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 = match p4_cmd(conn, true).args(args).output() { Ok(o) => o, Err(e) => { let m = format!("Failed to start p4: {e}"); log_p4(args, 0, false, Some(&m)); return Err(m); } }; let text = decode(&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(); log_p4(args, 0, false, Some(&msg)); 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 = decode(&out.stderr).trim().to_string(); if !e.is_empty() { log_p4(args, 0, false, Some(&e)); return Err(e); } } log_p4(args, items.len() as i64, true, None); Ok(items) } /// Trust the SSL fingerprint on FIRST connect only (no `-f`): if the server's /// fingerprint later CHANGES — the signature of a MITM/substituted server — /// p4 refuses and we surface that instead of silently re-trusting and then /// sending the user's password to the impostor. fn trust(port: &str) -> Result<(), String> { let mut c = Command::new("p4"); #[cfg(windows)] { use std::os::windows::process::CommandExt; c.creation_flags(0x0800_0000); } c.stdin(Stdio::null()); let out = match c.args(["-p", port, "trust", "-y"]).output() { Ok(o) => o, Err(_) => return Ok(()), // p4 missing → the next real command reports it properly }; if !out.status.success() { let e = decode(&out.stderr); let el = e.to_lowercase(); // only a fingerprint MISMATCH is fatal; "not an SSL connection" etc. is fine if el.contains("has changed") || el.contains("mismatch") { return Err(format!( "SECURITY: the server's SSL fingerprint has CHANGED — possible man-in-the-middle. \ Verify the server before connecting.\n{}", e.trim() )); } } Ok(()) } /// 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(), ..Default::default() }; 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 mut conn = Conn { port: port.clone(), user: user.clone(), client: client.clone(), ..Default::default() }; 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 = decode(&out.stderr); let err = if err.trim().is_empty() { decode(&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 }); } let info = run_json(&conn, &["info"])?; let info = info.into_iter().next().unwrap_or(Value::Null); conn.root = info.get("clientRoot").and_then(|r| r.as_str()).unwrap_or("").to_string(); *state.0.lock().unwrap_or_else(|e| e.into_inner()) = conn; Ok(info) } /// 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 mut conn = Conn { port, user, client, ..Default::default() }; // `p4 login -s` reports ticket status; errors if not authenticated. run_json(&conn, &["login", "-s"])?; let info = run_json(&conn, &["info"])?; let info = info.into_iter().next().unwrap_or(Value::Null); conn.root = info.get("clientRoot").and_then(|r| r.as_str()).unwrap_or("").to_string(); *state.0.lock().unwrap_or_else(|e| e.into_inner()) = conn; Ok(info) } fn current(state: &State) -> Result { let c = state.0.lock().unwrap_or_else(|e| e.into_inner()).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 { // no opened files — but NEVER delete a changelist that holds shelved // files: it looks "empty" here yet carries real work let shelved = run_json(&conn, &["describe", "-s", "-S", &num]) .ok() .and_then(|v| v.into_iter().next()) .map_or(false, |d| d.get("depotFile0").is_some()); if shelved { result.push(c); } else { 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_or_else(|e| e.into_inner()) = 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 = match p4_cmd(conn, false).args(args).output() { Ok(o) => o, Err(e) => { let m = format!("Failed to start p4: {e}"); log_p4(args, 0, false, Some(&m)); return Err(m); } }; let stdout = decode(&out.stdout).trim().to_string(); let stderr = decode(&out.stderr).trim().to_string(); if !out.status.success() && !stderr.is_empty() { log_p4(args, 0, false, Some(&stderr)); return Err(stderr); } log_p4(args, stdout.lines().count() as i64, true, None); Ok(if stdout.is_empty() { stderr } else { stdout }) } /// Pull a short file name out of a p4 sync/submit progress line, e.g. /// "//depot/Proj/Foo.uasset#3 - added as C:\..." -> "Foo.uasset". fn progress_file(line: &str) -> String { let head = line.split(" - ").next().unwrap_or(line); let head = head.split('#').next().unwrap_or(head); head.rsplit(['/', '\\']).next().unwrap_or(head).trim().to_string() } /// Run a p4 command streaming its stdout line-by-line, emitting a `p4-transfer` /// event as files move so the UI can show a live P4V-style progress dialog. /// `op` labels the operation ("sync" | "submit"). Returns the full stdout. fn run_stream(conn: &Conn, args: &[&str], op: &str) -> Result { let mut child = match p4_cmd(conn, false) .args(args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() { Ok(c) => c, Err(e) => { let m = format!("Failed to start p4: {e}"); log_p4(args, 0, false, Some(&m)); return Err(m); } }; let mut collected = String::new(); let mut count: i64 = 0; let mut last = Instant::now(); if let Some(out) = child.stdout.take() { for line in BufReader::new(out).lines().map_while(Result::ok) { count += 1; // throttle to ~25 fps regardless of file count, but always send the first few if count <= 5 || last.elapsed().as_millis() >= 40 { if let Some(app) = APP.get() { let _ = app.emit( "p4-transfer", serde_json::json!({ "op": op, "count": count, "file": progress_file(&line), "done": false }), ); } last = Instant::now(); } collected.push_str(&line); collected.push('\n'); } } let status = child.wait().map_err(|e| e.to_string())?; let stderr = child .stderr .take() .map(|mut s| { let mut buf = String::new(); let _ = std::io::Read::read_to_string(&mut s, &mut buf); buf.trim().to_string() }) .unwrap_or_default(); // tell the UI the transfer is finished if let Some(app) = APP.get() { let _ = app.emit( "p4-transfer", serde_json::json!({ "op": op, "count": count, "done": true }), ); } if !status.success() && !stderr.is_empty() { log_p4(args, 0, false, Some(&stderr)); return Err(stderr); } log_p4(args, count, true, None); Ok(collected.trim().to_string()) } /// 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_stream(&conn, &["sync", &scope_spec(&scope)], "sync")?; Ok(if msg.is_empty() { "Already up to date — nothing to sync.".into() } else { msg }) } /// List labels on the server (most-recently-updated first). #[tauri::command] async fn p4_labels(state: State<'_, AppState>) -> Result, String> { let conn = current(&state)?; run_json(&conn, &["labels", "-m", "300"]) } /// Tag revisions with a label (auto-creates the label if new). `rev` optional: /// a changelist, another label, or a date; empty = head. Scope confines it. #[tauri::command] async fn p4_label_tag(state: State<'_, AppState>, label: String, scope: String, rev: String) -> Result { let conn = current(&state)?; let label = label.trim(); if label.is_empty() { return Err("No label name".into()); } let mut spec = scope_spec(&scope); let rev = rev.trim().trim_start_matches(['@', '#']); if !rev.is_empty() { spec = format!("{spec}@{rev}"); } run_text(&conn, &["tag", "-l", label, &spec]) } /// Sync the workspace to an exact revision: a changelist number, a label name, /// or a date (`YYYY/MM/DD`). `rev` is appended to the scope spec as `@rev`. #[tauri::command] async fn p4_sync_to(state: State<'_, AppState>, scope: String, rev: String) -> Result { let conn = current(&state)?; let rev = rev.trim(); if rev.is_empty() { return Err("No revision specified".into()); } // strip a leading @ or # if the user typed one let rev = rev.trim_start_matches(['@', '#']); let spec = format!("{}@{}", scope_spec(&scope), rev); let msg = run_stream(&conn, &["sync", &spec], "sync")?; Ok(if msg.is_empty() { "Already at that revision — 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(decode(&out.stderr).trim().to_string()); } // stdout: "Change 23 created." let s = decode(&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_stream(&conn, &["submit", "-c", &change], "submit") } /// "Uncommit": move files back into the default changelist (like `git reset`). /// The now-empty numbered changelist is cleaned up on the next `p4_changes`. #[tauri::command] async fn p4_reopen_default(state: State<'_, AppState>, files: Vec) -> Result { let conn = current(&state)?; if files.is_empty() { return Ok(String::new()); } let mut args: Vec<&str> = vec!["reopen", "-c", "default"]; for f in &files { args.push(f.as_str()); } run_text(&conn, &args) } /// Edit the description of a pending changelist, preserving its file list. #[tauri::command] async fn p4_set_desc(state: State<'_, AppState>, change: String, description: String) -> Result { let conn = current(&state)?; let spec = run_text(&conn, &["change", "-o", &change])?; // rebuild the spec, replacing only the Description field's indented body let mut out = String::new(); let mut lines = spec.lines().peekable(); let mut replaced = false; while let Some(line) = lines.next() { if !replaced && line.starts_with("Description:") { out.push_str("Description:\n"); for dl in description.trim().lines() { out.push('\t'); out.push_str(dl); out.push('\n'); } // consume the old (indented / blank) description body while let Some(p) = lines.peek() { if p.starts_with('\t') || p.starts_with(' ') || p.is_empty() { lines.next(); } else { break; } } replaced = true; } else { out.push_str(line); out.push('\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(out.as_bytes()).map_err(|e| e.to_string())?; } let res = child.wait_with_output().map_err(|e| e.to_string())?; if !res.status.success() { return Err(decode(&res.stderr).trim().to_string()); } Ok(decode(&res.stdout).trim().to_string()) } /// 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_stream(&conn, &["submit", "-d", &description], "submit"); } // 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_stream(&conn, &["submit", "-c", &cl], "submit") } /// 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)?; if change.is_empty() || !change.chars().all(|c| c.is_ascii_digit()) { return Err(format!("Invalid changelist number: {change}")); } 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(()) } /// A code editor / IDE detected on this machine. #[derive(serde::Serialize, Clone)] struct Editor { id: String, // stable key ("vscode", "cursor", "vs", …) name: String, // display name exe: String, // resolved absolute .exe (empty → system-default association) args: Vec, // args inserted before the file path (e.g. ["/edit"] for VS) } /// First path in `cands` that exists on disk. fn first_existing(cands: &[String]) -> Option { cands.iter().find(|p| std::path::Path::new(p).exists()).cloned() } /// Bounded recursive search for a file named `target` (case-insensitive) under `root`. fn find_file_named(root: &std::path::Path, target: &str, depth: usize) -> Option { if depth == 0 || !root.is_dir() { return None; } let entries = std::fs::read_dir(root).ok()?; let mut dirs = Vec::new(); for e in entries.flatten() { let p = e.path(); if p.is_file() { if p.file_name().and_then(|n| n.to_str()).map_or(false, |n| n.eq_ignore_ascii_case(target)) { return Some(p.to_string_lossy().to_string()); } } else if p.is_dir() { dirs.push(p); } } for d in dirs { if let Some(f) = find_file_named(&d, target, depth - 1) { return Some(f); } } None } /// Locate Visual Studio's devenv.exe via vswhere (latest install). fn find_devenv() -> Option { let pf86 = std::env::var("ProgramFiles(x86)").ok()?; let vswhere = format!("{pf86}\\Microsoft Visual Studio\\Installer\\vswhere.exe"); if !std::path::Path::new(&vswhere).exists() { return None; } let mut c = Command::new(&vswhere); #[cfg(windows)] { use std::os::windows::process::CommandExt; c.creation_flags(0x0800_0000); } let out = c.args(["-latest", "-property", "productPath"]).output().ok()?; let s = decode(&out.stdout).trim().to_string(); if s.is_empty() || !std::path::Path::new(&s).exists() { None } else { Some(s) } } fn add_editor(out: &mut Vec, id: &str, name: &str, exe: Option, args: &[&str]) { if let Some(exe) = exe { out.push(Editor { id: id.into(), name: name.into(), exe, args: args.iter().map(|s| s.to_string()).collect() }); } } /// Scan for installed code editors / IDEs (Windows). Only installed ones are /// returned; "System default" (open with the associated app) is always last. fn detect_editors() -> Vec { let la = std::env::var("LOCALAPPDATA").unwrap_or_default(); let pf = std::env::var("ProgramFiles").unwrap_or_default(); let pf86 = std::env::var("ProgramFiles(x86)").unwrap_or_default(); let mut out: Vec = Vec::new(); add_editor(&mut out, "vscode", "VS Code", first_existing(&[ format!("{la}\\Programs\\Microsoft VS Code\\Code.exe"), format!("{pf}\\Microsoft VS Code\\Code.exe"), format!("{pf86}\\Microsoft VS Code\\Code.exe"), ]), &[]); add_editor(&mut out, "cursor", "Cursor", first_existing(&[ format!("{la}\\Programs\\cursor\\Cursor.exe"), ]), &[]); add_editor(&mut out, "windsurf", "Windsurf", first_existing(&[ format!("{la}\\Programs\\Windsurf\\Windsurf.exe"), ]), &[]); add_editor(&mut out, "vscode-insiders", "VS Code Insiders", first_existing(&[ format!("{la}\\Programs\\Microsoft VS Code Insiders\\Code - Insiders.exe"), format!("{pf}\\Microsoft VS Code Insiders\\Code - Insiders.exe"), ]), &[]); add_editor(&mut out, "vs", "Visual Studio", find_devenv(), &["/edit"]); let jb_pf = format!("{pf}\\JetBrains"); let jb_tb = format!("{la}\\JetBrains\\Toolbox\\apps"); add_editor(&mut out, "rider", "JetBrains Rider", find_file_named(std::path::Path::new(&jb_pf), "rider64.exe", 3) .or_else(|| find_file_named(std::path::Path::new(&jb_tb), "rider64.exe", 6)), &[]); add_editor(&mut out, "clion", "JetBrains CLion", find_file_named(std::path::Path::new(&jb_pf), "clion64.exe", 3) .or_else(|| find_file_named(std::path::Path::new(&jb_tb), "clion64.exe", 6)), &[]); add_editor(&mut out, "idea", "IntelliJ IDEA", find_file_named(std::path::Path::new(&jb_pf), "idea64.exe", 3) .or_else(|| find_file_named(std::path::Path::new(&jb_tb), "idea64.exe", 6)), &[]); add_editor(&mut out, "sublime", "Sublime Text", first_existing(&[ format!("{pf}\\Sublime Text\\sublime_text.exe"), format!("{pf}\\Sublime Text 3\\sublime_text.exe"), format!("{pf86}\\Sublime Text 3\\sublime_text.exe"), ]), &[]); add_editor(&mut out, "npp", "Notepad++", first_existing(&[ format!("{pf}\\Notepad++\\notepad++.exe"), format!("{pf86}\\Notepad++\\notepad++.exe"), ]), &[]); add_editor(&mut out, "zed", "Zed", first_existing(&[ format!("{la}\\Programs\\Zed\\Zed.exe"), format!("{la}\\Zed\\Zed.exe"), ]), &[]); // always available: open with the OS-associated application out.push(Editor { id: "default".into(), name: "System default".into(), exe: String::new(), args: vec![] }); out } /// List installed code editors for the Settings picker. #[tauri::command] async fn list_editors() -> Result, String> { Ok(detect_editors()) } /// Open a depot file's local working copy in the chosen editor (by id). #[tauri::command] async fn open_in_editor(state: State<'_, AppState>, depot: String, editor: String) -> Result<(), String> { 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())?; if !std::path::Path::new(&local).exists() { return Err(format!("File is not on disk yet (not synced): {local}")); } let editors = detect_editors(); let ed = editors .iter() .find(|e| e.id == editor) .or_else(|| editors.iter().find(|e| e.id == "default")) .cloned() .ok_or_else(|| "No editor available".to_string())?; if ed.exe.is_empty() { // system-default association via `start` let mut c = Command::new("cmd"); #[cfg(windows)] { use std::os::windows::process::CommandExt; c.creation_flags(0x0800_0000); } c.args(["/C", "start", "", &local]) .spawn() .map_err(|e| format!("Could not launch: {e}"))?; } else { let mut c = Command::new(&ed.exe); #[cfg(windows)] { use std::os::windows::process::CommandExt; c.creation_flags(0x0800_0000); } for a in &ed.args { c.arg(a); } // pass the path as a separate arg so Rust quotes only it c.arg(&local) .spawn() .map_err(|e| format!("Could not open in {}: {e}", ed.name))?; } Ok(()) } /// Open a local file with its associated application (e.g. launch Unreal for a /// `.uproject`). Confined to the workspace root — the webview cannot use this /// to execute arbitrary files elsewhere on disk. #[tauri::command] async fn launch_file(state: State<'_, AppState>, path: String) -> Result<(), String> { let conn = current(&state)?; ensure_under_root(&conn, &path)?; if !std::path::Path::new(&path).exists() { return Err(format!("File not found: {path}")); } let mut c = Command::new("cmd"); #[cfg(windows)] { use std::os::windows::process::CommandExt; c.creation_flags(0x0800_0000); } c.args(["/C", "start", "", &path]) .spawn() .map_err(|e| format!("Could not launch: {e}"))?; Ok(()) } /// Resolve a working-folder scope to a local directory via `p4 where`. fn scope_local_dir(conn: &Conn, scope: &str) -> Option { if scope.is_empty() { return None; } let spec = format!("{}/...", scope.trim_end_matches(['/', '.'])); run_json(conn, &["where", &spec]) .ok()? .into_iter() .next() .and_then(|v| v.get("path").and_then(|p| p.as_str()).map(String::from)) .map(|p| p.trim_end_matches(['.', '/', '\\']).to_string()) } /// First file with the given extension in `dir` (checked, then one level of subdirs). fn find_ext(dir: &str, ext: &str) -> String { let matches = |p: &std::path::Path| p.extension().map_or(false, |x| x.eq_ignore_ascii_case(ext)); if let Ok(entries) = std::fs::read_dir(dir) { let mut subdirs = Vec::new(); for e in entries.flatten() { let p = e.path(); if matches(&p) { return p.to_string_lossy().to_string(); } if p.is_dir() { subdirs.push(p); } } for sd in subdirs { if let Ok(inner) = std::fs::read_dir(&sd) { for e in inner.flatten() { let p = e.path(); if matches(&p) { return p.to_string_lossy().to_string(); } } } } } String::new() } /// Local path of a `.uproject` in the scope (or "" if not a UE project). #[tauri::command] async fn find_uproject(state: State<'_, AppState>, scope: String) -> Result { let conn = current(&state)?; Ok(scope_local_dir(&conn, &scope).map(|d| find_ext(&d, "uproject")).unwrap_or_default()) } /// Local path of a Visual Studio `.sln` in the scope (or "" if none). #[tauri::command] async fn find_sln(state: State<'_, AppState>, scope: String) -> Result { let conn = current(&state)?; Ok(scope_local_dir(&conn, &scope).map(|d| find_ext(&d, "sln")).unwrap_or_default()) } /// Build a Visual Studio solution with MSBuild (located via vswhere), streaming /// its output line-by-line as `build-log` events. Uses the solution's default /// configuration — for an Unreal project that compiles the game module. #[tauri::command] async fn build_sln(path: String, config: String) -> Result { if !std::path::Path::new(&path).exists() { return Err(format!("Solution not found: {path}")); } let emit = |line: String, done: bool, ok: bool| { if let Some(app) = APP.get() { let _ = app.emit( "build-log", serde_json::json!({ "line": line, "done": done, "ok": ok }), ); } }; // locate MSBuild.exe via vswhere (falls back to PATH) let pf86 = std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| "C:\\Program Files (x86)".into()); let vswhere = format!("{pf86}\\Microsoft Visual Studio\\Installer\\vswhere.exe"); let msbuild = { let mut vc = Command::new(&vswhere); #[cfg(windows)] { use std::os::windows::process::CommandExt; vc.creation_flags(0x0800_0000); } vc.args(["-latest", "-requires", "Microsoft.Component.MSBuild", "-find", "MSBuild\\**\\Bin\\MSBuild.exe"]); match vc.output() { Ok(o) => decode(&o.stdout).lines().next().unwrap_or("").trim().to_string(), Err(_) => String::new(), } }; let msbuild = if msbuild.is_empty() { "MSBuild.exe".to_string() } else { msbuild }; emit(format!("MSBuild: {msbuild}"), false, false); emit( format!("Building {path}{} …", if config.is_empty() { String::new() } else { format!(" [{config} | Win64]") }), false, false, ); let mut cmd = Command::new(&msbuild); #[cfg(windows)] { use std::os::windows::process::CommandExt; cmd.creation_flags(0x0800_0000); } // build args; a specific UE configuration ("Development Editor", "Shipping", …) → Win64 let mut args: Vec = vec![ path.clone(), "/m".into(), "/nologo".into(), "/v:minimal".into(), "/clp:NoSummary".into(), ]; if !config.is_empty() { args.push(format!("/p:Configuration={config}")); args.push("/p:Platform=Win64".into()); } cmd.args(&args).stdout(Stdio::piped()).stderr(Stdio::piped()); let mut child = match cmd.spawn() { Ok(c) => c, Err(e) => { let m = format!("Could not start MSBuild (Visual Studio C++ tools installed?): {e}"); emit(m.clone(), true, false); return Err(m); } }; // For Unreal, the .sln drives UnrealBuildTool; MSBuild can return non-zero for a // side project even when UBT reports the game module built fine. So trust UBT's // "Result: Succeeded" (and the absence of real compile errors) over the exit code. let mut ubt_result = false; let mut ubt_success = false; let mut compile_error = false; let mut scan = |line: &str| { let l = line.to_ascii_lowercase(); if l.contains("result: succeeded") { ubt_result = true; ubt_success = true; } if l.contains("result: failed") { ubt_result = true; } if l.contains(": error ") || l.contains(") error ") || l.contains("error msb") { compile_error = true; } }; if let Some(out) = child.stdout.take() { for line in BufReader::new(out).lines().map_while(Result::ok) { if !line.trim().is_empty() { scan(&line); emit(line, false, false); } } } let status = child.wait().map_err(|e| e.to_string())?; if let Some(mut se) = child.stderr.take() { let mut buf = String::new(); let _ = std::io::Read::read_to_string(&mut se, &mut buf); for line in buf.lines() { if !line.trim().is_empty() { scan(line); emit(line.to_string(), false, false); } } } // UBT verdict wins when present; otherwise fall back to MSBuild's exit code let ok = if ubt_result { ubt_success && !compile_error } else { status.success() }; emit(if ok { "✓ Build succeeded.".into() } else { "✗ Build FAILED.".into() }, true, ok); if ok { Ok("ok".into()) } else { Err("Build failed".into()) } } /// 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. /// Confined to the workspace root and size-capped. #[tauri::command] async fn read_file(state: State<'_, AppState>, path: String) -> Result { let conn = current(&state)?; ensure_under_root(&conn, &path)?; check_local_size(&path)?; std::fs::read(&path) .map(tauri::ipc::Response::new) .map_err(|e| format!("Could not read file: {e}")) } /// Write UTF-8 text back to a depot file's local working copy (built-in editor). /// Opens the file for edit first (so the change is tracked and the read-only bit /// is cleared), then writes. Confined to the workspace root. #[tauri::command] async fn write_depot(state: State<'_, AppState>, depot: String, content: String) -> Result<(), String> { 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())?; ensure_under_root(&conn, &local)?; // ensure the file is open for edit (ignored for a not-yet-submitted add, or // if it is already open); this also clears the read-only attribute let _ = run_text(&conn, &["edit", &depot]); // belt-and-suspenders: clear read-only in case `p4 edit` didn't apply if let Ok(md) = std::fs::metadata(&local) { let mut perms = md.permissions(); #[allow(clippy::permissions_set_readonly_false)] perms.set_readonly(false); let _ = std::fs::set_permissions(&local, perms); } std::fs::write(&local, content.as_bytes()).map_err(|e| format!("Could not write file: {e}"))?; Ok(()) } /// 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())?; check_local_size(&local)?; std::fs::read(&local) .map(tauri::ipc::Response::new) .map_err(|e| format!("Could not read file: {e}")) } /// Read raw file bytes at an exact revision via `p4 print` (works for /// submitted / historical revisions that may not be synced locally). /// `spec` is a depot path optionally suffixed with a revision, e.g. /// `//depot/foo.cpp#7`. `-q` suppresses the leading file header line. #[tauri::command] async fn print_depot(state: State<'_, AppState>, spec: String) -> Result { let conn = current(&state)?; // size guard: reject huge revisions before buffering them via `p4 print` if let Ok(sizes) = run_json(&conn, &["sizes", &spec]) { if let Some(sz) = sizes.first().and_then(|v| v.get("fileSize")).and_then(|s| s.as_str()).and_then(|s| s.parse::().ok()) { if sz > MAX_READ_BYTES { return Err(format!( "File is too large to preview ({} MB, limit {} MB)", sz / (1024 * 1024), MAX_READ_BYTES / (1024 * 1024) )); } } } let out = p4_cmd(&conn, false) .args(["print", "-q", &spec]) .output() .map_err(|e| format!("Failed to start p4: {e}"))?; if !out.status.success() && out.stdout.is_empty() { let e = decode(&out.stderr).trim().to_string(); log_p4(&["print", "-q", &spec], 0, false, Some(&e)); return Err(if e.is_empty() { "p4 print failed".into() } else { e }); } if out.stdout.len() as u64 > MAX_READ_BYTES { return Err("File is too large to preview".into()); } log_p4(&["print", "-q", &spec], out.stdout.len() as i64, true, None); Ok(tauri::ipc::Response::new(out.stdout)) } /// Run an arbitrary `p4` command for the built-in terminal and return combined /// stdout+stderr as text. Never errors on a non-zero exit — the terminal shows /// the error text just like a real console would. #[tauri::command] async fn p4_console(state: State<'_, AppState>, args: Vec) -> Result { let conn = current(&state)?; let refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); // stdin is null (p4_cmd default) so prompting commands fail fast instead of // hanging; a failing P4EDITOR aborts editor-spawning forms (change, submit // without -d, client, …) the same way. let out = p4_cmd(&conn, false) .env("P4EDITOR", "cmd /c exit 1") .args(&refs) .output() .map_err(|e| format!("Failed to start p4: {e}"))?; let mut s = decode(&out.stdout); let err = decode(&out.stderr); if !err.trim().is_empty() { if !s.is_empty() && !s.ends_with('\n') { s.push('\n'); } s.push_str(&err); } let ok = out.status.success(); log_p4(&refs, s.lines().count() as i64, ok, if ok { None } else { Some(err.trim()) }); Ok(s) } /// Tail the newest Unreal log for a project. `uproject` is the local path of the /// `.uproject`; the log lives at `/Saved/Logs/*.log`. Returns the /// last `tail_bytes` bytes (empty string if no log yet — e.g. UE not running). #[tauri::command] async fn ue_log(state: State<'_, AppState>, uproject: String, tail_bytes: u64) -> Result { use std::path::Path; let conn = current(&state)?; ensure_under_root(&conn, &uproject)?; let dir = Path::new(&uproject) .parent() .ok_or("bad uproject path")? .join("Saved") .join("Logs"); if !dir.is_dir() { return Ok(String::new()); } let mut newest: Option<(std::time::SystemTime, std::path::PathBuf)> = None; for e in std::fs::read_dir(&dir).map_err(|e| e.to_string())?.flatten() { let p = e.path(); if p.extension().and_then(|x| x.to_str()) == Some("log") { if let Ok(m) = e.metadata().and_then(|m| m.modified()) { if newest.as_ref().map_or(true, |(t, _)| m > *t) { newest = Some((m, p)); } } } } let Some((_, path)) = newest else { return Ok(String::new()) }; let data = std::fs::read(&path).map_err(|e| e.to_string())?; let start = data.len().saturating_sub(tail_bytes as usize); Ok(decode(&data[start..])) } /// Files opened by ANYONE in the scope (`p4 opened -a`) — powers the /// "who is holding this file" indicators and exclusive-checkout awareness. #[tauri::command] async fn p4_opened_all(state: State<'_, AppState>, scope: String) -> Result, String> { let conn = current(&state)?; if scope.is_empty() { run_json(&conn, &["opened", "-a"]) } else { run_json(&conn, &["opened", "-a", &scope_spec(&scope)]) } } /// Lock opened files so nobody else can submit them (binary-asset safety). #[tauri::command] async fn p4_lock(state: State<'_, AppState>, files: Vec) -> Result { let conn = current(&state)?; let mut args: Vec<&str> = vec!["lock"]; for f in &files { args.push(f.as_str()); } run_text(&conn, &args) } /// Release locks. #[tauri::command] async fn p4_unlock(state: State<'_, AppState>, files: Vec) -> Result { let conn = current(&state)?; let mut args: Vec<&str> = vec!["unlock"]; for f in &files { args.push(f.as_str()); } run_text(&conn, &args) } /// Read the server typemap as a list of "TYPE PATTERN" entries. /// (Empty list if the typemap is unset. Requires admin to *change*, not to read.) #[tauri::command] async fn p4_typemap_get(state: State<'_, AppState>) -> Result, String> { let conn = current(&state)?; let spec = run_text(&conn, &["typemap", "-o"])?; let mut entries = Vec::new(); let mut in_map = false; for line in spec.lines() { if line.starts_with("TypeMap:") { in_map = true; continue; } if in_map { if line.starts_with('\t') || line.starts_with(' ') { let e = line.trim(); if !e.is_empty() && !e.starts_with('#') { entries.push(e.to_string()); } } else if !line.trim().is_empty() { break; // reached the next spec field } } } Ok(entries) } /// Replace the server typemap with the given "TYPE PATTERN" entries. /// Requires admin rights on the server. #[tauri::command] async fn p4_typemap_set(state: State<'_, AppState>, entries: Vec) -> Result { let conn = current(&state)?; let mut spec = String::from("TypeMap:\n"); for e in &entries { let e = e.trim(); if !e.is_empty() { spec.push('\t'); spec.push_str(e); spec.push('\n'); } } let mut child = p4_cmd(&conn, false) .args(["typemap", "-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() { let err = decode(&out.stderr); let msg = if err.trim().is_empty() { decode(&out.stdout) } else { err }; let low = msg.to_lowercase(); return Err(if low.contains("permission") || low.contains("protect") || low.contains("admin") || low.contains("not allowed") { "You need admin rights on the server to change the typemap.".to_string() } else { msg.trim().to_string() }); } Ok(decode(&out.stdout).trim().to_string()) } /// Change the Perforce file type of opened files (e.g. add "+l" exclusive lock). /// `filetype` is passed to `p4 reopen -t`, so "+l" adds the modifier and /// "binary+l" sets the full type. #[tauri::command] async fn p4_reopen_type(state: State<'_, AppState>, files: Vec, filetype: String) -> Result { let conn = current(&state)?; if files.is_empty() { return Ok(String::new()); } let mut args: Vec<&str> = vec!["reopen", "-t", &filetype]; for f in &files { args.push(f.as_str()); } run_text(&conn, &args) } /// Extract the largest embedded PNG from a byte blob — this is how an uncooked /// Unreal `.uasset` stores its Content-Browser thumbnail (PNG-compressed in the /// package thumbnail table). We scan for the PNG signature → IEND and keep the /// biggest hit (the real thumbnail, not some tiny incidental chunk). No engine, /// no CUE4Parse — exactly the approach Anchorpoint uses for its previews. fn extract_png(b: &[u8]) -> Option> { const SIG: [u8; 8] = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; let mut best: Option<(usize, usize)> = None; // (start, end) let mut i = 0usize; while i + 8 < b.len() { if b[i..i + 8] == SIG { // find the IEND chunk that closes this PNG let mut j = i + 8; let mut end = None; while j + 8 <= b.len() { if &b[j..j + 4] == b"IEND" { end = Some(j + 8); // IEND + 4-byte CRC break; } j += 1; } let e = end.unwrap_or(b.len()); if best.map_or(true, |(s0, e0)| (e - i) > (e0 - s0)) { best = Some((i, e)); } i = e; // continue scanning after this PNG } else { i += 1; } } best.map(|(s, e)| b[s..e].to_vec()) } /// Read the bytes of a `.uasset` (working copy or an exact submitted revision) /// and return ONLY its embedded thumbnail PNG. Reading happens server-side so a /// huge asset never crosses the IPC bridge — just the small thumbnail does, and /// the 64 MB preview cap does not apply here. #[tauri::command] async fn uasset_thumbnail(state: State<'_, AppState>, spec: String, historical: bool) -> Result { let conn = current(&state)?; // avoid buffering absurdly large assets just to grab a thumbnail const MAX_UASSET: u64 = 400 * 1024 * 1024; let bytes: Vec = if historical { // exact revision → p4 print if let Ok(sizes) = run_json(&conn, &["sizes", &spec]) { if let Some(sz) = sizes.first().and_then(|v| v.get("fileSize")).and_then(|s| s.as_str()).and_then(|s| s.parse::().ok()) { if sz > MAX_UASSET { return Err("Asset too large for a thumbnail".into()); } } } let out = p4_cmd(&conn, false) .args(["print", "-q", &spec]) .output() .map_err(|e| format!("Failed to start p4: {e}"))?; if !out.status.success() && out.stdout.is_empty() { return Err(decode(&out.stderr).trim().to_string()); } out.stdout } else { // working copy → resolve local path and read it let local = run_json(&conn, &["where", &spec])? .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())?; if let Ok(m) = std::fs::metadata(&local) { if m.len() > MAX_UASSET { return Err("Asset too large for a thumbnail".into()); } } std::fs::read(&local).map_err(|e| format!("Could not read file: {e}"))? }; match extract_png(&bytes) { Some(png) => Ok(tauri::ipc::Response::new(png)), None => Err("No embedded thumbnail".into()), } } /// Python run inside a headless Unreal editor to export one mesh asset to FBX. /// Args come via env vars (EXD_ASSET = /Game path, EXD_OUT = output .fbx). const EXPORT_PY: &str = r#" import unreal, os def log(m): unreal.log("EXD_EXPORT: " + str(m)) ap = os.environ.get("EXD_ASSET", "") op = os.environ.get("EXD_OUT", "") if not ap or not op: log("missing env"); raise SystemExit(1) asset = unreal.EditorAssetLibrary.load_asset(ap) if asset is None: log("asset not found: " + ap); raise SystemExit(2) task = unreal.AssetExportTask() task.set_editor_property("object", asset) task.set_editor_property("filename", op) task.set_editor_property("automated", True) task.set_editor_property("prompt", False) task.set_editor_property("replace_identical", True) if isinstance(asset, unreal.StaticMesh): opt = unreal.FbxExportOption(); opt.set_editor_property("collision", False); opt.set_editor_property("level_of_detail", False) task.set_editor_property("options", opt); task.set_editor_property("exporter", unreal.StaticMeshExporterFBX()) elif isinstance(asset, unreal.SkeletalMesh): opt = unreal.FbxExportOption(); opt.set_editor_property("collision", False); opt.set_editor_property("level_of_detail", False) task.set_editor_property("options", opt); task.set_editor_property("exporter", unreal.SkeletalMeshExporterFBX()) else: log("unsupported type: " + asset.get_class().get_name()); raise SystemExit(3) ok = unreal.Exporter.run_asset_export_task(task) log("result " + str(ok)) raise SystemExit(0 if ok else 4) "#; /// Query a Windows registry value (returns the REG_SZ string, if any). fn reg_query(key: &str, value: &str) -> Option { let mut c = Command::new("reg"); #[cfg(windows)] { use std::os::windows::process::CommandExt; c.creation_flags(0x0800_0000); } let out = c.stdin(Stdio::null()).args(["query", key, "/v", value]).output().ok()?; if !out.status.success() { return None; } let s = decode(&out.stdout); for line in s.lines() { if let Some(idx) = line.find("REG_SZ") { let val = line[idx + 6..].trim(); if !val.is_empty() { return Some(val.to_string()); } } } None } /// Locate UnrealEditor-Cmd.exe for a project's engine (version or source-build GUID). fn unreal_editor_cmd(uproject: &str) -> Option { let assoc = std::fs::read_to_string(uproject) .ok() .and_then(|s| serde_json::from_str::(&s).ok()) .and_then(|v| v.get("EngineAssociation").and_then(|e| e.as_str()).map(String::from)) .unwrap_or_default(); let bin = |root: &str| -> Option { for name in ["UnrealEditor-Cmd.exe", "UE4Editor-Cmd.exe"] { let p = format!("{root}\\Engine\\Binaries\\Win64\\{name}"); if std::path::Path::new(&p).exists() { return Some(p); } } None }; let pf = std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".into()); // launcher install "UE_5.x" by version, else registry, else source-build GUID if assoc.chars().next().map_or(false, |c| c.is_ascii_digit()) { if let Some(b) = bin(&format!("{pf}\\Epic Games\\UE_{assoc}")) { return Some(b); } if let Some(dir) = reg_query(&format!("HKLM\\SOFTWARE\\EpicGames\\Unreal Engine\\{assoc}"), "InstalledDirectory") { if let Some(b) = bin(dir.trim()) { return Some(b); } } } else if !assoc.is_empty() { if let Some(dir) = reg_query("HKCU\\SOFTWARE\\Epic Games\\Unreal Engine\\Builds", &assoc) { if let Some(b) = bin(dir.trim()) { return Some(b); } } } // fallback: any UE_* under Program Files\Epic Games if let Ok(rd) = std::fs::read_dir(format!("{pf}\\Epic Games")) { for e in rd.flatten() { let p = e.path(); if p.is_dir() && p.file_name().and_then(|n| n.to_str()).map_or(false, |n| n.starts_with("UE_")) { if let Some(b) = bin(&p.to_string_lossy()) { return Some(b); } } } } None } /// Map a local `.uasset` path to its Unreal object path: /// /Content/Foo.uasset → /Game/Foo /// /Plugins//Content/Bar… → //Bar (plugin mount point) fn content_to_game(uproject: &str, local: &str) -> Option { let strip_ext = |s: &str| -> String { for ext in [".uasset", ".umap"] { if let Some(x) = s.strip_suffix(ext) { return x.to_string(); } } s.to_string() }; let proj_dir = std::path::Path::new(uproject).parent()?; let norm = local.replace('\\', "/"); // project Content → /Game/ let content = proj_dir.join("Content"); if let Ok(rel) = std::path::Path::new(local).strip_prefix(&content) { return Some(format!("/Game/{}", strip_ext(&rel.to_string_lossy().replace('\\', "/")))); } // plugin Content → // (…/Plugins/…//Content/) if let Some(pidx) = norm.find("/Plugins/") { let after = &norm[pidx + "/Plugins/".len()..]; if let Some(cidx) = after.find("/Content/") { let plugin_path = &after[..cidx]; // may be nested, e.g. "GameFeatures/GASPALS" let mount = plugin_path.rsplit('/').next().unwrap_or(plugin_path); let rest = &after[cidx + "/Content/".len()..]; return Some(format!("/{mount}/{}", strip_ext(rest))); } } None } /// Export a `.uasset` mesh to FBX by driving a headless Unreal editor, so the /// app's 3D viewport can show a real, rotatable model. The FBX is written to a /// temp file that is deleted immediately after reading — nothing is left on disk; /// the model exists only in the preview's memory while it is open. #[tauri::command] async fn uasset_export_3d(state: State<'_, AppState>, depot: String, uproject: String) -> Result { let conn = current(&state)?; if uproject.is_empty() { return Err("Not an Unreal project — pick the project working folder first.".into()); } 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())?; if !std::path::Path::new(&local).exists() { return Err("Asset is not synced to disk yet — Get Latest first.".into()); } ensure_under_root(&conn, &local)?; let game = content_to_game(&uproject, &local) .ok_or_else(|| "Asset is outside the project's Content folder (3D export supports /Game assets).".to_string())?; let editor = unreal_editor_cmd(&uproject) .ok_or_else(|| "UnrealEditor-Cmd.exe not found — is Unreal Engine installed for this project's version?".to_string())?; // export to a temp file that is deleted the moment we've read it — nothing is // cached on disk; the model lives only in the preview's memory while open let safe: String = game.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect(); let out_fbx = std::env::temp_dir().join(format!("exd_preview_{safe}.fbx")); let _ = std::fs::remove_file(&out_fbx); // clear any stale leftover let script = std::env::temp_dir().join("exd_uasset_export.py"); std::fs::write(&script, EXPORT_PY).map_err(|e| e.to_string())?; let mut c = Command::new(&editor); #[cfg(windows)] { use std::os::windows::process::CommandExt; c.creation_flags(0x0800_0000); } // commandlet mode (-run=pythonscript) + -nullrhi → no editor window appears c.arg(&uproject) .arg("-run=pythonscript") .arg(format!("-script={}", script.to_string_lossy())) .args(["-unattended", "-nosplash", "-nullrhi", "-nop4", "-NoLogTimes", "-NoLoadStartupPackages"]) .env("EXD_ASSET", &game) .env("EXD_OUT", out_fbx.to_string_lossy().to_string()) .stdin(Stdio::null()) .stdout(Stdio::null()) // Unreal logs a lot; ignore it to avoid a full-pipe deadlock .stderr(Stdio::null()); let mut child = c.spawn().map_err(|e| format!("Failed to launch Unreal: {e}"))?; // poll with a hard timeout so a stuck editor never wedges the backend let deadline = Instant::now() + Duration::from_secs(360); loop { match child.try_wait() { Ok(Some(_)) => break, Ok(None) => { if Instant::now() > deadline { let _ = child.kill(); return Err("Unreal export timed out (6 min).".into()); } std::thread::sleep(Duration::from_millis(400)); } Err(e) => return Err(e.to_string()), } } if !out_fbx.exists() { return Err("Unreal produced no file — the asset may not be a Static/Skeletal Mesh, or the editor failed to start.".into()); } let data = std::fs::read(&out_fbx).map_err(|e| e.to_string())?; let _ = std::fs::remove_file(&out_fbx); // delete right away — leave nothing behind if data.is_empty() { return Err("Exported model is empty.".into()); } Ok(tauri::ipc::Response::new(data)) } /// Shelve a pending changelist's files on the server (share work-in-progress). #[tauri::command] async fn p4_shelve(state: State<'_, AppState>, change: String) -> Result { let conn = current(&state)?; run_text(&conn, &["shelve", "-f", "-c", &change]) } /// Unshelve files from a shelved changelist into the workspace. #[tauri::command] async fn p4_unshelve(state: State<'_, AppState>, change: String) -> Result { let conn = current(&state)?; run_text(&conn, &["unshelve", "-s", &change, "-f"]) } /// Delete the shelved files of a changelist. #[tauri::command] async fn p4_delete_shelf(state: State<'_, AppState>, change: String) -> Result { let conn = current(&state)?; run_text(&conn, &["shelve", "-d", "-c", &change]) } /// Shelved files of a changelist (describe -S). #[tauri::command] async fn p4_shelved_files(state: State<'_, AppState>, change: String) -> Result { let conn = current(&state)?; let v = run_json(&conn, &["describe", "-s", "-S", &change])?; Ok(v.into_iter().next().unwrap_or(Value::Null)) } /// Files that need conflict resolution after a sync (`p4 resolve -n`). #[tauri::command] async fn p4_resolve_list(state: State<'_, AppState>) -> Result, String> { let conn = current(&state)?; // -n = preview only; never prompts run_json(&conn, &["resolve", "-n"]).or_else(|e| { // "no file(s) to resolve" is not an error — just an empty list if e.to_lowercase().contains("no file") { Ok(Vec::new()) } else { Err(e) } }) } /// Resolve one file (or everything when `file` is empty). /// mode: "am" auto-merge · "ay" accept yours · "at" accept theirs. #[tauri::command] async fn p4_resolve_file(state: State<'_, AppState>, file: String, mode: String) -> Result { let conn = current(&state)?; let flag = match mode.as_str() { "ay" => "-ay", "at" => "-at", _ => "-am", }; if file.is_empty() { run_text(&conn, &["resolve", flag]) } else { run_text(&conn, &["resolve", flag, &file]) } } /// Propagate changes between branches/streams: op is "merge" (with resolve), /// "copy" (overwrite target), or "integrate" (classic). Opens the affected files /// into a fresh numbered changelist; the user then resolves (if needed) and /// submits via the normal pending-changelist flow. Returns the changelist number. #[tauri::command] async fn p4_branch_op(state: State<'_, AppState>, op: String, source: String, target: String) -> Result { let conn = current(&state)?; let cmd = match op.as_str() { "merge" => "merge", "copy" => "copy", _ => "integrate", }; let src = source.trim(); let tgt = target.trim(); if src.is_empty() || tgt.is_empty() { return Err("Source and target paths are required".into()); } let cl = create_changelist(&conn, &format!("{cmd} {src} -> {tgt}"))?; let out = run_text(&conn, &[cmd, "-c", &cl, src, tgt]).unwrap_or_default(); Ok(format!("CL {cl}\n{}", out.trim())) } /// Unified diff between two exact revisions (`p4 diff2 -du`). #[tauri::command] async fn p4_diff2(state: State<'_, AppState>, spec1: String, spec2: String) -> Result { let conn = current(&state)?; run_text(&conn, &["diff2", "-du", &spec1, &spec2]) } /// Per-line blame: `p4 annotate -c -u -q` (changelist + user per line). #[tauri::command] async fn p4_annotate(state: State<'_, AppState>, spec: String) -> Result { let conn = current(&state)?; let mut s = run_text(&conn, &["annotate", "-c", "-u", "-q", &spec])?; if s.len() > 2_000_000 { s.truncate(2_000_000); s.push_str("\n… (truncated)"); } Ok(s) } /// Depot file search by name fragment (case-preserving p4 wildcards). #[tauri::command] async fn p4_search_files(state: State<'_, AppState>, query: String, scope: String) -> Result, String> { let conn = current(&state)?; let q = query.trim(); if q.is_empty() { return Ok(Vec::new()); } let base = if scope.is_empty() { "//".to_string() } else { format!("{}/", scope.trim_end_matches(['/', '\\'])) }; // `...` spans directories; `*` stays within a segment → "name contains q" let spec = format!("{base}...{q}*"); run_json(&conn, &["files", "-e", "-m", "200", &spec]).or_else(|e| { if e.to_lowercase().contains("no such file") { Ok(Vec::new()) } else { Err(e) } }) } /// Move opened files into another pending changelist ("default" allowed). #[tauri::command] async fn p4_reopen_to(state: State<'_, AppState>, change: String, files: Vec) -> Result { let conn = current(&state)?; if files.is_empty() { return Ok(String::new()); } let mut args: Vec<&str> = vec!["reopen", "-c", &change]; for f in &files { args.push(f.as_str()); } run_text(&conn, &args) } /// Newest submitted changelist in the scope — cheap poll for "new submit" toasts. #[tauri::command] async fn p4_latest_change(state: State<'_, AppState>, scope: String) -> Result { let conn = current(&state)?; let spec = if scope.is_empty() { "//...".to_string() } else { scope_spec(&scope) }; let v = run_json(&conn, &["changes", "-s", "submitted", "-m", "1", &spec])?; Ok(v.into_iter().next().unwrap_or(Value::Null)) } /// Get a workspace (client) spec as editable text. An unknown name returns a /// fresh template — used to create a new workspace. #[tauri::command] async fn p4_client_spec(state: State<'_, AppState>, client: String) -> Result { let conn = current(&state)?; if client.trim().is_empty() { return Err("No workspace name".into()); } run_text(&conn, &["client", "-o", client.trim()]) } /// Save a workspace (client) spec (`p4 client -i`). Creates it if new. #[tauri::command] async fn p4_client_save(state: State<'_, AppState>, spec: String) -> Result { let conn = current(&state)?; let mut child = p4_cmd(&conn, false) .args(["client", "-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() { let err = decode(&out.stderr); return Err(if err.trim().is_empty() { decode(&out.stdout).trim().to_string() } else { err.trim().to_string() }); } Ok(decode(&out.stdout).trim().to_string()) } /// 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_or_else(|e| e.into_inner()); if c.port.is_empty() { return Err("No active connection".into()); } c.client = client; c.clone() }; let info = run_json(&conn, &["info"])?; let info = info.into_iter().next().unwrap_or(Value::Null); // refresh the cached client root for the new workspace let root = info.get("clientRoot").and_then(|r| r.as_str()).unwrap_or("").to_string(); state.0.lock().unwrap_or_else(|e| e.into_inner()).root = root; Ok(info) } /// 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"]) } /// All Perforce users (with last-access time → activity indicator). #[tauri::command] async fn p4_users(state: State<'_, AppState>) -> Result, String> { let conn = current(&state)?; run_json(&conn, &["users"]) } /// All groups on the server, or the groups a given user belongs to. #[tauri::command] async fn p4_groups(state: State<'_, AppState>, user: String) -> Result, String> { let conn = current(&state)?; if user.is_empty() { run_json(&conn, &["groups"]) } else { run_json(&conn, &["groups", "-u", &user]) } } /// Add or remove a user from a group (role assignment). Requires admin/super. /// Edits the group spec's Users list and pipes it back via `group -i`. #[tauri::command] async fn p4_group_member( state: State<'_, AppState>, group: String, user: String, add: bool, ) -> Result { let conn = current(&state)?; let spec = run_text(&conn, &["group", "-o", &group])?; // collect existing Users: entries (indented under the "Users:" field) let mut users: Vec = Vec::new(); let mut in_users = false; let mut head = String::new(); for line in spec.lines() { if line.starts_with("Users:") { in_users = true; continue; } if in_users { if line.starts_with('\t') || line.starts_with(' ') { let u = line.trim().to_string(); if !u.is_empty() { users.push(u); } continue; } else if line.trim().is_empty() { continue; } else { in_users = false; // next field — stop collecting } } if !in_users { head.push_str(line); head.push('\n'); } } users.retain(|u| !u.eq_ignore_ascii_case(&user)); if add { users.push(user.clone()); } // rebuild spec: head (everything except Users:) + fresh Users section let mut out = head.trim_end().to_string(); out.push_str("\n\nUsers:\n"); for u in &users { out.push('\t'); out.push_str(u); out.push('\n'); } let mut child = p4_cmd(&conn, false) .args(["group", "-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(out.as_bytes()).map_err(|e| e.to_string())?; } let res = child.wait_with_output().map_err(|e| e.to_string())?; if !res.status.success() { return Err(decode(&res.stderr).trim().to_string()); } Ok(decode(&res.stdout).trim().to_string()) } /// OpenRouter API key baked into the binary at compile time from the /// git-ignored file src-tauri/openrouter.key. const BAKED_AI_KEY: &str = include_str!("../openrouter.key"); /// Path to the (git-ignored) OpenRouter API key, stored in the app config dir. /// Kept as a fallback/override location for builds without a baked key. fn ai_key_path() -> Option { let app = APP.get()?; let dir = app.path().app_config_dir().ok()?; Some(dir.join("openrouter.key")) } #[tauri::command] async fn read_ai_key() -> Result { let baked = BAKED_AI_KEY.trim(); if !baked.is_empty() { return Ok(baked.to_string()); } Ok(ai_key_path() .and_then(|p| std::fs::read_to_string(p).ok()) .map(|s| s.trim().to_string()) .unwrap_or_default()) } #[tauri::command] async fn save_ai_key(key: String) -> Result<(), String> { let p = ai_key_path().ok_or("No config dir")?; if let Some(dir) = p.parent() { let _ = std::fs::create_dir_all(dir); } std::fs::write(&p, key.trim()).map_err(|e| e.to_string()) } #[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 .setup(|app| { // stash the app handle so p4 helpers can emit the live command log let _ = APP.set(app.handle().clone()); Ok(()) }) .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_reopen_default, p4_set_desc, p4_add, p4_edit, p4_delete, p4_reconcile, p4_scan, p4_undo_change, open_in_explorer, find_uproject, find_sln, build_sln, launch_file, list_editors, open_in_editor, p4_describe, read_file, write_depot, read_depot, print_depot, p4_console, ue_log, p4_opened_all, p4_lock, p4_unlock, p4_typemap_get, p4_typemap_set, p4_reopen_type, p4_sync_to, p4_labels, p4_label_tag, p4_branch_op, p4_client_spec, p4_client_save, uasset_thumbnail, uasset_export_3d, p4_shelve, p4_unshelve, p4_delete_shelf, p4_shelved_files, p4_resolve_list, p4_resolve_file, p4_diff2, p4_annotate, p4_search_files, p4_reopen_to, p4_latest_change, p4_switch_client, p4_depots, p4_users, p4_groups, p4_group_member, read_ai_key, save_ai_key, p4_dirs, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }