- Tools -> Integrate / Merge / Copy: pick op + source/target paths, runs into a new pending changelist; resolve (if needed) via the Resolve UI and submit - Backend p4 merge/copy/integrate -c Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2137 lines
80 KiB
Rust
2137 lines
80 KiB
Rust
// 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<AppHandle> = 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<Conn>);
|
|
|
|
/// 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<Vec<Value>, 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<Vec<Value>, 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<Value, String> {
|
|
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<Value, String> {
|
|
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<AppState>) -> Result<Conn, String> {
|
|
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<Value, String> {
|
|
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<Vec<Value>, 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<Vec<Value>, 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<Vec<Value>, 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<Vec<Value>, 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<String, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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<Vec<Value>, 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<String, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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<String>) -> Result<String, String> {
|
|
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<String, String> {
|
|
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<String>,
|
|
) -> Result<String, String> {
|
|
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<String, String> {
|
|
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<String>) -> Result<String, String> {
|
|
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<String, String> {
|
|
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<String>,
|
|
) -> Result<String, String> {
|
|
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<Vec<Value>, 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<String>) -> Result<Vec<Value>, String> {
|
|
files_cmd(¤t(&state)?, "add", &files)
|
|
}
|
|
|
|
/// Open files for edit (check out).
|
|
#[tauri::command]
|
|
async fn p4_edit(state: State<'_, AppState>, files: Vec<String>) -> Result<Vec<Value>, String> {
|
|
files_cmd(¤t(&state)?, "edit", &files)
|
|
}
|
|
|
|
/// Mark files for delete.
|
|
#[tauri::command]
|
|
async fn p4_delete(state: State<'_, AppState>, files: Vec<String>) -> Result<Vec<Value>, 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<Vec<Value>, 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<Vec<Value>, 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<String, String> {
|
|
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<String>, // 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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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<Editor>, id: &str, name: &str, exe: Option<String>, 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<Editor> {
|
|
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<Editor> = 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<Vec<Editor>, 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<String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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<String> = 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<Value, String> {
|
|
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<tauri::ipc::Response, String> {
|
|
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<tauri::ipc::Response, 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())?;
|
|
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<tauri::ipc::Response, String> {
|
|
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::<u64>().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<String>) -> Result<String, String> {
|
|
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 `<ProjectDir>/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<String, String> {
|
|
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<Vec<Value>, 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<String>) -> Result<String, String> {
|
|
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<String>) -> Result<String, String> {
|
|
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<Vec<String>, 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<String>) -> Result<String, String> {
|
|
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<String>, filetype: String) -> Result<String, String> {
|
|
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<Vec<u8>> {
|
|
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<tauri::ipc::Response, String> {
|
|
let conn = current(&state)?;
|
|
// avoid buffering absurdly large assets just to grab a thumbnail
|
|
const MAX_UASSET: u64 = 400 * 1024 * 1024;
|
|
let bytes: Vec<u8> = 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::<u64>().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<String> {
|
|
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<String> {
|
|
let assoc = std::fs::read_to_string(uproject)
|
|
.ok()
|
|
.and_then(|s| serde_json::from_str::<Value>(&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<String> {
|
|
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:
|
|
/// <Project>/Content/Foo.uasset → /Game/Foo
|
|
/// <Project>/Plugins/<Name>/Content/Bar… → /<Name>/Bar (plugin mount point)
|
|
fn content_to_game(uproject: &str, local: &str) -> Option<String> {
|
|
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 → /<PluginName>/ (…/Plugins/…/<Name>/Content/<rest>)
|
|
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<tauri::ipc::Response, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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<Value, String> {
|
|
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<Vec<Value>, 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<String, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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<Vec<Value>, 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<String>) -> Result<String, String> {
|
|
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<Value, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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<Value, String> {
|
|
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<Vec<Value>, 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<Vec<Value>, 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<Vec<Value>, 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<String, String> {
|
|
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<String> = 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<std::path::PathBuf> {
|
|
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<String, String> {
|
|
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");
|
|
}
|