0.3.6 — plugin registry, Unreal extracted to a plugin, in-app update check + fixes

- Official plugin registry hosted on Gitea; client fetches registry.json over
  HTTPS and installs plugins via backend plugin_write_file.
- Auto-install autoInstall plugins on first connect; per-machine enabled state.
- Extract all Unreal tooling (Build Solution / Unreal Log) into a separate
  "unreal" plugin (defaultEnabled) driven through the new host.ue bridge;
  core UI no longer hard-wires Unreal.
- Settings → Plugins: "Official plugins" section with Install buttons.
- Status bar: "Updates" button (right of Log) triggers an update check without
  restarting the client.
- Fixes: NEW badge hard-right/centered without shifting text; History commit
  size no longer flickers (cached per change); giant icons in empty plugin/
  People lists; Get Latest hover contrast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-09 15:13:02 +03:00
parent f0fc2f687a
commit 8e7908ae09
13 changed files with 283 additions and 72 deletions

View File

@ -1,7 +1,7 @@
{
"name": "exbyte-depot",
"private": true,
"version": "0.3.5",
"version": "0.3.6",
"type": "module",
"scripts": {
"dev": "vite",

View File

@ -0,0 +1,46 @@
// Unreal Engine Tools — official plugin.
// The core app keeps the build engine (UnrealBuildTool / MSBuild), the build
// overlay and the log reader; this plugin is just the Unreal-facing surface:
// a "Build Solution" action and a live Unreal-log view. Reached through the
// permission-gated `host.ue` bridge.
export function activate(host) {
const ue = host.ue;
if (!ue) { host.log("no ue bridge — plugin.json needs the \"ue\" permission"); return; }
// Build the game target (UnrealBuildTool for the game module, MSBuild for plain C++).
host.ui.addMenuItem("🔨 Build Solution (Unreal)", () => {
if (!ue.hasProject()) { host.flash("No Unreal project in the current working folder.", true); return; }
ue.build(); // triggers the core build flow + overlay
});
// Live Unreal editor log (tails Saved/Logs). Opens from the Actions menu.
host.ui.addView({
id: "unreal-log",
title: "Unreal Log",
render: (el) => {
el.style.cssText = "display:flex;flex-direction:column;height:100%;min-height:340px";
const pre = document.createElement("pre");
pre.style.cssText =
"flex:1;margin:0;overflow:auto;white-space:pre-wrap;word-break:break-word;" +
"font:11.5px/1.6 var(--mono,ui-monospace,monospace);color:var(--muted,#9a9aa8)";
el.appendChild(pre);
let alive = true;
async function tick() {
if (!alive) return;
try {
if (!ue.hasProject()) { pre.textContent = "No Unreal project in the current working folder."; return; }
const atBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 30;
const txt = await ue.readLog(200000);
pre.textContent = txt || "No Unreal log yet. It appears when the editor writes to Saved/Logs (i.e. while Unreal is running).";
if (atBottom) pre.scrollTop = pre.scrollHeight;
} catch (e) { pre.textContent = String(e); }
}
tick();
const id = setInterval(tick, 1500);
return () => { alive = false; clearInterval(id); };
},
});
host.log("Unreal Engine Tools activated");
}

View File

@ -0,0 +1,12 @@
{
"id": "unreal",
"name": "Unreal Engine Tools",
"version": "1.0.0",
"author": "Exbyte Studios",
"description": "Build the game (.sln / UnrealBuildTool) and tail the Unreal editor log. Enable this on Unreal Engine projects.",
"entry": "index.js",
"minAppVersion": "0.3.6",
"permissions": ["ue"],
"defaultEnabled": true,
"contributes": { "menu": true, "views": ["unreal-log"] }
}

2
src-tauri/Cargo.lock generated
View File

@ -966,7 +966,7 @@ dependencies = [
[[package]]
name = "exbyte-depot"
version = "0.3.5"
version = "0.3.6"
dependencies = [
"encoding_rs",
"serde",

View File

@ -1,6 +1,6 @@
[package]
name = "exbyte-depot"
version = "0.3.5"
version = "0.3.6"
description = "Exbyte Depot — native Perforce client by Exbyte Studios"
authors = ["Exbyte Studios"]
edition = "2021"

View File

@ -2923,7 +2923,10 @@ async fn plugin_list() -> Result<Vec<Value>, String> {
let Ok(m) = serde_json::from_str::<Value>(&txt) else { continue };
let id = m.get("id").and_then(|v| v.as_str()).map(String::from)
.unwrap_or_else(|| e.file_name().to_string_lossy().to_string());
let enabled = *en.get(&id).unwrap_or(&true);
// off by default unless the manifest opts in (defaultEnabled) — official
// plugins like Unreal ship on; the user's choice persists in enabled.json
let default_on = m.get("defaultEnabled").and_then(|v| v.as_bool()).unwrap_or(false);
let enabled = *en.get(&id).unwrap_or(&default_on);
out.push(serde_json::json!({
"id": id,
"name": m.get("name").and_then(|v| v.as_str()).unwrap_or(""),
@ -2967,6 +2970,23 @@ async fn plugin_set_enabled(id: String, enabled: bool) -> Result<(), String> {
Ok(())
}
/// Write one file of a plugin into <config>/plugins/<id>/<name>. The frontend
/// fetches the file bytes from the registry over HTTPS and hands the content here;
/// this only touches the confined plugins folder (id + name are path-validated).
#[tauri::command]
async fn plugin_write_file(id: String, name: String, content: String) -> Result<(), String> {
if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') || id.contains("..") {
return Err("Invalid plugin id".into());
}
if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") {
return Err("Invalid file name".into());
}
let dir = plugins_dir().ok_or("No config dir")?.join(&id);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
std::fs::write(dir.join(&name), content).map_err(|e| e.to_string())?;
Ok(())
}
fn copy_dir_recursive(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> {
std::fs::create_dir_all(to)?;
for e in std::fs::read_dir(from)? {
@ -3125,6 +3145,7 @@ pub fn run() {
plugin_list,
plugin_read_entry,
plugin_set_enabled,
plugin_write_file,
plugin_install,
plugin_remove,
plugins_dir_path,

View File

@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2",
"productName": "Exbyte Depot",
"mainBinaryName": "Exbyte Depot",
"version": "0.3.5",
"version": "0.3.6",
"identifier": "com.bonchellon.exbyte-depot",
"build": {
"beforeDevCommand": "npm run dev",
@ -24,8 +24,8 @@
}
],
"security": {
"csp": "default-src 'self'; script-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost https://openrouter.ai; worker-src 'self' blob:; object-src 'none'; base-uri 'self'",
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420 https://openrouter.ai; worker-src 'self' blob:; object-src 'none'; base-uri 'self'"
"csp": "default-src 'self'; script-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost https://openrouter.ai https://gitea.exbytestudios.com; worker-src 'self' blob:; object-src 'none'; base-uri 'self'",
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420 https://openrouter.ai https://gitea.exbytestudios.com; worker-src 'self' blob:; object-src 'none'; base-uri 'self'"
}
},
"bundle": {

View File

@ -94,7 +94,11 @@ input,textarea,[contenteditable="true"],
.tgroup{display:flex;min-width:0;position:relative}
.tgroup .tzone{flex:1;min-width:0}
.tgroup .tzone:last-child{flex:0 0 var(--right-zone-w,220px);min-width:160px;border-right:none}
.tzone:last-child{border-right:none}.tzone:hover{background:rgba(124,110,246,.06)}
.tzone:last-child{border-right:none}
.tzone:hover{background:var(--hover)}
/* keep the labels/icon high-contrast on hover in every theme (fixes greyed-out text) */
.tzone:hover .val{color:var(--txt)}.tzone:hover .lbl,.tzone:hover .sub{color:var(--muted)}
.sync:hover .ic{color:var(--accent-2)}.sync:hover .val{color:var(--txt)}.sync:hover .sub{color:var(--muted)}
/* draggable panel dividers */
.vhandle{position:absolute;top:0;bottom:0;width:9px;cursor:col-resize;z-index:8}
@ -120,9 +124,11 @@ body.resizing{cursor:col-resize!important;user-select:none}
.tab{flex:1;display:flex;align-items:center;justify-content:center;gap:8px;font-size:13px;font-weight:600;
color:var(--muted);cursor:pointer;position:relative;transition:color .2s;background:none;border:none}
.tab:hover{color:var(--txt)}.tab.on{color:var(--txt)}
.tab:focus{outline:none}
.tab:focus-visible{outline:none;box-shadow:inset 0 0 0 2px rgba(124,110,246,.35)}
.tab:active{transform:scale(.97)}
/* sliding underline that glides between the two tabs */
.tab-slider{position:absolute;bottom:-1px;height:2px;width:calc(50% - 32px);border-radius:2px 2px 0 0;pointer-events:none;
.tab-slider{position:absolute;bottom:0;height:2px;width:calc(50% - 32px);border-radius:2px;pointer-events:none;
background:linear-gradient(90deg,var(--accent),var(--accent-2));box-shadow:0 0 10px rgba(124,110,246,.6);
transition:left .34s cubic-bezier(.5,.02,.15,1)}
/* content fades + rises slightly when the tab changes */
@ -276,6 +282,10 @@ body.resizing{cursor:col-resize!important;user-select:none}
.upd-ic{width:34px;height:34px;flex:0 0 auto;border-radius:10px;display:grid;place-items:center;color:#fff;
background:linear-gradient(145deg,var(--accent-2),var(--accent-deep));box-shadow:0 0 18px rgba(124,110,246,.5)}
.upd-ic svg{width:18px;height:18px}
.upd.sm{width:auto;max-width:340px;padding:11px 14px}
.upd.sm .upd-ic{width:28px;height:28px}
.upd.sm.ok{cursor:pointer}
.upd-ic.ok{background:linear-gradient(145deg,var(--add),#1f9f6e);box-shadow:0 0 16px rgba(62,207,142,.45)}
.upd-ttl{display:flex;flex-direction:column;gap:1px;min-width:0}
.upd-ttl b{font-size:13.5px}
.upd-ttl span{font-size:11.5px;color:var(--faint);font-variant-numeric:tabular-nums}
@ -721,6 +731,7 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.th-token input[type=color]::-webkit-color-swatch-wrapper{padding:0}
/* plugins manager */
.pl-list{flex:1;min-height:0;overflow:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
.pl-official-h{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint);font-weight:700;margin:8px 2px 0;padding-top:10px;border-top:1px solid var(--border-soft)}
.pl-card{display:flex;gap:12px;align-items:flex-start;border:1px solid var(--border);border-radius:12px;padding:13px 15px;background:var(--panel);transition:.15s}
.pl-card.off{opacity:.55}
.pl-main{flex:1;min-width:0}
@ -785,7 +796,7 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.diff::-webkit-scrollbar{width:10px}.diff::-webkit-scrollbar-thumb{background:var(--panel-3);border-radius:8px}
/* history */
.hrow{display:flex;gap:12px;align-items:flex-start;padding:12px 16px;border-bottom:1px solid var(--border-soft)}
.hrow{display:flex;gap:12px;align-items:flex-start;padding:12px 16px;border-bottom:1px solid var(--border-soft);position:relative}
.hrow.click{cursor:pointer}
.hrow:hover{background:var(--hover)}
.hrow.sel{background:rgba(124,110,246,.12);box-shadow:inset 2px 0 0 var(--accent)}
@ -846,7 +857,7 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.hdesc{font-size:13px;color:var(--txt);overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
.hmeta{font-size:11px;color:var(--faint)}
.hsize{margin-left:7px;padding:1px 6px;border-radius:6px;background:var(--chip);border:1px solid var(--border-soft);color:var(--muted);font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}
.hnew{display:inline-block;vertical-align:middle;margin-right:7px;padding:1px 7px;border-radius:6px;font-size:9.5px;font-weight:800;letter-spacing:.6px;color:#fff;
.hnew{position:absolute;top:50%;right:14px;transform:translateY(-50%);padding:3px 10px;border-radius:7px;font-size:11px;font-weight:800;letter-spacing:.7px;color:#fff;pointer-events:none;
background:linear-gradient(135deg,var(--accent-2),var(--accent-deep));box-shadow:0 0 0 rgba(124,110,246,.5);animation:newpulse 2.2s ease-in-out infinite}
@keyframes newpulse{0%,100%{box-shadow:0 0 0 0 rgba(124,110,246,.45)}50%{box-shadow:0 0 0 4px rgba(124,110,246,0)}}
@ -1110,7 +1121,9 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.ur-actd{width:8px;height:8px;border-radius:50%;flex:0 0 auto;background:var(--faint)}
.ur-actd.active{background:#3ecb8f;box-shadow:0 0 0 3px rgba(62,203,143,.18)}
.ur-actd.recent{background:#f4c04e}.ur-actd.idle{background:#4b4b58}
.ur-empty{padding:16px;font-size:12px;color:var(--faint);text-align:center}
.ur-empty{padding:16px;font-size:12px;color:var(--faint);text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;white-space:pre-line}
.ur-empty svg{width:34px;height:34px;opacity:.35;flex:0 0 auto}
.viewer-msg svg{width:36px;height:36px;opacity:.4;flex:0 0 auto}
.ur-detail{flex:1;display:flex;flex-direction:column;min-width:0;overflow-y:auto}
.ur-dhead{display:flex;align-items:center;gap:14px;padding:18px 20px 14px;border-bottom:1px solid var(--border-soft)}
.ur-dinfo{min-width:0}

View File

@ -12,7 +12,7 @@ import "./App.css";
import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, humanSize, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, recentScopes, pushRecentScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev, FsEntry, PluginInfo } from "./p4";
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
import { Theme, THEME_TOKENS, BUILTIN_THEMES, allThemes, loadCustomThemes, saveCustomThemes, loadActiveId, saveActiveId, resolveTheme, applyTheme, tokenValue, newCustomId, exportTheme, importTheme } from "./themes";
import { loadPlugins, getRegistry, subscribeRegistry, runSubmitHooks, PluginView } from "./plugins";
import { loadPlugins, getRegistry, subscribeRegistry, runSubmitHooks, PluginView, autoInstallOfficial, fetchRegistry, installFromRegistry, RegistryEntry } from "./plugins";
import { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai";
// native OS notification (works even when the window is hidden in the tray)
@ -378,6 +378,8 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null);
const [history, setHistory] = useState<Change[]>([]);
const [changeSizes, setChangeSizes] = useState<Record<string, number>>({}); // per-changelist byte weight for History
const sizesRef = useRef<Record<string, number>>({}); // mirror of changeSizes so we only fetch new changes (no flicker)
const histSizeScope = useRef<string>(""); // scope the cached sizes belong to
const [detail, setDetail] = useState<Describe | null>(null);
const [selChange, setSelChange] = useState<string>(""); // highlighted History row (instant)
const [detailBusy, setDetailBusy] = useState(false); // right-panel files loading
@ -390,6 +392,7 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
const [xferMin, setXferMin] = useState(false); // transfer dialog collapsed to a floating chip (keeps running)
const [showXfer, setShowXfer] = useState(false); // delayed so fast ops don't flash a dialog
const [uproject, setUproject] = useState(""); // local .uproject path when the scope is a UE project
const uprojectRef = useRef(""); // live mirror for the plugin ue-bridge closures
const [slnPath, setSlnPath] = useState(""); // local .sln path in the scope (for building)
const [editors, setEditors] = useState<Editor[]>([]); // code editors installed on this machine
const [editorId, setEditorId] = useState(getEditor()); // chosen editor id ("" → auto-pick)
@ -621,17 +624,32 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
info: () => info,
selectedFile: () => selFile || null,
refresh: () => { void refresh(); },
// Unreal bridge — core keeps buildSln/overlay/log; the plugin just drives them
ue: {
hasProject: () => !!uprojectRef.current,
uproject: () => uprojectRef.current,
build: () => { startBuild(); },
readLog: (tb?: number) => p4.ueLog(uprojectRef.current, tb ?? 200_000),
},
});
setPluginVer((v) => v + 1);
return res;
}
useEffect(() => { uprojectRef.current = uproject; }, [uproject]); // keep the ue-bridge mirror live
useEffect(() => {
if (pluginsLoaded.current || !info?.clientName) return;
pluginsLoaded.current = true;
reloadPlugins().then(({ loaded, errors }) => {
(async () => {
// auto-install official plugins (e.g. Unreal) on first run so no one loses tooling
try {
const installed = new Set((await p4.pluginList()).map((p) => p.id));
const added = await autoInstallOfficial(installed);
if (added.length) console.log("[plugins] auto-installed:", added);
} catch { /* offline — skip */ }
const { loaded, errors } = await reloadPlugins();
if (errors.length) console.warn("[plugins] load errors:", errors);
if (loaded) flash(t("{n} plugin(s) loaded", { n: loaded }));
}).catch(() => {});
})().catch(() => {});
}, [info?.clientName]); // eslint-disable-line
useEffect(() => subscribeRegistry(() => setPluginVer((v) => v + 1)), []);
// live Perforce command log — the Rust backend emits one event per p4 call
@ -894,13 +912,19 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
try {
const h = await p4.history(path);
setHistory(h);
// commit weights load in the background so the list shows instantly
// commit weights load in the background so the list shows instantly. Sizes are
// cached per changelist and only fetched for NEW changes — so a poll/refresh
// on the same scope never re-fetches (which used to make the badges flicker).
const scopeChanged = f !== histSizeScope.current;
histSizeScope.current = f;
if (scopeChanged) { sizesRef.current = {}; setChangeSizes({}); }
const nums = h.map((c) => c.change || "").filter(Boolean);
setChangeSizes({});
if (nums.length) {
p4.changeSizes(nums, f).then((rows) => {
const m: Record<string, number> = {};
const need = nums.filter((n) => sizesRef.current[n] === undefined);
if (need.length) {
p4.changeSizes(need, f).then((rows) => {
const m = { ...sizesRef.current };
for (const r of rows) m[r.change] = r.size;
sizesRef.current = m;
setChangeSizes(m);
}).catch(() => {});
}
@ -1373,7 +1397,6 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
{ label: (viewTabs.includes("locks") ? "✓ " : "") + t("File Locks"), icon: I.lock, hint: t("Everyone's checked-out / exclusively-locked files, docked into the view panel."), act: () => toggleViewTab("locks") },
{ label: (dockOpen && dockTab === "log" ? "✓ " : "") + t("Log"), kb: "Ctrl+L", icon: I.log, hint: t("Show the panel of recently run Perforce commands."), act: () => openDock("log") },
{ label: (dockOpen && dockTab === "terminal" ? "✓ " : "") + t("Terminal"), kb: "Ctrl+`", icon: I.terminal, hint: t("Open an embedded terminal for raw p4 commands."), act: () => openDock("terminal") },
...(uproject ? [{ label: (dockOpen && dockTab === "unreal" ? "✓ " : "") + t("Unreal Log"), icon: I.hex, hint: t("Show output from the headless Unreal preview process."), act: () => openDock("unreal") }] : []),
],
Tools: [
{ label: t("Search depot…"), icon: I.search, hint: t("Find files anywhere in the depot by name or path."), act: () => setModal({ kind: "search" }) },
@ -1386,7 +1409,6 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
{ label: t("Edit .p4ignore…"), icon: I.gear, hint: t("Edit which files reconcile / add ignore (build output, caches)."), act: () => setModal({ kind: "ignore" }) },
...getRegistry().menu.map((m) => ({ label: m.label, icon: I.hex, hint: t("From plugin: {p}", { p: m.pluginId }), act: () => { void Promise.resolve(m.run()).catch((e) => flash(String(e), true)); } })),
...getRegistry().views.map((v) => ({ label: v.title, icon: I.hex, hint: t("Plugin view"), act: () => setPluginView(v) })),
{ label: (slnPath || uproject) ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", icon: I.hammer, hint: t("Compile the game C++ — Unreal projects build via UnrealBuildTool (game module only), plain C++ via MSBuild."), act: startBuild },
{ label: t("People & Roles…"), icon: I.people, hint: t("See who works on this depot and their roles."), act: () => setModal({ kind: "users" }) },
{ label: t("Settings…"), icon: I.gear, hint: t("App preferences — language, theme, editor, and more."), act: () => setModal({ kind: "settings" }) },
],
@ -1439,7 +1461,6 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
...(activePath ? [{ label: t("Properties"), icon: I.info, act: () => setModal({ kind: "folderprops", path: activePath, name: splitPath(activePath).name || activePath }) }] : []),
{ label: t("Show in File Tree"), icon: I.folder, act: () => showInTree("depot") },
...(uproject ? [{ label: t("Launch Unreal Engine"), icon: I.ue, act: launchUE }] : []),
...((slnPath || uproject) ? [{ label: t("Build Solution (.sln)"), icon: I.hammer, act: startBuild }] : []),
{ label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(activePath || String(info?.clientRoot || "")) },
{ label: t("Choose another folder…"), icon: I.folder, act: () => browseTo("") },
{ label: t("Copy path"), icon: I.copy, act: () => copyText(activePath || "//…", t("Path")) },
@ -1675,7 +1696,7 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
height={dockH} onResize={startDockResize}
logs={logs} onClearLogs={() => setLogs([])}
termLines={termLines} termInput={termInput} setTermInput={setTermInput} termBusy={termBusy}
onRun={runConsole} cmdHist={cmdHist} onClearTerm={() => setTermLines([])} uproject={uproject} />}
onRun={runConsole} cmdHist={cmdHist} onClearTerm={() => setTermLines([])} />}
<div className="statusbar">
<span className="stfront" onMouseEnter={showPeek} onMouseLeave={hidePeek} title={t("Recent commands")}>
@ -1683,9 +1704,12 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
<span className="stlast">{logs.length ? `${logs[logs.length - 1].cmd} ${logCount(logs[logs.length - 1])}` : "p4 — ready"}</span>
</span>
<span className="stspace" />
{uproject && <button className="stlog" onClick={() => openDock("unreal")} title={t("Unreal Log")}>{I.hex}<span>{t("Unreal")}</span></button>}
<button className="stlog" onClick={() => openDock("terminal")} title="Ctrl+`">{I.terminal}<span>{t("Terminal")}</span></button>
<button className="stlog" onClick={() => openDock("log")} title="Ctrl+L">{I.log}<span>Log</span></button>
<button className="stlog" onClick={() => window.dispatchEvent(new Event("exd-check-update"))} title={t("Check for updates")}>
<svg viewBox="0 0 24 24" fill="none"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
<span>{t("Updates")}</span>
</button>
{peek && logs.length > 0 && (
<div className="logpeek" onMouseEnter={showPeek} onMouseLeave={hidePeek}>
<div className="logpeek-h">{t("Log")} · {logs.length}</div>
@ -2036,17 +2060,16 @@ function LogRow({ l }: { l: LogEntry }) {
const P4_COMMANDS = ["add","annotate","branch","branches","change","changes","client","clients","counter","counters","delete","depot","depots","describe","diff","dirs","edit","filelog","files","fstat","group","groups","have","info","integrate","labels","login","logout","monitor","move","opened","print","protect","reconcile","reopen","resolve","resolved","revert","reviews","shelve","sizes","status","stream","streams","submit","sync","tag","tickets","unshelve","user","users","where"];
/* ---------------- bottom dock: tabbed Log / Terminal / Unreal ---------------- */
function DockPanel({ tab, setTab, onClose, height, onResize, logs, onClearLogs, termLines, termInput, setTermInput, termBusy, onRun, cmdHist, onClearTerm, uproject }: {
function DockPanel({ tab, setTab, onClose, height, onResize, logs, onClearLogs, termLines, termInput, setTermInput, termBusy, onRun, cmdHist, onClearTerm }: {
tab: "log" | "terminal" | "unreal"; setTab: (t: "log" | "terminal" | "unreal") => void; onClose: () => void;
height: number; onResize: (e: ReactMouseEvent) => void;
logs: LogEntry[]; onClearLogs: () => void;
termLines: TermLine[]; termInput: string; setTermInput: (s: string) => void; termBusy: boolean;
onRun: (cmd: string) => void; cmdHist: string[]; onClearTerm: () => void; uproject: string;
onRun: (cmd: string) => void; cmdHist: string[]; onClearTerm: () => void;
}) {
const tabs: { key: "log" | "terminal" | "unreal"; label: string; icon: ReactNode }[] = [
{ key: "log", label: t("Log"), icon: I.log },
{ key: "terminal", label: t("Terminal"), icon: I.terminal },
...(uproject ? [{ key: "unreal" as const, label: t("Unreal"), icon: I.hex }] : []),
];
return (
<div className="dockpanel" style={{ flexBasis: height, height }}>
@ -2067,7 +2090,6 @@ function DockPanel({ tab, setTab, onClose, height, onResize, logs, onClearLogs,
<div className="dockpane" key={tab}>
{tab === "log" && <LogTab logs={logs} />}
{tab === "terminal" && <TerminalTab lines={termLines} input={termInput} setInput={setTermInput} busy={termBusy} onRun={onRun} cmdHist={cmdHist} />}
{tab === "unreal" && <UnrealTab uproject={uproject} />}
</div>
</div>
);
@ -2143,28 +2165,6 @@ function TerminalTab({ lines, input, setInput, busy, onRun, cmdHist }: {
);
}
/* tails the newest Unreal log (Saved/Logs/*.log) while the tab is open */
function UnrealTab({ uproject }: { uproject: string }) {
const [text, setText] = useState("");
const [err, setErr] = useState("");
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
let live = true;
const load = () => p4.ueLog(uproject).then((s) => { if (live) { setText(s); setErr(""); } }).catch((e) => live && setErr(String(e)));
load();
const iv = window.setInterval(load, 1500);
return () => { live = false; clearInterval(iv); };
}, [uproject]);
useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [text]);
return (
<div className="termbody" ref={ref}>
{err ? <div className="termhint err">{err}</div>
: text ? <pre className="termout ue">{text}</pre>
: <div className="termhint">{t("No Unreal log yet. It shows here when the editor writes to Saved/Logs — i.e. while Unreal is running.")}</div>}
</div>
);
}
/* ---------------- server transfer progress (P4V-style) ---------------- */
function TransferDialog({ transfer, onCancel, onMinimize }: { transfer: Transfer; onCancel: () => void; onMinimize: () => void }) {
const up = transfer.op === "submit";
@ -3248,8 +3248,16 @@ function PluginsModal({ onClose, onReload, onFlash, embedded }: { onClose?: () =
const [list, setList] = useState<PluginInfo[]>([]);
const [busy, setBusy] = useState(true);
const [dir, setDir] = useState("");
const [reg, setReg] = useState<RegistryEntry[]>([]);
const [installing, setInstalling] = useState<string>("");
const load = () => { setBusy(true); p4.pluginList().then(setList).catch((e) => onFlash(String(e), true)).finally(() => setBusy(false)); };
useEffect(() => { load(); p4.pluginsDirPath().then(setDir).catch(() => {}); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose?.(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
useEffect(() => { load(); p4.pluginsDirPath().then(setDir).catch(() => {}); fetchRegistry().then(setReg).catch(() => {}); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose?.(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
async function installReg(entry: RegistryEntry) {
setInstalling(entry.id);
try { await installFromRegistry(entry); load(); await onReload(); onFlash(t("Installed plugin “{id}”.", { id: entry.name })); }
catch (e) { onFlash(String(e), true); }
finally { setInstalling(""); }
}
async function toggle(p: PluginInfo, enabled: boolean) {
try { await p4.pluginSetEnabled(p.id, enabled); load(); await onReload(); } catch (e) { onFlash(String(e), true); }
}
@ -3286,6 +3294,21 @@ function PluginsModal({ onClose, onReload, onFlash, embedded }: { onClose?: () =
</div>
</div>
))}
{reg.filter((e) => !list.some((p) => p.id === e.id)).length > 0 && (<>
<div className="pl-official-h">{t("Official plugins")}</div>
{reg.filter((e) => !list.some((p) => p.id === e.id)).map((e) => (
<div key={e.id} className="pl-card">
<div className="pl-main">
<div className="pl-top"><b>{e.name}</b><span className="pl-ver">v{e.version}</span></div>
{e.description && <div className="pl-desc">{e.description}</div>}
<div className="pl-meta">{e.author && <span className="pl-tag">{e.author}</span>}{(e.permissions || []).map((perm) => <span key={perm} className="pl-perm">{perm}</span>)}</div>
</div>
<div className="pl-actions">
<button className="mbtn primary" style={{ flex: "none", padding: "7px 13px", gap: 6 }} disabled={installing === e.id} onClick={() => installReg(e)}>{installing === e.id ? <span className="ldr sm" /> : I.down}{t("Install")}</button>
</div>
</div>
))}
</>)}
</div>
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
<button className="mbtn ghost" onClick={() => p4.openPluginsDir().catch(() => {})} title={dir}>{I.reveal}{t("Open plugins folder")}</button>
@ -3682,9 +3705,10 @@ function HistoryList({ history, sizes, busy, sel, onSelect, onContext }: { histo
<div key={c.change} className={"hrow click" + (sel === c.change ? " sel" : "")} onClick={() => onSelect(c)} onContextMenu={(e) => onContext?.(c, e)}>
<Avatar user={c.user || "?"} size={26} />
<span className="hbody">
<span className="hdesc">{fresh && <span className="hnew" title={t("Submitted less than 3 hours ago")}>{t("NEW")}</span>}{(c.desc || "").trim() || t("(no description)")}</span>
<span className="hdesc">{(c.desc || "").trim() || t("(no description)")}</span>
<span className="hmeta">#{c.change} · {c.user} · {fmtTime(c.time)}{sz != null && sz > 0 ? <span className="hsize" title={t("Total size of files in this changelist")}>{humanSize(sz)}</span> : null}</span>
</span>
{fresh && <span className="hnew" title={t("Submitted less than 3 hours ago")}>{t("NEW")}</span>}
</div>
);
})}

View File

@ -1,32 +1,50 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { check, type Update } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
import { t } from "./i18n";
type Phase = "idle" | "available" | "downloading" | "done" | "error";
type Phase = "idle" | "checking" | "available" | "uptodate" | "downloading" | "done" | "error";
// Non-intrusive update prompt: quietly checks the Gitea Releases endpoint a few
// seconds after launch. If a newer signed version exists, slides a small card into
// the bottom-right corner. The user chooses when to install — nothing is forced.
// Update prompt. Quietly checks the Gitea Releases endpoint a few seconds after
// launch, and again on demand when the "Updates" button fires `exd-check-update`.
// If a newer signed version exists, slides a small card into the bottom-right.
export default function UpdateBanner() {
const [update, setUpdate] = useState<Update | null>(null);
const [phase, setPhase] = useState<Phase>("idle");
const [pct, setPct] = useState(0);
const [err, setErr] = useState("");
const [hidden, setHidden] = useState(false);
const busyRef = useRef(false);
// shared check routine. `manual` shows "checking" / "up to date" feedback.
async function runCheck(manual: boolean) {
if (busyRef.current || phase === "downloading") return;
busyRef.current = true;
if (manual) { setHidden(false); setPhase("checking"); }
try {
const u = await check();
if (u) { setUpdate(u); setPhase("available"); setHidden(false); }
else if (manual) { setPhase("uptodate"); setHidden(false); }
else setPhase("idle");
} catch (e) {
if (manual) { setErr(String(e)); setPhase("error"); setHidden(false); }
} finally { busyRef.current = false; }
}
// initial silent check + listen for manual "Updates" clicks
useEffect(() => {
let live = true;
const t = setTimeout(async () => {
try {
const u = await check();
if (live && u) { setUpdate(u); setPhase("available"); }
} catch {
// offline, or no release published yet — stay silent, never nag
}
}, 4000);
return () => { live = false; clearTimeout(t); };
}, []);
const id = setTimeout(() => runCheck(false), 4000);
const onManual = () => runCheck(true);
window.addEventListener("exd-check-update", onManual);
return () => { clearTimeout(id); window.removeEventListener("exd-check-update", onManual); };
}, []); // eslint-disable-line
// auto-dismiss the transient "up to date" card
useEffect(() => {
if (phase !== "uptodate") return;
const id = setTimeout(() => { setHidden(true); setPhase("idle"); }, 3500);
return () => clearTimeout(id);
}, [phase]);
async function install() {
if (!update) return;
@ -43,7 +61,25 @@ export default function UpdateBanner() {
} catch (e) { setErr(String(e)); setPhase("error"); }
}
if (hidden || phase === "idle" || !update) return null;
if (hidden || phase === "idle") return null;
// compact cards for the transient states (no `update` object yet)
if (phase === "checking") {
return <div className="upd sm"><div className="upd-head"><span className="upd-ic"><span className="ldr sm" /></span><div className="upd-ttl"><b>{t("Checking for updates…")}</b></div></div></div>;
}
if (phase === "uptodate") {
return <div className="upd sm ok" onClick={() => { setHidden(true); setPhase("idle"); }}>
<div className="upd-head"><span className="upd-ic ok"><svg viewBox="0 0 24 24" fill="none"><path d="M5 12l5 5 9-11" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" /></svg></span>
<div className="upd-ttl"><b>{t("You're on the latest version")}</b></div></div>
</div>;
}
if (phase === "error") {
return <div className="upd sm"><div className="upd-head"><span className="upd-ic"><svg viewBox="0 0 24 24" fill="none"><path d="M12 8v5m0 3h.01M10.3 4l-7 12A1.5 1.5 0 0 0 4.6 18h14.8a1.5 1.5 0 0 0 1.3-2L13.7 4a1.5 1.5 0 0 0-3.4 0Z" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg></span>
<div className="upd-ttl"><b>{t("Update check failed")}</b><span>{err.slice(0, 80)}</span></div>
<button className="upd-x" onClick={() => { setHidden(true); setPhase("idle"); }}><svg viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button></div>
</div>;
}
if (!update) return null;
return (
<div className="upd">
@ -55,7 +91,7 @@ export default function UpdateBanner() {
<b>{t("Update available")}</b>
<span>Exbyte Depot v{update.version}</span>
</div>
{(phase === "available" || phase === "error") && (
{phase === "available" && (
<button className="upd-x" onClick={() => setHidden(true)} title={t("Later")}>
<svg viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
</button>
@ -71,7 +107,6 @@ export default function UpdateBanner() {
</div>
)}
{phase === "done" && <div className="upd-notes">{t("Installed — restarting…")}</div>}
{phase === "error" && <div className="upd-notes err">{err}</div>}
{phase === "available" && (
<div className="upd-actions">

View File

@ -179,6 +179,11 @@ const D: Record<string, Tr> = {
"Submitted less than 3 hours ago": { ru: "Засабмичено меньше 3 часов назад", de: "Vor weniger als 3 Stunden übermittelt", fr: "Soumis il y a moins de 3 heures", es: "Enviado hace menos de 3 horas" },
"Plugins": { ru: "Плагины", de: "Plugins", fr: "Extensions", es: "Plugins" },
"General": { ru: "Общие", de: "Allgemein", fr: "Général", es: "General" },
"Check for updates": { ru: "Проверить обновления", de: "Nach Updates suchen", fr: "Vérifier les mises à jour", es: "Buscar actualizaciones" },
"Updates": { ru: "Обновления", de: "Updates", fr: "Mises à jour", es: "Actualizaciones" },
"Checking for updates…": { ru: "Проверяю обновления…", de: "Suche nach Updates…", fr: "Recherche de mises à jour…", es: "Buscando actualizaciones…" },
"You're on the latest version": { ru: "У тебя последняя версия", de: "Du hast die neueste Version", fr: "Vous avez la dernière version", es: "Tienes la última versión" },
"Update check failed": { ru: "Не удалось проверить обновления", de: "Update-Prüfung fehlgeschlagen", fr: "Échec de la vérification", es: "Fallo al buscar actualizaciones" },
"Appearance": { ru: "Оформление", de: "Darstellung", fr: "Apparence", es: "Apariencia" },
"Base theme": { ru: "Базовая тема", de: "Basis-Theme", fr: "Thème de base", es: "Tema base" },
"{n} installed": { ru: "установлено: {n}", de: "{n} installiert", fr: "{n} installés", es: "{n} instalados" },
@ -186,6 +191,8 @@ const D: Record<string, Tr> = {
"No plugins installed yet.\nInstall one from a folder, or drop it into the plugins folder below.": { ru: "Плагинов пока нет.\nУстанови из папки или положи в папку плагинов ниже.", de: "Noch keine Plugins.\nAus einem Ordner installieren oder in den Plugin-Ordner unten legen.", fr: "Aucune extension.\nInstallez depuis un dossier ou déposez-la dans le dossier ci-dessous.", es: "Aún no hay plugins.\nInstala desde una carpeta o suéltalo en la carpeta de abajo." },
"Enable": { ru: "Включить", de: "Aktivieren", fr: "Activer", es: "Activar" },
"Disable": { ru: "Выключить", de: "Deaktivieren", fr: "Désactiver", es: "Desactivar" },
"Official plugins": { ru: "Официальные плагины", de: "Offizielle Plugins", fr: "Extensions officielles", es: "Plugins oficiales" },
"Install": { ru: "Установить", de: "Installieren", fr: "Installer", es: "Instalar" },
"Open plugins folder": { ru: "Открыть папку плагинов", de: "Plugin-Ordner öffnen", fr: "Ouvrir le dossier des extensions", es: "Abrir carpeta de plugins" },
"Reload": { ru: "Перезагрузить", de: "Neu laden", fr: "Recharger", es: "Recargar" },
"Install from folder…": { ru: "Установить из папки…", de: "Aus Ordner installieren…", fr: "Installer depuis un dossier…", es: "Instalar desde carpeta…" },

View File

@ -112,6 +112,7 @@ export const p4 = {
pluginList: () => invoke<PluginInfo[]>("plugin_list"),
pluginReadEntry: (id: string) => invoke<string>("plugin_read_entry", { id }),
pluginSetEnabled: (id: string, enabled: boolean) => invoke<void>("plugin_set_enabled", { id, enabled }),
pluginWriteFile: (id: string, name: string, content: string) => invoke<void>("plugin_write_file", { id, name, content }),
pluginInstall: (src: string) => invoke<string>("plugin_install", { src }),
pluginRemove: (id: string) => invoke<void>("plugin_remove", { id }),
pluginsDirPath: () => invoke<string>("plugins_dir_path"),

View File

@ -28,6 +28,15 @@ export function registryVersion(): number { return version; }
export function subscribeRegistry(cb: () => void): () => void { listeners.add(cb); return () => { listeners.delete(cb); }; }
function clearRegistry() { registry.menu = []; registry.fileItems = []; registry.views = []; registry.submitHooks = []; }
// Unreal Engine bridge — the core keeps the build machinery + overlay + log
// reader; the plugin only drives them. Gated by the "ue" permission.
export interface UeBridge {
hasProject: () => boolean;
uproject: () => string;
build: () => void;
readLog: (tailBytes?: number) => Promise<string>;
}
// ---- host context (provided by the app) -------------------------------------
export interface HostContext {
flash: (text: string, err?: boolean) => void;
@ -35,6 +44,7 @@ export interface HostContext {
info: () => { userName?: string; clientName?: string; clientRoot?: string } | null;
selectedFile: () => { depotFile?: string } | null;
refresh: () => void;
ue?: UeBridge;
}
// ---- the Host SDK a plugin receives -----------------------------------------
@ -57,6 +67,7 @@ export interface HostApi {
info: () => ReturnType<HostContext["info"]>;
selectedFile: () => { depotFile?: string } | null;
refresh: () => void;
ue?: UeBridge; // present only with the "ue" permission
theme: () => Record<string, string>;
t: typeof t;
log: (...a: unknown[]) => void;
@ -104,6 +115,7 @@ function makeHost(info: PluginInfo, ctx: HostContext): HostApi {
t,
log: (...a) => console.log(`[plugin:${info.id}]`, ...a),
};
if (has("ue") && ctx.ue) host.ue = ctx.ue; // Unreal bridge, permission-gated
return host;
}
@ -146,3 +158,43 @@ export async function runSubmitHooks(files: { depotFile?: string }[]): Promise<S
}
return { ok: true };
}
// ---- official plugin registry (hosted on Gitea) -----------------------------
// The client fetches registry.json over HTTPS (frontend fetch — no Rust HTTP dep),
// then hands each file to the backend to write into the plugins folder.
export const REGISTRY_URL = "https://gitea.exbytestudios.com/ExbytePublicServices/exbyte-depot-plugins/raw/branch/main/registry.json";
export interface RegistryEntry {
id: string; name: string; version: string; author?: string; description?: string;
permissions?: string[]; minAppVersion?: string; official?: boolean; autoInstall?: boolean;
files: string[]; baseUrl: string;
}
export async function fetchRegistry(): Promise<RegistryEntry[]> {
const r = await fetch(REGISTRY_URL, { cache: "no-store" });
if (!r.ok) throw new Error(`registry HTTP ${r.status}`);
const j = await r.json();
return Array.isArray(j.plugins) ? (j.plugins as RegistryEntry[]).filter((p) => p && p.id && Array.isArray(p.files)) : [];
}
/** Download a registry plugin's files over HTTPS and write them into the plugins folder. */
export async function installFromRegistry(entry: RegistryEntry): Promise<void> {
for (const f of entry.files) {
const res = await fetch(entry.baseUrl + f, { cache: "no-store" });
if (!res.ok) throw new Error(`${f}: HTTP ${res.status}`);
await p4.pluginWriteFile(entry.id, f, await res.text());
}
}
/** Install any official plugins marked autoInstall that aren't installed yet. Silent on network failure. */
export async function autoInstallOfficial(installedIds: Set<string>): Promise<string[]> {
let reg: RegistryEntry[] = [];
try { reg = await fetchRegistry(); } catch { return []; }
const done: string[] = [];
for (const e of reg) {
if (e.autoInstall && !installedIds.has(e.id)) {
try { await installFromRegistry(e); done.push(e.id); } catch { /* offline / transient — try next launch */ }
}
}
return done;
}