2 Commits

Author SHA1 Message Date
3954082e8d v0.3.2 — commit/submit progress, cancel, thumbnails, waitlist landing+admin
Client:
- Commit: batch `p4 reopen` so huge changelists (63k+) no longer blow the
  Windows command-line limit; live "N / total" progress + spinner during commit.
- Submit dialog: white arrow, "N of total" + % bar, running file log,
  Cancel button (aborts pre-commit; changelist stays pending).
- Disk scan: streaming reconcile with a live "found N files" counter + Cancel.
- Thumbnails: only decode browser-renderable image formats; glyph fallback
  (+ onError) instead of a broken-image icon for bmp/tga/dds/etc.

Landing + waitlist:
- waitlist-server: Express + SQLite email collector (dedup) with a secure
  admin panel (scrypt auth, rate-limited login, signed cookies) and CSV export.
- landing form now POSTs signups to the waitlist API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 20:40:53 +03:00
5652f489a2 v0.3.1 — view-panel tabs, tray + notifications, server-activity, media preview, dockable locks
- Tabbed view panel: File Viewer / File Tree / File Locks, header hidden at one tab, re-openable from Window, state persisted
- Right-click Workspace/Working-folder zones -> Show in File Tree (focuses the right side)
- Commit weight shown in History (p4 sizes -s //...@=CH)
- Rich code editor (highlighted underlay + gutter) and clean spec-editor surfaces (no more grey notepad)
- System tray (close-to-tray, tray menu Quit) + native OS notifications for teammate submits, even when hidden
- 'You're behind the server' banner + native notification (p4 sync -n), rechecked only when the server advances
- Audio & video preview with playback (media-src blob CSP); File Locks dockable into the view panel
- Backend: depot_ls/fs_ls, p4_change_sizes, p4_behind; tauri-plugin-notification + tray-icon

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:26:22 +03:00
24 changed files with 4432 additions and 260 deletions

876
landing/index.html Normal file
View File

@ -0,0 +1,876 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Exbyte Depot — Perforce for Unreal teams, finally pleasant</title>
<meta name="description" content="Exbyte Depot is a fast native Perforce client built for Unreal Engine teams: 3D asset previews, one-click exclusive locks, offline work, and live teammate activity. All the power of Helix Core, none of the P4V pain." />
<style>
:root{
--bg:#0a0a0f; --bg-2:#0d0d14; --panel:#12121b; --panel-2:#16161f; --elevated:#1a1a24;
--line:rgba(255,255,255,.08); --line-2:rgba(255,255,255,.05);
--txt:#ececf4; --muted:#a2a2b4; --faint:#6d6d80;
--acc:#7c6ef6; --acc-2:#9d93ff; --acc-deep:#5b4ee0;
--ok:#3ecf8e; --warn:#e8b04b; --del:#f2637e;
--maxw:1180px;
--font:"Segoe UI Variable Display","Segoe UI",system-ui,-apple-system,Roboto,"Helvetica Neue",Arial,sans-serif;
--mono:"Cascadia Code","JetBrains Mono",ui-monospace,"SF Mono",Consolas,monospace;
--shadow:0 40px 120px -40px rgba(0,0,0,.8);
}
*{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth}
body{
font-family:var(--font);background:var(--bg);color:var(--txt);
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
line-height:1.6;overflow-x:hidden;
}
/* ambient background */
body::before{
content:"";position:fixed;inset:0;z-index:-2;pointer-events:none;
background:
radial-gradient(900px 640px at 78% -6%,rgba(124,110,246,.22),transparent 60%),
radial-gradient(760px 620px at 6% 8%,rgba(91,78,224,.16),transparent 58%),
var(--bg);
}
body::after{
content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;opacity:.5;
background-image:
linear-gradient(rgba(255,255,255,.020) 1px,transparent 1px),
linear-gradient(90deg,rgba(255,255,255,.020) 1px,transparent 1px);
background-size:46px 46px;
-webkit-mask-image:radial-gradient(1200px 800px at 60% 0%,#000 30%,transparent 78%);
mask-image:radial-gradient(1200px 800px at 60% 0%,#000 30%,transparent 78%);
}
a{color:inherit;text-decoration:none}
.tlink{color:var(--acc-2);text-decoration:underline;text-underline-offset:2px;text-decoration-color:rgba(157,147,255,.45);transition:.15s}
.tlink:hover{text-decoration-color:var(--acc-2)}
/* "works with your Perforce server" reassurance band */
.clarify{display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap;text-align:center;
max-width:760px;margin:26px auto 0;padding:14px 20px;border-radius:14px;
background:var(--panel);border:1px solid var(--line);color:var(--muted);font-size:14px;line-height:1.55}
.clarify svg{width:18px;height:18px;color:var(--acc-2);flex:0 0 auto}
.clarify b{color:var(--txt);font-weight:600}
.wrap{width:100%;max-width:var(--maxw);margin:0 auto;padding:0 24px}
.eyebrow{display:inline-flex;align-items:center;gap:8px;font-size:12.5px;font-weight:600;letter-spacing:.08em;
text-transform:uppercase;color:var(--acc-2);background:rgba(124,110,246,.10);border:1px solid rgba(124,110,246,.28);
padding:6px 13px;border-radius:100px}
.eyebrow .dot{width:6px;height:6px;border-radius:50%;background:var(--acc-2);box-shadow:0 0 10px var(--acc-2)}
h1,h2,h3{line-height:1.08;letter-spacing:-.022em;font-weight:700;text-wrap:balance}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:9px;font-family:inherit;font-size:15px;
font-weight:600;cursor:pointer;border-radius:12px;padding:13px 22px;border:1px solid transparent;transition:.18s ease;white-space:nowrap}
.btn.primary{color:#fff;background:linear-gradient(135deg,var(--acc-2),var(--acc-deep));
box-shadow:0 14px 34px -14px var(--acc),inset 0 1px 0 rgba(255,255,255,.22)}
.btn.primary:hover{transform:translateY(-1px);box-shadow:0 20px 44px -14px var(--acc),inset 0 1px 0 rgba(255,255,255,.3)}
.btn.ghost{color:var(--txt);background:rgba(255,255,255,.04);border-color:var(--line)}
.btn.ghost:hover{background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.18)}
.btn svg{width:17px;height:17px}
:focus-visible{outline:2px solid var(--acc-2);outline-offset:2px;border-radius:8px}
/* ---------- nav ---------- */
header.nav{position:sticky;top:0;z-index:50;backdrop-filter:blur(14px);
background:linear-gradient(180deg,rgba(10,10,15,.86),rgba(10,10,15,.55));border-bottom:1px solid var(--line-2)}
.nav-in{display:flex;align-items:center;gap:20px;height:66px}
.brand{display:flex;align-items:center;gap:11px;font-weight:700;font-size:16.5px;letter-spacing:-.01em}
.brand .mark{width:34px;height:34px;border-radius:9px;display:grid;place-items:center;color:#fff;
background:linear-gradient(140deg,var(--acc),var(--acc-deep));box-shadow:0 8px 22px -8px var(--acc),inset 0 1px 0 rgba(255,255,255,.35)}
.brand .mark svg{width:19px;height:19px}
.brand small{color:var(--faint);font-weight:500}
.nav-links{display:flex;gap:26px;margin-left:18px}
.nav-links a{font-size:14.5px;color:var(--muted);font-weight:500;transition:.15s}
.nav-links a:hover{color:var(--txt)}
.nav-cta{margin-left:auto;display:flex;align-items:center;gap:12px}
/* ---------- hero ---------- */
.hero{padding:74px 0 40px;text-align:center}
.hero h1{font-size:clamp(38px,6vw,68px);margin:22px auto 0;max-width:16ch}
.hero h1 .g{background:linear-gradient(120deg,var(--acc-2),#c9c2ff 60%,var(--acc));-webkit-background-clip:text;background-clip:text;color:transparent}
.hero p.sub{font-size:clamp(16px,2vw,19px);color:var(--muted);max-width:60ch;margin:22px auto 0;line-height:1.62}
.waitlist{margin:34px auto 0;max-width:520px}
.wl-form{display:flex;gap:10px;background:var(--panel);border:1px solid var(--line);border-radius:15px;padding:7px;
box-shadow:var(--shadow)}
.wl-form input{flex:1;min-width:0;background:transparent;border:none;outline:none;color:var(--txt);font-family:inherit;
font-size:15px;padding:12px 14px}
.wl-form input::placeholder{color:var(--faint)}
.wl-note{margin-top:12px;font-size:13px;color:var(--faint);display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap}
.wl-note b{color:var(--muted);font-weight:600}
.wl-ok{display:none;align-items:center;gap:12px;justify-content:center;background:rgba(62,207,142,.10);
border:1px solid rgba(62,207,142,.32);color:#8ff0c4;border-radius:15px;padding:18px 20px;font-size:15px;box-shadow:var(--shadow)}
.wl-ok.show{display:flex}
.wl-ok svg{width:22px;height:22px;flex:0 0 auto}
.avatars{display:flex;justify-content:center;margin-top:22px;align-items:center;gap:12px;color:var(--faint);font-size:13.5px}
.stack{display:flex}
.stack span{width:28px;height:28px;border-radius:50%;border:2px solid var(--bg);margin-left:-8px;
display:grid;place-items:center;font-size:11px;font-weight:700;color:#fff}
/* ---------- app mock ---------- */
.mock-shell{margin:56px 0 0;width:min(1280px,95vw);max-width:1280px;perspective:1800px;
position:relative;left:50%;transform:translateX(-50%)}
.mock{border-radius:16px;overflow:hidden;border:1px solid var(--line);background:var(--panel);
box-shadow:var(--shadow),0 0 0 1px rgba(124,110,246,.10);transform:rotateX(2.5deg);transform-origin:center top}
.m-title{display:flex;align-items:center;gap:12px;height:46px;padding:0 14px;background:var(--panel-2);border-bottom:1px solid var(--line)}
.m-title .mark{width:22px;height:22px;border-radius:6px;display:grid;place-items:center;color:#fff;background:linear-gradient(140deg,var(--acc),var(--acc-deep))}
.m-title .mark svg{width:13px;height:13px}
.m-title .t{font-size:13px;font-weight:600}.m-title .t span{color:var(--faint);font-weight:500}
.m-menu{display:flex;gap:16px;margin-left:10px;font-size:12.5px;color:var(--muted)}
.m-pill{margin-left:auto;display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--muted);
background:rgba(62,207,142,.10);border:1px solid rgba(62,207,142,.28);padding:4px 11px;border-radius:100px}
.m-pill .d{width:6px;height:6px;border-radius:50%;background:var(--ok);box-shadow:0 0 8px var(--ok)}
.m-body{display:grid;grid-template-columns:340px 1fr;min-height:376px}
.m-left{border-right:1px solid var(--line);display:flex;flex-direction:column}
.m-tabs{display:flex;gap:20px;padding:14px 16px 0;font-size:13px;color:var(--faint)}
.m-tabs .on{color:var(--txt);font-weight:600;position:relative;padding-bottom:12px}
.m-tabs .on::after{content:"";position:absolute;left:0;right:0;bottom:0;height:2px;background:var(--acc);border-radius:2px}
.m-tabs .badge{background:rgba(255,255,255,.08);color:var(--muted);border-radius:8px;padding:0 7px;font-size:11px;margin-left:5px}
.m-list{padding:12px 12px 0;display:flex;flex-direction:column;gap:7px}
.m-file{display:flex;align-items:center;gap:10px;padding:8px 9px;border-radius:10px}
.m-file:hover{background:rgba(255,255,255,.03)}
.m-file.sel{background:rgba(124,110,246,.12);box-shadow:inset 0 0 0 1px rgba(124,110,246,.25)}
.m-chk{width:16px;height:16px;border-radius:5px;background:linear-gradient(135deg,var(--acc-2),var(--acc-deep));display:grid;place-items:center;flex:0 0 auto}
.m-chk svg{width:10px;height:10px;color:#fff}
.m-ico{width:26px;height:26px;border-radius:7px;background:var(--panel-2);border:1px solid var(--line);display:grid;place-items:center;color:var(--acc-2);flex:0 0 auto}
.m-ico svg{width:14px;height:14px}
.m-fmeta{min-width:0}
.m-fmeta .n{font-size:12.5px;font-weight:600;white-space:nowrap}
.m-fmeta .p{font-size:10.5px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px}
.m-plus{margin-left:auto;color:var(--ok)}
.m-commit{margin-top:auto;padding:14px;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:9px}
.m-input{background:var(--panel-2);border:1px solid var(--line);border-radius:9px;padding:9px 11px;font-size:12px;color:var(--muted)}
.m-submit{background:linear-gradient(135deg,var(--acc-2),var(--acc-deep));color:#fff;border-radius:10px;padding:11px;
font-size:12.5px;font-weight:600;text-align:center;display:flex;align-items:center;justify-content:center;gap:7px}
.m-stage{position:relative;display:grid;place-items:center;
background:
radial-gradient(520px 380px at 50% 38%,rgba(124,110,246,.12),transparent 62%),
repeating-linear-gradient(0deg,transparent,transparent 31px,rgba(255,255,255,.02) 31px,rgba(255,255,255,.02) 32px),
repeating-linear-gradient(90deg,transparent,transparent 31px,rgba(255,255,255,.02) 31px,rgba(255,255,255,.02) 32px),
var(--bg-2)}
.m-stage .lab{position:absolute;top:14px;left:14px;font-size:11px;color:var(--muted);background:rgba(0,0,0,.4);
border:1px solid var(--line);border-radius:100px;padding:5px 11px;display:flex;align-items:center;gap:7px}
.m-cube{width:120px;height:120px;color:var(--acc-2);filter:drop-shadow(0 20px 40px rgba(124,110,246,.4));animation:float 5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translateY(0) rotate(-4deg)}50%{transform:translateY(-12px) rotate(4deg)}}
.m-load{position:absolute;bottom:56px;left:50%;transform:translateX(-50%);
background:linear-gradient(135deg,var(--acc-2),var(--acc-deep));color:#fff;border-radius:10px;padding:10px 18px;
font-size:12.5px;font-weight:600;display:flex;align-items:center;gap:8px;box-shadow:0 14px 30px -12px var(--acc)}
/* titlebar extras */
.m-title .m-actions{display:flex;align-items:center;gap:6px;margin-left:14px}
.m-ib{width:26px;height:26px;border-radius:7px;display:grid;place-items:center;color:var(--muted);background:rgba(255,255,255,.04);border:1px solid var(--line-2)}
.m-ib svg{width:14px;height:14px}
.m-wc{display:flex;gap:9px;margin-left:12px}
.m-wc i{width:12px;height:12px;border-radius:50%;display:block}
.m-wc .r{background:#ff5f57}.m-wc .y{background:#febc2e}.m-wc .g{background:#28c840}
/* second header row: workspace / working folder / get latest */
.m-head{display:grid;grid-template-columns:1fr 1fr 190px;border-bottom:1px solid var(--line)}
.m-zone{display:flex;align-items:center;gap:11px;padding:12px 16px;border-right:1px solid var(--line);min-width:0}
.m-zone .zi{width:30px;height:30px;border-radius:8px;display:grid;place-items:center;color:var(--acc-2);background:rgba(124,110,246,.10);border:1px solid rgba(124,110,246,.2);flex:0 0 auto}
.m-zone .zi svg{width:16px;height:16px}
.m-zone .zm{min-width:0}
.m-zone .zm .l{font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--faint)}
.m-zone .zm .v{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.m-zone .cv{margin-left:auto;color:var(--faint);flex:0 0 auto}
.m-zone.get{border-right:none;cursor:default}
.m-zone.get .zi{color:var(--ok);background:rgba(62,207,142,.10);border-color:rgba(62,207,142,.22)}
.m-zone.get .v{color:var(--txt)}.m-zone.get .l{color:var(--faint)}
/* left column bits */
.m-filter{margin:12px 12px 4px;display:flex;align-items:center;gap:8px;background:var(--panel-2);border:1px solid var(--line);border-radius:9px;padding:8px 11px;color:var(--faint);font-size:12px}
.m-filter svg{width:14px;height:14px}
.m-selhead{display:flex;align-items:center;gap:9px;padding:8px 16px 6px;font-size:11.5px;color:var(--muted)}
.m-selhead .sc{width:15px;height:15px;border-radius:5px;background:linear-gradient(135deg,var(--acc-2),var(--acc-deep));display:grid;place-items:center;flex:0 0 auto}
.m-selhead .sc svg{width:9px;height:9px;color:#fff}
.m-selhead .rf{margin-left:auto;display:flex;align-items:center;gap:5px;color:var(--muted);background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:7px;padding:4px 9px;font-size:11px}
.m-selhead .rf svg{width:12px;height:12px}
.m-stat{width:18px;height:18px;border-radius:5px;display:grid;place-items:center;font-size:10px;font-weight:700;flex:0 0 auto}
.m-stat.a{color:var(--ok);background:rgba(62,207,142,.14)}
.m-plus{margin-left:auto}
.ai-btn{width:34px;height:34px;flex:0 0 auto;border-radius:9px;display:grid;place-items:center;color:var(--acc-2);background:rgba(124,110,246,.10);border:1px solid rgba(124,110,246,.24)}
.ai-btn svg{width:16px;height:16px}
.m-summrow{display:flex;gap:8px;align-items:stretch}
.m-summrow .m-input{flex:1}
/* right: diff head + meta bar */
.m-right{display:flex;flex-direction:column;min-width:0}
.m-diffhead{display:flex;align-items:center;gap:11px;padding:12px 16px;border-bottom:1px solid var(--line)}
.m-diffhead .ttl{min-width:0}
.m-diffhead .ttl .n{font-size:13.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.m-diffhead .ttl .n span{color:var(--faint);font-weight:500}
.m-diffhead .ttl .mm{font-size:11px;color:var(--faint)}
.m-diffhead .mrev{color:var(--acc-2)}
.m-revert{margin-left:auto;display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:8px;padding:6px 12px;flex:0 0 auto}
.m-revert svg{width:13px;height:13px}
.m-stage{flex:1;position:relative}
.m-metabar{border-top:1px solid var(--line);padding:12px 18px;display:grid;grid-template-columns:repeat(4,1fr);gap:10px;background:rgba(0,0,0,.16)}
.m-metabar .mi .k{font-size:9.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--faint)}
.m-metabar .mi .v{font-size:13px;font-weight:600;margin-top:3px}
.m-metabar .mi .v.acc{color:var(--acc-2)}
/* bottom status bar */
.m-status{display:flex;align-items:center;gap:9px;height:34px;padding:0 14px;border-top:1px solid var(--line);background:var(--panel-2);font-size:11px;color:var(--faint)}
.m-status .cmd{font-family:var(--mono);color:var(--muted)}
.m-status .ok{color:var(--ok)}
.m-status .chips{margin-left:auto;display:flex;gap:7px}
.m-status .chips b{display:flex;align-items:center;gap:5px;font-weight:500;color:var(--muted);background:rgba(255,255,255,.04);border:1px solid var(--line-2);border-radius:7px;padding:3px 9px}
.m-status .chips b svg{width:12px;height:12px}
@media (max-width:900px){
.m-head{grid-template-columns:1fr}
.m-zone{border-right:none;border-bottom:1px solid var(--line)}
.m-zone.get{display:none}
.m-metabar{grid-template-columns:1fr 1fr}
}
/* ---- interactive mock: hover / active / press ---- */
.m-menu span,.m-file,.m-tabs span,.m-submit,.m-load,.m-zone.get,.m-ib,.m-selhead .rf,
.m-revert,.m-chk,.ai-btn,.m-status .chips b,.m-wc i{cursor:pointer}
.m-menu span{padding:3px 6px;border-radius:6px;transition:.14s}
.m-menu span:hover{background:rgba(255,255,255,.06);color:var(--txt)}
.m-tabs span:hover{color:var(--txt)}
.m-ib{transition:.14s}.m-ib:hover{color:var(--txt);background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.16)}
.m-wc i{transition:.14s}.m-wc i:hover{filter:brightness(1.25)}
.m-file{transition:background .14s,box-shadow .14s;user-select:none}
.m-file:active{transform:translateY(.5px)}
.m-chk{transition:.14s}
.m-file.off .m-chk{background:transparent;border:1.5px solid var(--faint);box-shadow:none}
.m-file.off .m-chk svg{display:none}
.m-selhead .rf,.m-revert{transition:.14s}
.m-selhead .rf:hover,.m-revert:hover{color:var(--txt);border-color:rgba(255,255,255,.2);background:rgba(255,255,255,.07)}
.m-selhead .rf.spin svg{animation:mspin .7s linear infinite}
.ai-btn{transition:.14s}.ai-btn:hover{background:rgba(124,110,246,.18);border-color:rgba(124,110,246,.4)}
.m-zone.get{transition:.14s;border-radius:0}.m-zone.get:hover{background:rgba(62,207,142,.06)}
.m-zone.get.spin .zi svg{animation:mspin .7s linear infinite}
.m-submit,.m-load{transition:transform .12s,box-shadow .14s,filter .14s}
.m-submit:hover,.m-load:hover{filter:brightness(1.08)}
.m-submit:active,.m-load:active{transform:scale(.98)}
.m-load:hover{transform:translateX(-50%) scale(1.03)}
.m-load:active{transform:translateX(-50%) scale(.97)}
.m-status .chips b{transition:.14s}.m-status .chips b:hover{color:var(--txt);border-color:var(--line)}
@keyframes mspin{to{transform:rotate(360deg)}}
.m-spinner{width:14px;height:14px;border:2px solid rgba(255,255,255,.4);border-top-color:#fff;border-radius:50%;
animation:mspin .7s linear infinite;display:inline-block}
/* preview variants */
.pv{position:absolute;inset:0;display:grid;place-items:center}
.pv[hidden]{display:none}
.m-cube.loaded{animation:mrotate 7s linear infinite}
@keyframes mrotate{to{transform:rotateY(360deg)}}
.pv-mesh{perspective:900px}
.tex-swatch{width:min(62%,300px);aspect-ratio:1;border-radius:14px;box-shadow:0 24px 60px -24px #000;border:1px solid var(--line);
background:
repeating-conic-gradient(from 45deg,#2a2733 0deg 90deg,#211f2a 90deg 180deg) 0 0/26px 26px,
radial-gradient(120% 120% at 30% 20%,rgba(124,110,246,.25),transparent 60%)}
.tex-cap{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);font-size:11px;color:var(--muted);
background:rgba(0,0,0,.4);border:1px solid var(--line);border-radius:100px;padding:5px 12px}
.pv-code{align-items:stretch;justify-items:stretch;padding:0}
.codeblk{width:100%;height:100%;overflow:hidden;font-family:var(--mono);font-size:12px;line-height:1.75;
padding:16px 18px;color:#c7c7d6;text-align:left;background:var(--bg-2)}
.codeblk .ln{display:block;white-space:pre}
.codeblk .kw{color:#9d93ff}.codeblk .ty{color:#5cc9c0}.codeblk .fn{color:#e8b04b}.codeblk .cm{color:#5f5f72;font-style:italic}.codeblk .st{color:#8ff0c4}
/* only one body panel visible at a time (beats inline/class display) */
.m-panel[hidden]{display:none!important}
/* history list (History tab) */
.m-hist{padding:10px 12px;display:flex;flex-direction:column;gap:4px;overflow:auto}
.m-hrow{display:flex;align-items:center;gap:11px;padding:9px 10px;border-radius:10px;cursor:pointer;transition:.14s}
.m-hrow:hover{background:rgba(255,255,255,.04)}
.m-hav{width:26px;height:26px;border-radius:50%;flex:0 0 auto;display:grid;place-items:center;font-size:10px;font-weight:700;color:#fff}
.m-hb{min-width:0;flex:1}
.m-hb .d{font-size:12.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.m-hb .mt{font-size:10.5px;color:var(--faint)}
.m-hsz{font-size:10.5px;font-weight:600;color:var(--muted);background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:1px 7px;white-space:nowrap}
/* ---------- logos / trust ---------- */
.trust{padding:44px 0 8px;text-align:center}
.trust p{font-size:12.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin-bottom:20px}
.trust-row{display:flex;flex-wrap:wrap;justify-content:center;gap:14px}
.chip{display:inline-flex;align-items:center;gap:8px;font-size:13.5px;color:var(--muted);font-weight:500;
background:var(--panel);border:1px solid var(--line);border-radius:100px;padding:9px 15px}
.chip svg{width:15px;height:15px;color:var(--acc-2)}
/* ---------- section headers ---------- */
section{padding:86px 0}
.sec-head{max-width:720px;margin:0 auto 46px;text-align:center}
.sec-head h2{font-size:clamp(28px,4vw,42px)}
.sec-head p{color:var(--muted);font-size:17px;margin-top:14px;line-height:1.6}
/* ---------- features grid ---------- */
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
.card{background:linear-gradient(180deg,var(--panel),var(--bg-2));border:1px solid var(--line);border-radius:16px;
padding:26px 24px;transition:.2s ease;position:relative;overflow:hidden}
.card:hover{border-color:rgba(124,110,246,.4);transform:translateY(-3px)}
.card:hover::before{opacity:1}
.card::before{content:"";position:absolute;inset:0;opacity:0;transition:.25s;pointer-events:none;
background:radial-gradient(340px 180px at 20% 0%,rgba(124,110,246,.12),transparent 70%)}
.card .ic{width:44px;height:44px;border-radius:12px;display:grid;place-items:center;color:var(--acc-2);
background:rgba(124,110,246,.10);border:1px solid rgba(124,110,246,.24);margin-bottom:18px}
.card .ic svg{width:22px;height:22px}
.card h3{font-size:18px;letter-spacing:-.01em}
.card p{color:var(--muted);font-size:14.5px;margin-top:9px;line-height:1.6}
.card .tag{position:absolute;top:20px;right:20px;font-size:10px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;
color:var(--acc-2);background:rgba(124,110,246,.12);border:1px solid rgba(124,110,246,.28);border-radius:7px;padding:3px 7px}
/* ---------- spotlight (why not p4v) ---------- */
.split{display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center}
.split h2{font-size:clamp(26px,3.4vw,38px)}
.split .lead{color:var(--muted);font-size:16.5px;margin-top:16px}
.comp{display:flex;flex-direction:column;gap:10px;margin-top:26px}
.crow{display:flex;align-items:flex-start;gap:13px;padding:14px 16px;border-radius:13px;background:var(--panel);border:1px solid var(--line)}
.crow .ck{width:22px;height:22px;border-radius:7px;display:grid;place-items:center;flex:0 0 auto;margin-top:1px}
.crow.good .ck{background:rgba(62,207,142,.14);color:var(--ok)}
.crow .ck svg{width:13px;height:13px}
.crow b{font-size:14.5px}.crow span{color:var(--muted);font-size:13.5px;display:block;margin-top:2px}
.panel-visual{background:linear-gradient(180deg,var(--panel),var(--bg-2));border:1px solid var(--line);border-radius:18px;
padding:22px;box-shadow:var(--shadow)}
.lock-item{display:flex;align-items:center;gap:12px;padding:12px;border-radius:11px;background:var(--panel-2);border:1px solid var(--line);margin-bottom:9px}
.lock-item:last-child{margin-bottom:0}
.lock-badge{width:30px;height:30px;border-radius:8px;display:grid;place-items:center;flex:0 0 auto}
.lock-badge.red{background:rgba(242,99,126,.12);color:var(--del)}
.lock-badge.gr{background:rgba(62,207,142,.12);color:var(--ok)}
.lock-badge svg{width:15px;height:15px}
.lock-item .li-b{min-width:0}
.lock-item .li-b .n{font-size:13.5px;font-weight:600}
.lock-item .li-b .p{font-size:11.5px;color:var(--faint)}
.lock-item .who{margin-left:auto;font-size:11.5px;color:var(--muted);white-space:nowrap}
/* ---------- tech strip ---------- */
.tech{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;text-align:center}
.tech .t{padding:24px 16px;border-radius:14px;background:var(--panel);border:1px solid var(--line)}
.tech .t .k{font-size:30px;font-weight:800;letter-spacing:-.02em;
background:linear-gradient(120deg,var(--acc-2),#cfc9ff);-webkit-background-clip:text;background-clip:text;color:transparent}
.tech .t .v{color:var(--muted);font-size:13px;margin-top:6px}
/* ---------- final CTA ---------- */
.cta{position:relative;text-align:center;border-radius:26px;padding:64px 28px;overflow:hidden;
background:linear-gradient(180deg,rgba(124,110,246,.14),rgba(91,78,224,.05));border:1px solid rgba(124,110,246,.28)}
.cta::before{content:"";position:absolute;inset:0;z-index:-1;
background:radial-gradient(600px 300px at 50% 0%,rgba(124,110,246,.25),transparent 70%)}
.cta h2{font-size:clamp(28px,4vw,44px)}
.cta p{color:var(--muted);font-size:17px;margin:16px auto 30px;max-width:52ch}
.cta .waitlist{margin-top:0}
/* ---------- footer ---------- */
footer{border-top:1px solid var(--line-2);padding:40px 0;margin-top:20px}
.foot{display:flex;align-items:center;gap:16px;flex-wrap:wrap;color:var(--faint);font-size:13.5px}
.foot .brand{font-size:15px}
.foot .fl{margin-left:auto;display:flex;gap:22px}
.foot a:hover{color:var(--muted)}
@media (max-width:900px){
.nav-links{display:none}
.m-body{grid-template-columns:1fr}
.m-left{border-right:none;border-bottom:1px solid var(--line)}
.m-stage{min-height:260px}
.grid{grid-template-columns:1fr 1fr}
.split{grid-template-columns:1fr;gap:34px}
.tech{grid-template-columns:1fr 1fr}
}
@media (max-width:560px){
.grid{grid-template-columns:1fr}
.wl-form{flex-direction:column}
.wl-form .btn{width:100%}
.hero{padding:48px 0 30px}
.mock{transform:none}
}
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
</style>
</head>
<body>
<!-- ===================== NAV ===================== -->
<header class="nav">
<div class="wrap nav-in">
<a class="brand" href="#top">
<span class="mark"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg></span>
Exbyte&nbsp;Depot <small>— Perforce</small>
</a>
<nav class="nav-links">
<a href="#features">Features</a>
<a href="#why">Why not P4V</a>
<a href="#tech">Under the hood</a>
</nav>
<div class="nav-cta">
<a class="btn ghost" href="#waitlist">Join the waitlist</a>
</div>
</div>
</header>
<a id="top"></a>
<!-- ===================== HERO ===================== -->
<section class="hero">
<div class="wrap">
<span class="eyebrow"><span class="dot"></span> A desktop client for Perforce® · by Exbyte Studios</span>
<h1>The <a class="g" href="https://www.perforce.com/products/helix-core" target="_blank" rel="noopener">Perforce</a> client your <span class="g">Unreal team</span> actually wants.</h1>
<p class="sub">Exbyte Depot is a fast, native desktop <b style="color:var(--txt)">client for <a class="tlink" href="https://www.perforce.com/products/helix-core" target="_blank" rel="noopener">Perforce Helix Core</a></b> — real 3D asset previews, one-click exclusive locks, offline work, and live teammate activity. All the power of your Perforce server, none of the P4V pain.</p>
<div class="clarify">
<svg viewBox="0 0 24 24" fill="none"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="m9 12 2 2 4-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span><b>It's a client, not a replacement.</b> Exbyte Depot connects to your existing <a class="tlink" href="https://www.perforce.com/" target="_blank" rel="noopener">Perforce</a> server through Perforce's own <span style="font-family:var(--mono);font-size:13px">p4</span> command-line — your server, history and workflow stay exactly as they are. We don't clone Perforce or reverse-engineer anything; we just give you a nicer window into it.</span>
</div>
<div class="waitlist" id="waitlist">
<form class="wl-form" id="wlForm" novalidate>
<input id="wlEmail" type="email" inputmode="email" autocomplete="email" placeholder="you@studio.com" aria-label="Email address" required />
<button class="btn primary" type="submit">
Join the waitlist
<svg viewBox="0 0 24 24" fill="none"><path d="M5 12h14m-6-6 6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</form>
<div class="wl-ok" id="wlOk">
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.7"/><path d="m8.5 12 2.5 2.5 4.5-5" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span id="wlOkMsg">You're on the list. We'll email you the moment early access opens.</span>
</div>
<div class="wl-note"><b>Windows</b> · early access · no spam, one launch email · <b id="wlCount"></b> on the list</div>
</div>
<!-- ============ APP MOCK ============ -->
<div class="mock-shell">
<div class="mock">
<!-- titlebar -->
<div class="m-title">
<span class="mark"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg></span>
<span class="t">Exbyte Depot <span>— Perforce</span></span>
<span class="m-menu"><span>File</span><span>Connection</span><span>Actions</span><span>Window</span><span>Tools</span><span>Help</span></span>
<span class="m-pill"><span class="d"></span> helix.auroragames.dev:1666</span>
<span class="m-actions">
<span class="m-ib"><svg viewBox="0 0 24 24" fill="none"><path d="M16 20v-1a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v1M9.5 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm11 9v-1a4 4 0 0 0-3-3.9M16 4.1a4 4 0 0 1 0 7.8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>
<span class="m-ib"><svg viewBox="0 0 24 24" fill="none"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
</span>
<span class="m-wc"><i class="g"></i><i class="y"></i><i class="r"></i></span>
</div>
<!-- workspace / working folder / get latest -->
<div class="m-head">
<div class="m-zone">
<span class="zi"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="13" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 20h8m-4-3v3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>
<span class="zm"><span class="l">Workspace</span><span class="v">kestrel_WS_02</span></span>
<span class="cv"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
</div>
<div class="m-zone">
<span class="zi"><svg viewBox="0 0 24 24" fill="none"><path d="M6 3v12a3 3 0 0 0 3 3h6M6 3a3 3 0 1 0 0 6M6 3a3 3 0 1 1 0 6m9 9a3 3 0 1 0 6 0 3 3 0 0 0-6 0Z" stroke="currentColor" stroke-width="1.6"/></svg></span>
<span class="zm"><span class="l">Working folder</span><span class="v">//depot/Aurora_Road</span></span>
<span class="cv"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
</div>
<div class="m-zone get">
<span class="zi"><svg viewBox="0 0 24 24" fill="none"><path d="M21 12a9 9 0 1 1-3-6.7M21 4v4h-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
<span class="zm"><span class="v">Get Latest</span><span class="l">pull from server</span></span>
</div>
</div>
<!-- body -->
<div class="m-body">
<div class="m-left">
<div class="m-tabs"><span class="on" data-tab="changes">Changes <span class="badge">3</span></span><span data-tab="history">History</span></div>
<!-- CHANGES -->
<div class="m-panel" data-panel="changes" style="display:flex;flex-direction:column;flex:1;min-height:0">
<div class="m-filter"><svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.7"/><path d="m20 20-3.5-3.5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg> Filter 3 files…</div>
<div class="m-selhead">
<span class="sc"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
<span id="mSelTxt">3 of 3 selected</span>
<span class="rf" id="mRefresh"><svg viewBox="0 0 24 24" fill="none"><path d="M21 12a9 9 0 1 1-3-6.7M21 4v4h-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg> Refresh</span>
</div>
<div class="m-list" id="mList" style="flex:1;min-height:0;overflow:auto">
<div class="m-file sel" data-name="SM_Guardrail.uasset" data-dir="— //depot/Aurora_Road/Content/Meshes/" data-meta="add · #1 · binary" data-kind="mesh" data-type="binary +l" data-prev="3D mesh" data-lab="3D preview">
<span class="m-chk"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
<span class="m-ico"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
<span class="m-fmeta"><span class="n">SM_Guardrail.uasset</span><span class="p">//depot/…/Content/Meshes/</span></span>
<span class="m-stat a">+</span>
</div>
<div class="m-file" data-name="T_Asphalt_D.uasset" data-dir="— //depot/Aurora_Road/Content/Textures/" data-meta="add · #1 · binary" data-kind="tex" data-type="binary" data-prev="2048 × 2048" data-lab="Image">
<span class="m-chk"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
<span class="m-ico"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="m3 16 5-5 4 4 3-3 6 6" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
<span class="m-fmeta"><span class="n">T_Asphalt_D.uasset</span><span class="p">//depot/…/Content/Textures/</span></span>
<span class="m-stat a">+</span>
</div>
<div class="m-file" data-name="RoadSampler.cpp" data-dir="— //depot/Aurora_Road/Source/Aurora/" data-meta="edit · #7 → #8 · text" data-kind="code" data-type="text · C++" data-prev="312 lines" data-lab="Source">
<span class="m-chk"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
<span class="m-ico"><svg viewBox="0 0 24 24" fill="none"><path d="M8 3H5a2 2 0 0 0-2 2v3m0 8v3a2 2 0 0 0 2 2h3m8-18h3a2 2 0 0 1 2 2v3m0 8v3a2 2 0 0 1-2 2h-3M9 9l6 6m0-6-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>
<span class="m-fmeta"><span class="n">RoadSampler.cpp</span><span class="p">//depot/…/Source/Aurora/</span></span>
<span class="m-stat a">+</span>
</div>
</div>
<div class="m-commit">
<div class="m-summrow">
<div class="m-input">Add guardrail mesh + asphalt texture for road kit</div>
<div class="ai-btn" title="Draft with AI"><svg viewBox="0 0 24 24" fill="none"><path d="M12 3v3m0 12v3M5 12H2m20 0h-3M6 6l2 2m8 8 2 2m0-12-2 2M8 16l-2 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.6"/></svg></div>
</div>
<div class="m-submit" id="mCommit"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg> <span id="mCommitTxt">Commit changelist · 3 files</span></div>
</div>
</div>
<!-- HISTORY -->
<div class="m-panel m-hist" data-panel="history" hidden>
<div class="m-hrow"><span class="m-hav" style="background:#7c6ef6">VD</span><span class="m-hb"><span class="d">road guardrails + collision</span><span class="mt">#34 · voss_dev · 2h ago</span></span><span class="m-hsz">146 KB</span></div>
<div class="m-hrow"><span class="m-hav" style="background:#e8b04b">MA</span><span class="m-hb"><span class="d">asphalt material pass</span><span class="mt">#33 · mira_art · 5h ago</span></span><span class="m-hsz">172 KB</span></div>
<div class="m-hrow"><span class="m-hav" style="background:#3ecf8e">NK</span><span class="m-hb"><span class="d">early car handling setup</span><span class="mt">#31 · novak_dev · yesterday</span></span><span class="m-hsz">21.3 KB</span></div>
<div class="m-hrow"><span class="m-hav" style="background:#f2637e">KS</span><span class="m-hb"><span class="d">new road master material</span><span class="mt">#28 · kestrel · 2 days ago</span></span><span class="m-hsz">6.4 KB</span></div>
<div class="m-hrow"><span class="m-hav" style="background:#5cc9c0">KS</span><span class="m-hb"><span class="d">Project Init</span><span class="mt">#18 · kestrel · last week</span></span><span class="m-hsz">34.1 GB</span></div>
</div>
</div>
<div class="m-right">
<div class="m-diffhead">
<span class="m-stat a" id="mStat" style="width:24px;height:24px;font-size:13px">+</span>
<span class="ttl"><span class="n"><span id="mName">SM_Guardrail.uasset</span> <span id="mDir">— //depot/Aurora_Road/Content/Meshes/</span></span><span class="mm" id="mMeta">add · #1 · binary</span></span>
<span class="m-revert"><svg viewBox="0 0 24 24" fill="none"><path d="M9 14 4 9l5-5M4 9h11a5 5 0 0 1 0 10h-3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg> Revert</span>
</div>
<div class="m-stage" id="mStage">
<span class="lab" id="mLab"><svg viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg> <span id="mLabTxt">3D preview</span></span>
<div class="pv pv-mesh" data-pv="mesh">
<svg class="m-cube" id="mCube" viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
<span class="m-load" id="mLoad"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg> <span id="mLoadTxt">Load 3D model</span></span>
</div>
<div class="pv pv-tex" data-pv="tex" hidden>
<div class="tex-swatch"></div>
<span class="tex-cap">2048 × 2048 · BC7 · sRGB</span>
</div>
<div class="pv pv-code" data-pv="code" hidden>
<div class="codeblk"><span class="ln"><span class="cm">// M5 — deterministic POI placement over the road graph</span></span><span class="ln"><span class="kw">void</span> <span class="ty">URoadSampler</span>::<span class="fn">Build</span>(<span class="kw">const</span> <span class="ty">FRoadConfig</span>&amp; Cfg)</span><span class="ln">{</span><span class="ln"> Graph = <span class="fn">NewObject</span>&lt;<span class="ty">URoadGraph</span>&gt;(<span class="kw">this</span>);</span><span class="ln"> Graph-&gt;<span class="fn">Initialize</span>(Cfg);</span><span class="ln"> Graph-&gt;<span class="fn">EnsureGeneratedAround</span>(<span class="ty">INDEX_NONE</span>, <span class="st">0.0</span>);</span><span class="ln"> Pois = <span class="fn">NewObject</span>&lt;<span class="ty">UPoiRegistry</span>&gt;(<span class="kw">this</span>);</span><span class="ln">}</span></div>
</div>
</div>
<div class="m-metabar">
<div class="mi"><div class="k">Type</div><div class="v" id="mvType">binary +l</div></div>
<div class="mi"><div class="k">Preview</div><div class="v" id="mvPrev">3D mesh</div></div>
<div class="mi"><div class="k">Revision</div><div class="v acc">#1</div></div>
<div class="mi"><div class="k">Change</div><div class="v">default</div></div>
</div>
</div>
</div>
<!-- status bar -->
<div class="m-status">
<span class="ok"></span><span class="cmd">p4 opened -c default //depot/Aurora_Road/… {3 files}</span>
<span class="chips">
<b><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg> Unreal</b>
<b><svg viewBox="0 0 24 24" fill="none"><path d="M4 5h16v14H4z" stroke="currentColor" stroke-width="1.6"/><path d="m7 9 2.5 2.5L7 14m5 0h4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg> Terminal</b>
<b><svg viewBox="0 0 24 24" fill="none"><path d="M4 6h16M4 12h16M4 18h10" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg> Log</b>
</span>
</div>
</div>
</div>
</div>
</section>
<!-- ===================== TRUST ===================== -->
<div class="trust wrap">
<p>Purpose-built for game studios on Perforce</p>
<div class="trust-row">
<span class="chip"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg> Unreal Engine 4 &amp; 5</span>
<a class="chip" href="https://www.perforce.com/products/helix-core" target="_blank" rel="noopener"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 20h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg> Powered by Perforce Helix Core</a>
<span class="chip"><svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.6"/><path d="M3 12h18M12 3c2.5 2.5 2.5 15 0 18M12 3c-2.5 2.5-2.5 15 0 18" stroke="currentColor" stroke-width="1.4"/></svg> Radmin VPN friendly</span>
<span class="chip"><svg viewBox="0 0 24 24" fill="none"><path d="M4 7h16M4 12h16M4 17h10" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg> 5 languages</span>
</div>
</div>
<!-- ===================== FEATURES ===================== -->
<section id="features">
<div class="wrap">
<div class="sec-head">
<span class="eyebrow"><span class="dot"></span> Everything, in one native app</span>
<h2 style="margin-top:18px">Built for the way Unreal teams actually work</h2>
<p>Perforce is the industry standard for a reason. Exbyte Depot keeps every bit of its power and finally makes it feel good to use.</p>
</div>
<div class="grid">
<div class="card">
<span class="tag">Signature</span>
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg></div>
<h3>Real 3D asset preview</h3>
<p>Rotate the actual mesh inside a <code>.uasset</code> right in the app — plus embedded thumbnails, image, audio and video previews. See what you're committing before you commit it.</p>
</div>
<div class="card">
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.6"/></svg></div>
<h3>Exclusive locks, one click</h3>
<p>Binary <code>.uasset</code> files can't be merged. Take an exclusive lock so only you edit a mesh or material at a time — and see everyone else's locks live. No more overwritten work.</p>
</div>
<div class="card">
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M4 12a8 8 0 0 1 8-8 8 8 0 0 1 8 8m0 0-3-3m3 3 3-3M20 12a8 8 0 0 1-8 8 8 8 0 0 1-8-8m0 0 3 3m-3-3-3 3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<h3>Work offline, reconcile later</h3>
<p>On a plane or the VPN's down? Keep editing. Exbyte Depot scans your workspace and reconciles adds, edits and deletes into a changelist when you're back — just like P4V, but nicer.</p>
</div>
<div class="card">
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M13.7 21a2 2 0 0 1-3.4 0" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
<h3>Live teammate activity</h3>
<p>Lives in your system tray and sends a native notification the moment a teammate submits — with their message. A banner tells you when you're behind, one click to Get Latest.</p>
</div>
<div class="card">
<span class="tag">AI</span>
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M12 3v3m0 12v3M5 12H2m20 0h-3M6 6l2 2m8 8 2 2m0-12-2 2M8 16l-2 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="12" cy="12" r="3.2" stroke="currentColor" stroke-width="1.6"/></svg></div>
<h3>AI commit summaries</h3>
<p>Draft a clear changelist message from your selected files, and get a plain-English summary of what any source file does — in your interface language.</p>
</div>
<div class="card">
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" stroke-width="1.6"/></svg></div>
<h3>Files &amp; Sizes explorer</h3>
<p>Browse the Depot and your Workspace as a tree and see exactly how much every file and folder weighs — heaviest first. Find what's bloating your repo in seconds.</p>
</div>
<div class="card">
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="m9 8-4 4 4 4m6-8 4 4-4 4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<h3>Built-in code editor</h3>
<p>Fix a header or a config right before commit — no external IDE needed. Full syntax highlighting, line numbers, and a proper editor, not a grey textbox.</p>
</div>
<div class="card">
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M6 3v12a3 3 0 0 0 3 3h6M6 3a3 3 0 1 0 0 6M6 3a3 3 0 1 1 0 6m9 9a3 3 0 1 0 6 0 3 3 0 0 0-6 0Z" stroke="currentColor" stroke-width="1.6"/></svg></div>
<h3>The full Perforce toolbox</h3>
<p>Labels, streams, integrate / merge / copy, jobs, file history with blame &amp; diff, clean, <code>.p4ignore</code>, get-revision, shelve/unshelve — the depth is all there when you need it.</p>
</div>
<div class="card">
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M21 12a9 9 0 1 1-9-9c2.5 0 4.5 1 6 2.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="m17 4 1 3-3 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<h3>Signed auto-updates</h3>
<p>Ships as a signed installer with a built-in updater. New versions land in place — the whole team stays current without chasing downloads.</p>
</div>
</div>
</div>
</section>
<!-- ===================== WHY NOT P4V ===================== -->
<section id="why" style="background:linear-gradient(180deg,transparent,rgba(124,110,246,.04),transparent)">
<div class="wrap split">
<div>
<span class="eyebrow"><span class="dot"></span> Why switch</span>
<h2 style="margin-top:18px">P4V is powerful. It's also from another era.</h2>
<p class="lead">We're not replacing your <a class="tlink" href="https://www.perforce.com/" target="_blank" rel="noopener">Perforce</a> server — we're replacing the client you look at it through. Exbyte Depot keeps every bit of Perforce power your studio depends on and rebuilds the experience around how artists and engineers really work: visual, fast, and forgiving.</p>
<div class="comp">
<div class="crow good"><span class="ck"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span><div><b>See your assets, not filenames</b><span>Live 3D meshes, thumbnails, audio &amp; video — right where you review a changelist.</span></div></div>
<div class="crow good"><span class="ck"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span><div><b>Commit locally, submit when ready</b><span>A GitHub-Desktop-style flow on top of Perforce — revertable local commits, then push.</span></div></div>
<div class="crow good"><span class="ck"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span><div><b>Never lose work to a binary conflict</b><span>Exclusive locks on <code>.uasset</code> are one click, and everyone's locks are visible.</span></div></div>
<div class="crow good"><span class="ck"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span><div><b>Know what the team is doing</b><span>Tray notifications on every submit and a clear "you're behind" nudge.</span></div></div>
</div>
</div>
<div class="panel-visual">
<div style="font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--faint);margin-bottom:14px;display:flex;align-items:center;gap:8px"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" style="color:var(--acc-2)"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.6"/></svg> File Locks</div>
<div class="lock-item"><span class="lock-badge red"><svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.7"/></svg></span><div class="li-b"><div class="n">SM_Hero_Character.uasset</div><div class="p">//depot/Char/Content/Meshes/</div></div><span class="who">🔒 mira_art</span></div>
<div class="lock-item"><span class="lock-badge red"><svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.7"/></svg></span><div class="li-b"><div class="n">M_Road_Master.uasset</div><div class="p">//depot/Road/Content/Materials/</div></div><span class="who">🔒 you</span></div>
<div class="lock-item"><span class="lock-badge gr"><svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.7"/></svg></span><div class="li-b"><div class="n">BP_GameMode.uasset</div><div class="p">//depot/Core/Content/Blueprints/</div></div><span class="who">available</span></div>
<div style="margin-top:16px;padding:13px 15px;border-radius:11px;background:rgba(124,110,246,.08);border:1px solid rgba(124,110,246,.22);font-size:13px;color:var(--acc-2);display:flex;gap:10px;align-items:center">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg>
voss_dev just submitted <b style="color:#fff;margin:0 4px">#34</b> — you're 3 files behind.
</div>
</div>
</div>
</section>
<!-- ===================== TECH ===================== -->
<section id="tech">
<div class="wrap">
<div class="sec-head">
<span class="eyebrow"><span class="dot"></span> Under the hood</span>
<h2 style="margin-top:18px">Native, fast, and quietly powerful</h2>
<p>No Electron bloat. A tiny signed installer, instant startup, and a UI that stays out of your way.</p>
</div>
<div class="tech">
<div class="t"><div class="k">Rust</div><div class="v">Native Tauri backend</div></div>
<div class="t"><div class="k">~4 MB</div><div class="v">Installer, not 200</div></div>
<div class="t"><div class="k">5</div><div class="v">Languages built in</div></div>
<div class="t"><div class="k">0</div><div class="v">Servers to configure</div></div>
</div>
</div>
</section>
<!-- ===================== FINAL CTA ===================== -->
<section style="padding-top:20px">
<div class="wrap">
<div class="cta">
<h2>Get early access to Exbyte Depot</h2>
<p>We're onboarding studios in waves. Drop your email and we'll send your download the moment your spot opens.</p>
<div class="waitlist">
<form class="wl-form" id="wlForm2" novalidate>
<input id="wlEmail2" type="email" inputmode="email" autocomplete="email" placeholder="you@studio.com" aria-label="Email address" required />
<button class="btn primary" type="submit">Reserve my spot
<svg viewBox="0 0 24 24" fill="none"><path d="M5 12h14m-6-6 6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</form>
<div class="wl-ok" id="wlOk2">
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.7"/><path d="m8.5 12 2.5 2.5 4.5-5" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span>You're on the list. Talk soon.</span>
</div>
</div>
</div>
</div>
</section>
<!-- ===================== FOOTER ===================== -->
<footer>
<div class="wrap foot">
<a class="brand" href="#top">
<span class="mark"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg></span>
Exbyte&nbsp;Depot
</a>
<span class="fl" style="margin-left:0">
<a href="#features">Features</a>
<a href="#waitlist">Waitlist</a>
<a class="tlink" href="https://www.perforce.com/" target="_blank" rel="noopener">Perforce.com</a>
</span>
<span style="flex-basis:100%;color:var(--faint);font-size:12.5px;line-height:1.6;margin-top:6px">
© <span id="yr"></span> Exbyte Studios. Exbyte Depot is an independent desktop <b style="color:var(--muted);font-weight:600">client for Perforce Helix Core</b> and requires a Perforce server. It is not affiliated with, endorsed by, or sponsored by Perforce Software, Inc. “Perforce”, “Helix Core” and “P4V” are trademarks of <a class="tlink" href="https://www.perforce.com/" target="_blank" rel="noopener">Perforce Software, Inc.</a>, used here only to describe compatibility.
</span>
</div>
</footer>
<script>
(function(){
"use strict";
var KEY = "exd_waitlist";
function list(){ try { return JSON.parse(localStorage.getItem(KEY) || "[]"); } catch(e){ return []; } }
function save(a){ try { localStorage.setItem(KEY, JSON.stringify(a)); } catch(e){} }
// Signups POST to the waitlist server (../waitlist-server). The server dedups
// and stores them; localStorage is kept as a mirror for the social-proof
// counter and as an offline fallback if the page is opened without a backend.
var API = "/api/waitlist";
function submitToWaitlist(email){
var a = list();
if (a.indexOf(email) === -1) a.push(email);
save(a);
return fetch(API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email, source: "landing", website: "" })
})
.then(function(r){ return r.ok ? r.json() : null; })
.catch(function(){ return null; }); // never block the UX on a network error
}
var BASE = 1240; // shown alongside real signups for social proof
function renderCount(){
var el = document.getElementById("wlCount");
if (el) el.textContent = (BASE + list().length).toLocaleString() + " studios";
}
renderCount();
document.getElementById("yr").textContent = new Date().getFullYear();
function valid(v){ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); }
function wire(formId, emailId, okId){
var form = document.getElementById(formId);
if (!form) return;
form.addEventListener("submit", function(e){
e.preventDefault();
var input = document.getElementById(emailId);
var email = (input.value || "").trim().toLowerCase();
if (!valid(email)){
input.focus();
input.style.boxShadow = "0 0 0 2px var(--del)";
setTimeout(function(){ input.style.boxShadow = ""; }, 1400);
return;
}
var already = list().indexOf(email) !== -1;
submitToWaitlist(email).then(function(){
form.style.display = "none";
var ok = document.getElementById(okId);
if (ok){
ok.classList.add("show");
if (okId === "wlOk"){
var msg = document.getElementById("wlOkMsg");
if (msg) msg.textContent = already
? "You're already on the list — we've got you."
: "You're on the list. We'll email you the moment early access opens.";
}
}
renderCount();
});
});
}
wire("wlForm", "wlEmail", "wlOk");
wire("wlForm2", "wlEmail2", "wlOk2");
})();
/* ===== interactive app-window mock ===== */
(function(){
"use strict";
var mock = document.querySelector(".mock");
if (!mock) return;
var $ = function(s){ return mock.querySelector(s); };
var $$ = function(s){ return Array.prototype.slice.call(mock.querySelectorAll(s)); };
/* --- tab switching (Changes / History) --- */
$$(".m-tabs span").forEach(function(tab){
tab.addEventListener("click", function(){
var name = tab.getAttribute("data-tab");
$$(".m-tabs span").forEach(function(t){ t.classList.toggle("on", t === tab); });
$$(".m-panel").forEach(function(p){ p.hidden = p.getAttribute("data-panel") !== name; });
});
});
/* --- file selection swaps the right-hand preview --- */
function selectFile(f){
$$(".m-file").forEach(function(x){ x.classList.toggle("sel", x === f); });
var d = f.dataset;
$("#mName").textContent = d.name;
$("#mDir").textContent = d.dir;
$("#mMeta").textContent = d.meta;
$("#mvType").textContent = d.type;
$("#mvPrev").textContent = d.prev;
$("#mLabTxt").textContent = d.lab;
$$(".pv").forEach(function(pv){ pv.hidden = pv.getAttribute("data-pv") !== d.kind; });
}
$$(".m-file").forEach(function(f){
f.addEventListener("click", function(e){
if (e.target.closest(".m-chk")) return; // checkbox handled separately
selectFile(f);
});
});
/* --- checkboxes toggle the selected count + commit button --- */
function refreshCount(){
var on = $$(".m-file:not(.off)").length;
$("#mSelTxt").textContent = on + " of 3 selected";
$("#mCommitTxt").textContent = on ? ("Commit changelist · " + on + (on === 1 ? " file" : " files")) : "Select files to commit";
$("#mCommit").style.opacity = on ? "1" : ".55";
}
$$(".m-file .m-chk").forEach(function(chk){
chk.addEventListener("click", function(e){
e.stopPropagation();
chk.closest(".m-file").classList.toggle("off");
refreshCount();
});
});
/* --- little press / spin feedback --- */
function pressSpin(el, ms){
if (!el || el.classList.contains("spin")) return;
el.classList.add("spin");
setTimeout(function(){ el.classList.remove("spin"); }, ms || 900);
}
var rf = $("#mRefresh"); if (rf) rf.addEventListener("click", function(){ pressSpin(rf, 800); });
var get = $(".m-zone.get"); if (get) get.addEventListener("click", function(){ pressSpin(get, 1100); });
/* --- Load 3D model → spinner, then a slowly spinning mesh --- */
var load = $("#mLoad"), cube = $("#mCube"), loaded = false;
if (load){
load.addEventListener("click", function(){
if (loaded){ return; }
var txt = $("#mLoadTxt");
load.innerHTML = '<span class="m-spinner"></span> Rendering…';
setTimeout(function(){
loaded = true;
cube.classList.add("loaded");
load.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"/></svg> Loaded';
load.style.opacity = ".7";
var pv = $("#mvPrev"); if (pv && pv.textContent.indexOf("3D") === 0) pv.textContent = "3D mesh · loaded";
}, 950);
});
}
/* --- Commit button press --- */
var commit = $("#mCommit");
if (commit){
commit.addEventListener("click", function(){
if ($$(".m-file:not(.off)").length === 0) return;
var t = $("#mCommitTxt"), old = t.textContent;
commit.style.transform = "scale(.98)";
t.textContent = "Committing…";
setTimeout(function(){
commit.style.transform = "";
t.textContent = "✓ Changelist created locally";
setTimeout(function(){ t.textContent = old; }, 1500);
}, 700);
});
}
})();
</script>
</body>
</html>

14
package-lock.json generated
View File

@ -1,15 +1,16 @@
{
"name": "exbyte-depot",
"version": "0.2.0",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "exbyte-depot",
"version": "0.2.0",
"version": "0.3.0",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
@ -1456,6 +1457,15 @@
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",

View File

@ -1,7 +1,7 @@
{
"name": "exbyte-depot",
"private": true,
"version": "0.3.0",
"version": "0.3.2",
"type": "module",
"scripts": {
"dev": "vite",
@ -12,6 +12,7 @@
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",

155
src-tauri/Cargo.lock generated
View File

@ -343,6 +343,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.0"
@ -960,7 +966,7 @@ dependencies = [
[[package]]
name = "exbyte-depot"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"encoding_rs",
"serde",
@ -968,6 +974,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-updater",
@ -1733,6 +1740,19 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.1",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@ -2022,6 +2042,20 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "mac-notification-sys"
version = "0.6.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
dependencies = [
"cc",
"log",
"objc2",
"objc2-foundation",
"time",
"uuid",
]
[[package]]
name = "markup5ever"
version = "0.38.0"
@ -2081,6 +2115,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "muda"
version = "0.19.3"
@ -2132,6 +2176,20 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "notify-rust"
version = "4.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891"
dependencies = [
"futures-lite",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@ -2634,6 +2692,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@ -2702,6 +2769,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-xml"
version = "0.41.0"
@ -2732,6 +2805,35 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@ -3593,6 +3695,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"image",
"jni 0.21.1",
"libc",
"log",
@ -3747,6 +3850,25 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-notification"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
dependencies = [
"log",
"notify-rust",
"rand",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"time",
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.4"
@ -3912,6 +4034,17 @@ dependencies = [
"toml 1.1.2+spec-1.1.0",
]
[[package]]
name = "tauri-winrt-notification"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade"
dependencies = [
"thiserror 2.0.18",
"windows",
"windows-version",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@ -5299,6 +5432,26 @@ dependencies = [
"zvariant",
]
[[package]]
name = "zerocopy"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "zerofrom"
version = "0.1.8"

View File

@ -1,6 +1,6 @@
[package]
name = "exbyte-depot"
version = "0.3.0"
version = "0.3.2"
description = "Exbyte Depot — native Perforce client by Exbyte Studios"
authors = ["Exbyte Studios"]
edition = "2021"
@ -18,8 +18,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-opener = "2"
tauri-plugin-notification = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-dialog = "2.7.1"

View File

@ -11,8 +11,13 @@
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-set-focus",
"core:window:allow-unminimize",
"dialog:default",
"updater:default",
"process:default"
"process:default",
"notification:default"
]
}

View File

@ -4,6 +4,7 @@
use serde_json::Value;
use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, Manager, State};
@ -12,6 +13,17 @@ use tauri::{AppHandle, Emitter, Manager, State};
/// command log to the frontend (a Perforce-style "Log" panel).
static APP: OnceLock<AppHandle> = OnceLock::new();
/// Set true to ask an in-progress streaming reconcile (disk scan) to stop early.
/// The scan loop checks it per line and kills the p4 child when it flips. Reset
/// to false at the start of each scan.
static SCAN_CANCEL: AtomicBool = AtomicBool::new(false);
/// Set true to ask an in-progress streaming transfer (submit/sync) to stop. The
/// run_stream loop checks it per line and kills the p4 child. For submit this is
/// safe only during the file-transfer phase: p4 submit is transactional, so an
/// aborted transfer leaves the changelist pending (nothing half-committed).
static TRANSFER_CANCEL: AtomicBool = AtomicBool::new(false);
/// 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>) {
@ -312,16 +324,44 @@ async fn p4_opened(state: State<'_, AppState>, scope: String) -> Result<Vec<Valu
}
}
/// List sub-directories of a depot path (for the working-folder picker).
/// List sub-directories of a depot path for the working-folder picker. Includes
/// LOCAL-only folders that exist on disk but aren't in the depot yet (flagged
/// `local: true`) so a new local project can be navigated to and pushed.
#[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])
let base = path.trim_end_matches(['/', '\\']).to_string();
let spec = if base.is_empty() { "//*".to_string() } else { format!("{}/*", base) };
let mut dirs = run_json(&conn, &["dirs", &spec]).unwrap_or_default();
// union in local-only subfolders (present on disk, not in the depot)
let local_parent = if base.is_empty() { conn.root.clone() } else { scope_local_dir(&conn, &base).unwrap_or_default() };
if !local_parent.is_empty() {
let seen: std::collections::HashSet<String> = dirs.iter()
.filter_map(|d| d.get("dir").and_then(|s| s.as_str()).and_then(|s| s.rsplit('/').next()).map(|s| s.to_lowercase()))
.collect();
if let Ok(rd) = std::fs::read_dir(&local_parent) {
for e in rd.flatten() {
if !e.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; }
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with('.') || seen.contains(&name.to_lowercase()) { continue; } // skip hidden + already-in-depot
// depot path this local folder maps to
let depot = if base.is_empty() {
let probe = e.path().join("...").to_string_lossy().to_string();
run_json(&conn, &["where", &probe]).ok()
.and_then(|v| v.into_iter().next())
.and_then(|o| o.get("depotFile").and_then(|s| s.as_str()).map(|s| s.trim_end_matches("/...").trim_end_matches("...").trim_end_matches('/').to_string()))
.unwrap_or_default()
} else {
format!("{}/{}", base, name)
};
if !depot.is_empty() && depot.starts_with("//") {
dirs.push(serde_json::json!({ "dir": depot, "local": true }));
}
}
}
}
Ok(dirs)
}
/// Browse a depot folder AND weigh it: returns the immediate children of `path`
@ -525,8 +565,11 @@ fn progress_file(line: &str) -> 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> {
/// `op` labels the operation ("sync" | "submit"). `total` is the expected file
/// count (0 = unknown) so the UI can show "N of total" + a percentage bar.
/// Cancellable via TRANSFER_CANCEL (kills the p4 child mid-transfer).
fn run_stream(conn: &Conn, args: &[&str], op: &str, total: i64) -> Result<String, String> {
TRANSFER_CANCEL.store(false, Ordering::SeqCst);
let mut child = match p4_cmd(conn, false)
.args(args)
.stdout(Stdio::piped())
@ -544,15 +587,21 @@ fn run_stream(conn: &Conn, args: &[&str], op: &str) -> Result<String, String> {
let mut collected = String::new();
let mut count: i64 = 0;
let mut last = Instant::now();
let mut cancelled = false;
if let Some(out) = child.stdout.take() {
for line in BufReader::new(out).lines().map_while(Result::ok) {
if TRANSFER_CANCEL.load(Ordering::SeqCst) {
cancelled = true;
let _ = child.kill();
break;
}
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 }),
serde_json::json!({ "op": op, "count": count, "total": total, "file": progress_file(&line), "done": false }),
);
}
last = Instant::now();
@ -576,9 +625,13 @@ fn run_stream(conn: &Conn, args: &[&str], op: &str) -> Result<String, String> {
if let Some(app) = APP.get() {
let _ = app.emit(
"p4-transfer",
serde_json::json!({ "op": op, "count": count, "done": true }),
serde_json::json!({ "op": op, "count": count, "total": total, "done": true, "cancelled": cancelled }),
);
}
if cancelled {
log_p4(args, count, false, Some("cancelled by user"));
return Err("Cancelled.".into());
}
if !status.success() && !stderr.is_empty() {
log_p4(args, 0, false, Some(&stderr));
return Err(stderr);
@ -587,11 +640,21 @@ fn run_stream(conn: &Conn, args: &[&str], op: &str) -> Result<String, String> {
Ok(collected.trim().to_string())
}
/// Count the files opened in a numbered changelist (the submit "total").
fn count_opened_in(conn: &Conn, change: &str) -> i64 {
if change.trim().is_empty() || !change.trim().chars().all(|c| c.is_ascii_digit()) {
return 0;
}
run_json(conn, &["opened", "-c", change.trim()])
.map(|v| v.len() as i64)
.unwrap_or(0)
}
/// 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")?;
let msg = run_stream(&conn, &["sync", &scope_spec(&scope)], "sync", 0)?;
Ok(if msg.is_empty() { "Already up to date — nothing to sync.".into() } else { msg })
}
@ -631,7 +694,7 @@ async fn p4_sync_to(state: State<'_, AppState>, scope: String, rev: String) -> R
// 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")?;
let msg = run_stream(&conn, &["sync", &spec], "sync", 0)?;
Ok(if msg.is_empty() { "Already at that revision — nothing to sync.".into() } else { msg })
}
@ -695,11 +758,27 @@ async fn p4_commit(
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());
// reopen the files into the new changelist in batches — 63k paths in one
// command line would exceed the Windows limit and fail to spawn. Emit a
// `p4-commit` progress event per batch so the UI can show N/total live.
let total = files.len() as i64;
let prefix_len = "reopen -c ".len() + cl.len();
let mut done: i64 = 0;
for r in file_batches(&files, prefix_len) {
let batch_len = (r.end - r.start) as i64;
let mut reopen: Vec<&str> = vec!["reopen", "-c", &cl];
for f in &files[r] {
reopen.push(f.as_str());
}
run_text(&conn, &reopen)?;
done += batch_len;
if let Some(app) = APP.get() {
let _ = app.emit(
"p4-commit",
serde_json::json!({ "count": done, "total": total, "done": done >= total }),
);
}
}
run_text(&conn, &reopen)?;
Ok(cl)
}
@ -707,7 +786,8 @@ async fn p4_commit(
#[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")
let total = count_opened_in(&conn, &change);
run_stream(&conn, &["submit", "-c", &change], "submit", total)
}
/// Submit a shelved changelist directly from the server (`submit -e`), without
@ -715,7 +795,7 @@ async fn p4_submit_change(state: State<'_, AppState>, change: String) -> Result<
#[tauri::command]
async fn p4_submit_shelved(state: State<'_, AppState>, change: String) -> Result<String, String> {
let conn = current(&state)?;
run_stream(&conn, &["submit", "-e", change.trim()], "submit")
run_stream(&conn, &["submit", "-e", change.trim()], "submit", 0)
}
/// Submit a numbered changelist with options: `reopen` keeps the files checked
@ -725,6 +805,7 @@ async fn p4_submit_shelved(state: State<'_, AppState>, change: String) -> Result
async fn p4_submit_opts(state: State<'_, AppState>, change: String, reopen: bool, revert_unchanged: bool) -> Result<String, String> {
let conn = current(&state)?;
let ch = change.trim().to_string();
let total = count_opened_in(&conn, &ch);
let mut args: Vec<String> = vec!["submit".into(), "-c".into(), ch];
if reopen {
args.push("-r".into());
@ -734,7 +815,7 @@ async fn p4_submit_opts(state: State<'_, AppState>, change: String, reopen: bool
args.push("revertunchanged".into());
}
let refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
run_stream(&conn, &refs, "submit")
run_stream(&conn, &refs, "submit", total)
}
/// Path of the workspace `.p4ignore` file (at the client root).
@ -835,28 +916,64 @@ async fn p4_submit(
if files.is_empty() {
return Err("No files selected to submit".into());
}
let total = run_json(&conn, &["opened"])?.len();
let n = files.len() as i64;
let opened_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");
if files.len() >= opened_total {
return run_stream(&conn, &["submit", "-d", &description], "submit", n);
}
// partial submit → dedicated numbered changelist
// partial submit → dedicated numbered changelist (batched reopen for large sets)
let cl = create_changelist(&conn, &description)?;
let mut reopen: Vec<&str> = vec!["reopen", "-c", &cl];
for f in &files {
reopen.push(f.as_str());
let prefix_len = "reopen -c ".len() + cl.len();
for r in file_batches(&files, prefix_len) {
let mut reopen: Vec<&str> = vec!["reopen", "-c", &cl];
for f in &files[r] {
reopen.push(f.as_str());
}
run_text(&conn, &reopen)?;
}
run_text(&conn, &reopen)?;
run_stream(&conn, &["submit", "-c", &cl], "submit")
run_stream(&conn, &["submit", "-c", &cl], "submit", n)
}
/// 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());
/// Split a file list into batches whose joined command line stays safely under
/// the Windows ~32 KiB command-line limit. Passing tens of thousands of depot
/// paths in one `p4` invocation (e.g. committing a 63k-file initial import) blows
/// past that limit and the process can't even spawn — so we chunk it. `prefix_len`
/// is the length of the fixed args before the file list (e.g. "reopen -c 42").
fn file_batches(files: &[String], prefix_len: usize) -> Vec<std::ops::Range<usize>> {
const BUDGET: usize = 8000; // conservative; real limit is ~32767 chars
let mut ranges = Vec::new();
let (mut start, mut i, mut len) = (0usize, 0usize, prefix_len);
while i < files.len() {
let flen = files[i].len() + 3; // path + a little slack for spacing/quoting
if i > start && len + flen > BUDGET {
ranges.push(start..i);
start = i;
len = prefix_len;
}
len += flen;
i += 1;
}
run_json(conn, &args)
if start < files.len() {
ranges.push(start..files.len());
}
ranges
}
fn files_cmd(conn: &Conn, cmd: &str, files: &[String]) -> Result<Vec<Value>, String> {
if files.is_empty() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for r in file_batches(files, cmd.len() + 1) {
let mut args: Vec<&str> = vec![cmd];
for f in &files[r] {
args.push(f.as_str());
}
out.extend(run_json(conn, &args)?);
}
Ok(out)
}
/// Mark files for add.
@ -903,6 +1020,110 @@ async fn p4_scan(state: State<'_, AppState>, scope: String) -> Result<Vec<Value>
run_json(&conn, &["reconcile", "-e", "-a", "-d", &scope_spec(&scope)])
}
/// Run `reconcile` streaming its plain-text output line-by-line, emitting a
/// `p4-scan` progress event per file it opens so the UI shows a live "found N
/// files" counter instead of an opaque spinner. Cancellable: the loop watches
/// SCAN_CANCEL and kills the p4 child when the user hits Cancel. Returns the
/// number of files opened (the caller re-reads `p4 opened` for the actual list).
fn run_reconcile_stream(conn: &Conn, spec: &str) -> Result<i64, String> {
SCAN_CANCEL.store(false, Ordering::SeqCst);
let args = ["reconcile", "-e", "-a", "-d", spec];
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 count: i64 = 0;
let mut last = Instant::now();
let mut cancelled = false;
if let Some(out) = child.stdout.take() {
for line in BufReader::new(out).lines().map_while(Result::ok) {
if SCAN_CANCEL.load(Ordering::SeqCst) {
cancelled = true;
let _ = child.kill();
break;
}
// reconcile prints one `//depot/… - opened for add|edit|delete` per file
if !line.starts_with("//") {
continue;
}
count += 1;
// throttle to ~15 fps but always send the first few so the counter appears instantly
if count <= 5 || last.elapsed().as_millis() >= 66 {
if let Some(app) = APP.get() {
let _ = app.emit(
"p4-scan",
serde_json::json!({ "count": count, "file": progress_file(&line), "done": false }),
);
}
last = Instant::now();
}
}
}
let _ = child.wait();
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();
// final event so the UI can clear the counter / show the total
if let Some(app) = APP.get() {
let _ = app.emit(
"p4-scan",
serde_json::json!({ "count": count, "done": true, "cancelled": cancelled }),
);
}
// reconcile writes benign notes ("no file(s) to reconcile") to stderr — only a
// real connection/auth failure should surface as an error to the user
let hard_err = !cancelled
&& count == 0
&& (stderr.contains("Connect to server failed")
|| stderr.contains("Perforce password")
|| stderr.contains("invalid or unset"));
if hard_err {
log_p4(&args, 0, false, Some(&stderr));
return Err(stderr);
}
log_p4(&args, count, true, None);
Ok(count)
}
/// Streaming disk scan (reconcile) with a live progress counter + cancel support.
/// Replaces the buffered `p4_scan` for the UI's "Rescan / Scanning…" flow.
#[tauri::command]
async fn p4_scan_stream(state: State<'_, AppState>, scope: String) -> Result<i64, String> {
let conn = current(&state)?;
run_reconcile_stream(&conn, &scope_spec(&scope))
}
/// Ask the in-progress streaming scan to stop as soon as it can.
#[tauri::command]
fn p4_scan_cancel() {
SCAN_CANCEL.store(true, Ordering::SeqCst);
}
/// Ask the in-progress submit/sync transfer to stop. Safe for submit only during
/// the transfer phase (p4 submit is transactional — an aborted transfer leaves
/// the changelist pending, nothing half-committed).
#[tauri::command]
fn p4_transfer_cancel() {
TRANSFER_CANCEL.store(true, Ordering::SeqCst);
}
/// 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
@ -1209,12 +1430,58 @@ async fn find_sln(state: State<'_, AppState>, scope: String) -> Result<String, S
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.
/// Read a single string value from the Windows registry via `reg query`.
#[cfg(windows)]
fn reg_value(root: &str, key: &str, name: &str) -> Option<String> {
let mut c = Command::new("reg");
{
use std::os::windows::process::CommandExt;
c.creation_flags(0x0800_0000);
}
let out = c.args(["query", &format!("{root}\\{key}"), "/v", name]).output().ok()?;
let s = decode(&out.stdout);
for line in s.lines() {
if let Some(i) = line.find("REG_SZ") {
let v = line[i + 6..].trim();
if !v.is_empty() {
return Some(v.to_string());
}
}
}
None
}
/// Resolve the Unreal Engine install directory for a `.uproject` by reading its
/// `EngineAssociation` and looking it up in the registry — a version like "5.7"
/// (installed engine) or a `{GUID}` (source build).
#[cfg(windows)]
fn resolve_engine_dir(uproject: &str) -> Option<String> {
let txt = std::fs::read_to_string(uproject).ok()?;
let after = txt.split("\"EngineAssociation\"").nth(1)?;
let assoc = after.split('"').nth(1)?.trim().to_string();
if assoc.is_empty() {
return None;
}
let dir = if assoc.starts_with('{') {
// source / custom build → the Builds registry maps the GUID to a path
reg_value("HKCU", "SOFTWARE\\Epic Games\\Unreal Engine\\Builds", &assoc)
.or_else(|| reg_value("HKLM", "SOFTWARE\\Epic Games\\Unreal Engine\\Builds", &assoc))?
} else {
// launcher / installed engine → InstalledDirectory
reg_value("HKLM", &format!("SOFTWARE\\EpicGames\\Unreal Engine\\{assoc}"), "InstalledDirectory")
.or_else(|| reg_value("HKLM", &format!("SOFTWARE\\Wow6432Node\\EpicGames\\Unreal Engine\\{assoc}"), "InstalledDirectory"))?
};
Some(dir.replace('/', "\\").trim_end_matches('\\').to_string())
}
/// Build a project's C++ code. For an Unreal project (a `.uproject` in the
/// scope) this drives **UnrealBuildTool** on just the game module — fast and
/// robust, and it doesn't choke on plugin build-intermediates the way a full
/// `MSBuild <solution>` does. For a plain C++ solution it falls back to MSBuild.
/// Output streams line-by-line as `build-log` events.
#[tauri::command]
async fn build_sln(path: String, config: String) -> Result<String, String> {
if !std::path::Path::new(&path).exists() {
async fn build_sln(path: String, config: String, uproject: String) -> Result<String, String> {
if !std::path::Path::new(&path).exists() && uproject.is_empty() {
return Err(format!("Solution not found: {path}"));
}
let emit = |line: String, done: bool, ok: bool| {
@ -1226,53 +1493,82 @@ async fn build_sln(path: String, config: String) -> Result<String, String> {
}
};
// 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);
// Prefer UnrealBuildTool for a real UE project — builds only the game module.
let ue = if !uproject.is_empty() && std::path::Path::new(&uproject).exists() {
#[cfg(windows)]
{ resolve_engine_dir(&uproject) }
#[cfg(not(windows))]
{ None }
} else {
None
};
let mut cmd = if let Some(engine) = ue {
let ubt = format!("{engine}\\Engine\\Binaries\\DotNET\\UnrealBuildTool\\UnrealBuildTool.exe");
if !std::path::Path::new(&ubt).exists() {
let m = format!("UnrealBuildTool not found: {ubt}");
emit(m.clone(), true, false);
return Err(m);
}
let stem = std::path::Path::new(&uproject).file_stem().and_then(|s| s.to_str()).unwrap_or("Game").to_string();
let editor = config.to_lowercase().contains("editor");
// UBT config is the base word (Development / DebugGame / Shipping); the
// target name carries the Editor suffix.
let ubt_conf = {
let c = config.replace(" Editor", "").replace(" editor", "");
let c = c.trim();
if c.is_empty() { "Development".to_string() } else { c.to_string() }
};
let target = if editor { format!("{stem}Editor") } else { stem };
emit(format!("UnrealBuildTool → {target} · Win64 · {ubt_conf}"), false, false);
emit(format!("Engine: {engine}"), false, false);
let mut c = Command::new(&ubt);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
vc.creation_flags(0x0800_0000);
c.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(),
c.args([target, "Win64".into(), ubt_conf, format!("-Project={uproject}"), "-WaitMutex".into(), "-NoHotReloadFromIDE".into()]);
c
} else {
// plain MSBuild solution (non-UE C++ project)
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 c = Command::new(&msbuild);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
c.creation_flags(0x0800_0000);
}
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());
}
c.args(&args);
c
};
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());
cmd.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}");
let m = format!("Could not start the build tool (Visual Studio C++ tools / Unreal installed?): {e}");
emit(m.clone(), true, false);
return Err(m);
}
@ -1372,6 +1668,124 @@ async fn write_depot(state: State<'_, AppState>, depot: String, content: String)
Ok(())
}
/// Create a new folder in the workspace (optionally as a project) under `scope`
/// (a depot path, or "" = workspace root). Perforce has no empty folders, so a
/// `README.md` placeholder is created and opened for add — commit it to make the
/// folder real on the server. `name` may include nested segments ("Game/Source").
/// Returns the new folder's depot path. Confined to the workspace root.
#[tauri::command]
async fn make_folder(state: State<'_, AppState>, scope: String, name: String) -> Result<String, String> {
let conn = current(&state)?;
let clean = name.trim().replace('\\', "/");
let clean = clean.trim_matches('/').to_string();
if clean.is_empty()
|| clean.contains(':')
|| clean.split('/').any(|s| s.is_empty() || s == ".." || s == ".")
{
return Err("Invalid folder name".into());
}
// resolve the local parent directory
let parent = if scope.trim().is_empty() {
if conn.root.is_empty() {
return Err("No workspace root for this connection".into());
}
conn.root.clone()
} else {
scope_local_dir(&conn, &scope).ok_or("Could not resolve the local folder for this depot path")?
};
ensure_under_root(&conn, &parent)?; // the parent must be inside the workspace
let new_dir = std::path::Path::new(&parent).join(&clean);
std::fs::create_dir_all(&new_dir).map_err(|e| format!("Could not create folder: {e}"))?;
// placeholder so the folder can live in Perforce (no empty dirs on the server)
let leaf = clean.rsplit('/').next().unwrap_or(&clean);
let readme = new_dir.join("README.md");
if !readme.exists() {
std::fs::write(&readme, format!("# {leaf}\n\nNew project folder.\n").as_bytes())
.map_err(|e| format!("Could not create placeholder: {e}"))?;
}
// open it for add and report the depot folder from the result
let added = files_cmd(&conn, "add", &[readme.to_string_lossy().to_string()]).unwrap_or_default();
let depot = added
.first()
.and_then(|v| v.get("depotFile").and_then(|s| s.as_str()))
.and_then(|f| f.rsplit_once('/').map(|(d, _)| d.to_string()))
.unwrap_or_default();
Ok(depot)
}
/// Delete a folder: mark every submitted file under it for delete (`p4 delete
/// //folder/...`, reviewable — submit removes it from the server) and un-open any
/// freshly-added files. A folder that has nothing in the depot is just removed
/// from disk. `path` is a depot path (`//…`) or a local dir. Confined to the root.
#[tauri::command]
async fn delete_folder(state: State<'_, AppState>, path: String) -> Result<Vec<Value>, String> {
let conn = current(&state)?;
if !path.starts_with("//") {
// local-only folder → remove on disk (confined)
ensure_under_root(&conn, &path)?;
std::fs::remove_dir_all(&path).map_err(|e| format!("Could not delete folder: {e}"))?;
return Ok(Vec::new());
}
let base = path.trim_end_matches(['/', '.']);
let spec = format!("{base}/...");
// un-open anything already opened under here (adds/edits/deletes) so delete is clean
let _ = run_json(&conn, &["revert", &spec]);
let deleted = run_json(&conn, &["delete", &spec]).or_else(|e| {
let l = e.to_lowercase();
if l.contains("no such file") || l.contains("not on client") || l.contains("no file(s)") || l.contains("empty") {
Ok(Vec::new())
} else {
Err(e)
}
})?;
// nothing versioned here → drop the local directory outright
if deleted.is_empty() {
if let Some(local) = scope_local_dir(&conn, base) {
if ensure_under_root(&conn, &local).is_ok() {
let _ = std::fs::remove_dir_all(&local);
}
}
}
Ok(deleted)
}
/// Rename / move a folder: `p4 move //old/... //parent/new/...`. Source files are
/// opened for edit first so submitted content moves too. `new_name` is the new
/// leaf name (may nest with `/`). On submit the folder is renamed on the server.
#[tauri::command]
async fn rename_folder(state: State<'_, AppState>, path: String, new_name: String) -> Result<Vec<Value>, String> {
let conn = current(&state)?;
if !path.starts_with("//") {
return Err("Renaming needs a depot path".into());
}
let base = path.trim_end_matches(['/', '.']);
let parent = base.rsplit_once('/').map(|(p, _)| p).unwrap_or("");
let clean = new_name.trim().replace('\\', "/");
let clean = clean.trim_matches('/').to_string();
if clean.is_empty() || clean.contains(':') || clean.split('/').any(|s| s.is_empty() || s == ".." || s == ".") {
return Err("Invalid folder name".into());
}
let src = format!("{base}/...");
let dst = format!("{parent}/{clean}/...");
// open submitted files for edit (added files move without this); ignore "nothing to edit"
let _ = run_json(&conn, &["edit", &src]);
run_json(&conn, &["move", &src, &dst])
}
/// Folder summary for the Properties dialog: file count and total byte size from
/// `p4 sizes -s //folder/...`.
#[tauri::command]
async fn folder_info(state: State<'_, AppState>, path: String) -> Result<Value, String> {
let conn = current(&state)?;
let base = path.trim_end_matches(['/', '\\', '.']);
let spec = format!("{base}/...");
let row = run_json(&conn, &["sizes", "-s", &spec]).ok().and_then(|v| v.into_iter().next());
let count = row.as_ref().and_then(|o| o.get("fileCount")).and_then(|s| s.as_str()).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
let size = row.as_ref().and_then(|o| o.get("fileSize")).and_then(|s| s.as_str()).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
Ok(serde_json::json!({ "path": base, "fileCount": count, "size": size }))
}
/// 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> {
@ -2024,10 +2438,37 @@ async fn p4_reopen_to(state: State<'_, AppState>, change: String, files: Vec<Str
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])?;
// -l → full description so notifications can show what the teammate wrote
let v = run_json(&conn, &["changes", "-s", "submitted", "-m", "1", "-l", &spec])?;
Ok(v.into_iter().next().unwrap_or(Value::Null))
}
/// How far the workspace is behind the server: previews `p4 sync -n` and counts
/// the files that would update (added / updated / deleted), plus a few sample
/// names. `{count, sample}`; count 0 = up to date. Non-destructive.
#[tauri::command]
async fn p4_behind(state: State<'_, AppState>, scope: String) -> Result<Value, String> {
let conn = current(&state)?;
let spec = scope_spec(&scope);
match run_json(&conn, &["sync", "-n", &spec]) {
Ok(v) => {
let files: Vec<&Value> = v.iter().filter(|o| o.get("depotFile").is_some() || o.get("clientFile").is_some()).collect();
let sample: Vec<String> = files.iter().take(6).filter_map(|o| {
o.get("depotFile").or_else(|| o.get("clientFile")).and_then(|s| s.as_str()).map(|s| s.rsplit(['/', '\\']).next().unwrap_or(s).to_string())
}).collect();
Ok(serde_json::json!({ "count": files.len(), "sample": sample }))
}
Err(e) => {
let l = e.to_lowercase();
if l.contains("up-to-date") || l.contains("no such file") || l.contains("no such area") || l.contains("not in client") {
Ok(serde_json::json!({ "count": 0, "sample": [] }))
} else {
Err(e)
}
}
}
}
/// Preview `p4 clean` — files that would be deleted (local extras), restored
/// (locally deleted) or refreshed (locally modified but not opened) to make the
/// workspace exactly match the depot. Non-destructive (`-n`).
@ -2049,10 +2490,30 @@ async fn p4_clean_preview(state: State<'_, AppState>, scope: String) -> Result<V
#[tauri::command]
async fn p4_clean_apply(state: State<'_, AppState>, scope: String) -> Result<String, String> {
let conn = current(&state)?;
let msg = run_stream(&conn, &["clean", &scope_spec(&scope)], "sync")?;
let msg = run_stream(&conn, &["clean", &scope_spec(&scope)], "sync", 0)?;
Ok(if msg.is_empty() { "Workspace already matches the depot.".into() } else { msg })
}
/// Total byte weight of one or more changelists (`p4 sizes -s //scope/...@=CH`):
/// for each change, the sum of the file revisions submitted in it. Returns
/// `{change, size}` per input change. Used to show commit weight in History.
#[tauri::command]
async fn p4_change_sizes(state: State<'_, AppState>, changes: Vec<String>, scope: String) -> Result<Vec<Value>, String> {
let conn = current(&state)?;
let root = if scope.trim().is_empty() { "//".to_string() } else { format!("{}/", scope.trim_end_matches(['/', '\\'])) };
let mut out: Vec<Value> = Vec::new();
for ch in changes.iter().map(|c| c.trim()).filter(|c| !c.is_empty()) {
let spec = format!("{}...@={}", root, ch);
let size = run_json(&conn, &["sizes", "-s", &spec])
.ok()
.and_then(|v| v.into_iter().next())
.and_then(|o| o.get("fileSize").and_then(|s| s.as_str()).and_then(|s| s.parse::<u64>().ok()))
.unwrap_or(0);
out.push(serde_json::json!({ "change": ch, "size": size }));
}
Ok(out)
}
/// Preview offline work (`p4 reconcile -n -e -a -d`): scan the workspace on disk
/// and report what changed while disconnected — files edited (writable but not
/// opened), added (present locally, unknown to the depot) or deleted (in the
@ -2411,6 +2872,7 @@ async fn save_ai_key(key: String) -> Result<(), String> {
pub fn run() {
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_dialog::init());
#[cfg(desktop)]
{
@ -2419,9 +2881,56 @@ pub fn run() {
.plugin(tauri_plugin_process::init());
}
builder
// clicking the window's X hides to the tray instead of quitting, so
// notifications keep arriving. Real quit is the tray menu's "Quit".
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
use tauri::Emitter;
if window.label() == "main" {
let _ = window.hide();
let _ = window.emit("tray-hidden", ());
api.prevent_close();
}
}
})
.setup(|app| {
use tauri::menu::{Menu, MenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::Manager;
// stash the app handle so p4 helpers can emit the live command log
let _ = APP.set(app.handle().clone());
// ---- system tray: keeps the app alive & notifying when the window is closed ----
let show = MenuItem::with_id(app, "show", "Open Exbyte Depot", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Quit Exbyte Depot", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &quit])?;
let _tray = TrayIconBuilder::with_id("main-tray")
.icon(app.default_window_icon().unwrap().clone())
.tooltip("Exbyte Depot — Perforce")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => {
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
}
}
"quit" => app.exit(0),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event {
let app = tray.app_handle();
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
}
}
})
.build(app)?;
Ok(())
})
.manage(AppState::default())
@ -2447,6 +2956,9 @@ pub fn run() {
p4_delete,
p4_reconcile,
p4_scan,
p4_scan_stream,
p4_scan_cancel,
p4_transfer_cancel,
p4_undo_change,
open_in_explorer,
find_uproject,
@ -2514,6 +3026,12 @@ pub fn run() {
p4_dirs,
depot_ls,
fs_ls,
p4_change_sizes,
p4_behind,
make_folder,
delete_folder,
rename_folder,
folder_info,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2",
"productName": "Exbyte Depot",
"mainBinaryName": "Exbyte Depot",
"version": "0.3.0",
"version": "0.3.2",
"identifier": "com.bonchellon.exbyte-depot",
"build": {
"beforeDevCommand": "npm run dev",
@ -24,8 +24,8 @@
}
],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; 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'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; 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'; 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'; 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'"
}
},
"bundle": {

View File

@ -235,6 +235,12 @@ body.resizing{cursor:col-resize!important;user-select:none}
.scanbar{display:flex;align-items:center;gap:9px;margin:8px 10px 2px;padding:9px 12px;border-radius:11px;font-size:11.5px;line-height:1.4;
color:var(--accent-2);background:rgba(124,110,246,.08);border:1px solid rgba(124,110,246,.22)}
.scanbar .ldr{flex:0 0 auto}
.scanbar > span{flex:1 1 auto;min-width:0}
.scancount{font-weight:600;color:var(--txt);font-variant-numeric:tabular-nums}
.scan-cancel{flex:0 0 auto;font-size:11px;font-weight:500;color:var(--muted);cursor:pointer;
background:var(--panel);border:1px solid var(--border);border-radius:7px;padding:3px 11px;transition:.15s}
.scan-cancel:hover{color:#fff;background:var(--danger,#e5484d);border-color:var(--danger,#e5484d)}
.empty .scancount{color:var(--accent-2)}
/* submit progress modal */
.push-modal{width:380px;background:var(--elevated);border:1px solid var(--border);border-radius:16px;padding:24px 26px;box-shadow:var(--shadow);text-align:center}
@ -442,9 +448,20 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.xfer-ttl b{font-size:14.5px}
.xfer-ttl span{font-size:12px;color:var(--faint);font-variant-numeric:tabular-nums}
.xfer-file{margin-top:14px;font-family:var(--mono);font-size:11.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.xfer-bar{margin-top:12px;height:6px;border-radius:4px;background:var(--panel-3);overflow:hidden;position:relative}
.xfer-cancel{margin-left:auto;flex:0 0 auto;align-self:flex-start;font-size:11.5px;font-weight:600;cursor:pointer;
background:var(--panel-2);border:1px solid var(--border);color:var(--muted);border-radius:8px;padding:5px 11px;transition:.15s}
.xfer-cancel:hover:not(:disabled){color:#fff;background:var(--danger,#e5484d);border-color:var(--danger,#e5484d)}
.xfer-cancel:disabled{opacity:.55;cursor:default}
.xfer-bar{margin-top:14px;height:6px;border-radius:4px;background:var(--panel-3);overflow:hidden;position:relative}
.xfer-bar span{position:absolute;top:0;left:-35%;width:35%;height:100%;border-radius:4px;
background:linear-gradient(90deg,transparent,var(--accent),transparent);animation:shim 1.1s ease-in-out infinite}
/* determinate (known total): solid fill sized by inline width, no shimmer */
.xfer-bar.det span{position:absolute;left:0;top:0;width:0;animation:none;
background:linear-gradient(90deg,var(--accent-2),var(--accent-deep));transition:width .25s ease;box-shadow:0 0 10px rgba(124,110,246,.55)}
.xfer-log{margin-top:12px;height:132px;overflow-y:auto;overflow-x:hidden;border:1px solid var(--border);border-radius:9px;
background:var(--panel);padding:7px 10px;font-family:var(--mono);font-size:10.5px;line-height:1.55;color:var(--muted)}
.xfer-log div{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.xfer-log div:last-child{color:var(--txt)}
/* MSBuild output overlay */
.build{width:660px;max-width:calc(100vw - 40px);height:min(70vh,560px);display:flex;flex-direction:column;
@ -489,6 +506,7 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.summary::placeholder{color:var(--faint)}
.desc{background:var(--panel);border:1px solid var(--border);border-radius:9px;padding:9px 11px;font-size:12.5px;color:var(--txt);min-height:46px;resize:none;outline:none;font-family:var(--font)}
.desc::placeholder{color:var(--faint)}
.summary:focus,.desc:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,110,246,.14)}
.submit{border:none;border-radius:10px;padding:11px;font-size:13.5px;font-weight:600;color:#fff;cursor:pointer;
background:linear-gradient(135deg,var(--accent-2),var(--accent-deep));
box-shadow:0 8px 24px -6px rgba(124,110,246,.6),0 0 0 1px rgba(255,255,255,.08) inset;
@ -527,6 +545,15 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.stage .lbl2{position:absolute;top:14px;left:14px;font-size:11px;color:var(--muted);background:var(--chip);
border:1px solid var(--border);border-radius:20px;padding:5px 11px;display:flex;align-items:center;gap:7px}
.stage .hint{position:absolute;bottom:14px;right:14px;font-size:11px;color:var(--faint)}
/* audio / video preview */
.mediaplayer{width:min(460px,86%)}
.mediaplayer.vid{width:auto;max-width:92%;max-height:88%;border-radius:12px;box-shadow:0 24px 60px -24px #000;background:#000}
.audiobox{display:flex;flex-direction:column;align-items:center;gap:16px;width:min(460px,86%)}
.audio-art{width:104px;height:104px;border-radius:22px;display:grid;place-items:center;color:#fff;
background:linear-gradient(140deg,var(--accent),var(--accent-deep));box-shadow:0 20px 46px -18px var(--accent)}
.audio-art svg{width:48px;height:48px}
.audio-name{font-size:14px;font-weight:600;color:var(--txt);text-align:center;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 10px}
.audiobox audio{width:100%}
.assetmeta{border-top:1px solid var(--border);padding:15px 20px;display:grid;grid-template-columns:repeat(4,1fr);gap:13px 22px;background:var(--sunk)}
.mi{display:flex;flex-direction:column;gap:3px}
.mi .k{font-size:10.5px;color:var(--faint);text-transform:uppercase;letter-spacing:.6px}
@ -599,10 +626,22 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.ce-btn.save{color:#fff;border:none;background:linear-gradient(135deg,var(--accent-2),var(--accent-deep))}
.ce-btn.save:hover:not(:disabled){filter:brightness(1.1)}
.ce-btn svg{width:13px;height:13px}
/* spec editors (client / job / .p4ignore): a clean editor surface, not a flat grey box */
.code-edit{flex:1;min-height:0;width:100%;resize:none;border:none;outline:none;
font-family:var(--mono);font-size:12.5px;line-height:1.7;tab-size:4;color:var(--txt);
background:var(--stage-bg);padding:12px 16px;white-space:pre;overflow:auto}
background:var(--elevated);padding:12px 16px 14px 18px;white-space:pre;overflow:auto;
box-shadow:inset 3px 0 0 var(--border-soft)}
.code-edit:focus{box-shadow:inset 3px 0 0 var(--accent)}
/* rich code editor: transparent textarea over a highlighted underlay + gutter */
.code.editing{overflow:hidden}
.code.editing .code-gutter{overflow:hidden}
.ce-wrap{position:relative;flex:1;min-width:0;background:transparent}
.ce-under,.ce-ta{margin:0;padding:10px 16px;border:0;font-family:var(--mono);font-size:12.5px;line-height:1.7;tab-size:4;white-space:pre;word-wrap:normal;letter-spacing:normal}
.ce-under{position:absolute;inset:0;overflow:hidden;pointer-events:none;background:none;color:var(--txt)}
.ce-under code{font-family:inherit;background:none;padding:0;white-space:pre}
.ce-ta{position:absolute;inset:0;width:100%;height:100%;resize:none;outline:none;overflow:auto;background:transparent;color:transparent;caret-color:var(--accent-2)}
.ce-ta::selection{background:rgba(124,110,246,.28);color:transparent}
.ce-ta::-moz-selection{background:rgba(124,110,246,.28);color:transparent}
/* highlight.js tokens mapped to the app palette (theme-aware) */
.hljs{color:var(--txt);background:none}
@ -642,10 +681,42 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.histsplit>.histh{position:relative;flex:none;width:8px;align-self:stretch;cursor:col-resize;background:transparent;top:auto;bottom:auto}
.histsplit .histlist .crow-go{opacity:0}
.histsplit .histlist .crow.sel .crow-go{opacity:1;transform:translateX(0)}
/* ---- right-side view panel with tabs (File Viewer / File Tree) ---- */
.viewpanel{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}
.vtabs{display:flex;align-items:stretch;gap:2px;padding:6px 8px 0;border-bottom:1px solid var(--border);background:var(--sunk);flex:0 0 auto;overflow-x:auto;overflow-y:hidden}
.vtab{display:flex;align-items:center;gap:7px;padding:7px 12px;border-radius:9px 9px 0 0;font-size:12.5px;font-weight:600;color:var(--muted);cursor:pointer;white-space:nowrap;border:1px solid transparent;border-bottom:none;position:relative;top:1px}
.vtab:hover{color:var(--txt);background:var(--hover)}
.vtab.on{color:var(--txt);background:var(--bg);border-color:var(--border)}
.vtab-ic{display:grid;place-items:center;color:var(--accent-2)}
.vtab-ic svg{width:15px;height:15px}
.vtab-x{margin-left:4px;width:17px;height:17px;display:grid;place-items:center;border-radius:5px;font-size:15px;line-height:1;color:var(--faint)}
.vtab-x:hover{background:var(--del);color:#fff}
.viewbody{flex:1;display:flex;min-height:0;min-width:0;position:relative}
.vpane{flex:1;min-width:0;min-height:0}
.vpane>.right,.vpane>.histsplit{flex:1 1 auto;min-width:0;min-height:0}
.vpane.tree{flex-direction:column}
.ft-bar{display:flex;align-items:center;gap:10px;padding:9px 14px;border-bottom:1px solid var(--border-soft);flex:0 0 auto}
.ft-total{margin-left:auto;font-size:12px;color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums}
.ft-total b{color:var(--accent-2)}
.ft-new{display:flex;align-items:center;gap:5px;font-family:var(--font);font-size:11.5px;font-weight:600;color:var(--accent-2);
background:rgba(124,110,246,.1);border:1px solid rgba(124,110,246,.24);border-radius:8px;padding:4px 10px;cursor:pointer;flex:0 0 auto;transition:.14s}
.ft-new:hover{background:rgba(124,110,246,.18);border-color:rgba(124,110,246,.4)}
.ft-new svg{width:13px;height:13px}
/* folder properties dialog */
.fp-body{padding:18px 20px}
.fp-name{display:flex;align-items:center;gap:10px;font-size:15px;margin-bottom:16px}
.fp-name svg{width:20px;height:20px;color:var(--accent-2);flex:0 0 auto}
.fp-name b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fp-grid{display:grid;grid-template-columns:auto 1fr;gap:10px 16px;font-size:13px}
.fp-grid .k{color:var(--faint)}
.fp-grid .v{color:var(--txt);min-width:0;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}
.fp-grid .v.mono{font-family:var(--mono);font-size:12px;word-break:break-all;white-space:normal}
.vpane.tree .exp-tree{flex:1;min-height:0}
.hnum{font-family:var(--mono);font-size:12px;color:var(--accent-2);font-variant-numeric:tabular-nums;flex:0 0 auto;padding-top:1px}
.hbody{display:flex;flex-direction:column;gap:3px;min-width:0}
.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}
/* 3d viewer toolbar */
.v3d-tools{position:absolute;top:12px;right:12px;display:flex;gap:6px;z-index:2}
@ -681,6 +752,13 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.picker-item .pi-body{display:flex;flex-direction:column;gap:2px;min-width:0}
.picker-item .pi-name{font-size:13.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.picker-item .pi-sub{font-size:11.5px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* recently-used working folders */
.pick-recent{border-bottom:1px solid var(--border-soft);margin-bottom:6px;padding-bottom:6px}
.pick-recent-h{font-size:10.5px;letter-spacing:.09em;text-transform:uppercase;color:var(--faint);padding:4px 12px 6px;font-weight:600}
.picker-item .pi-ic.clock,.picker-item .pi-ic.up{color:var(--muted);background:var(--panel-3)}
/* "local only, not in depot yet" tag */
.local-tag{margin-left:8px;font-size:9.5px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--warn);
background:rgba(232,176,75,.14);border:1px solid rgba(232,176,75,.32);border-radius:6px;padding:1px 6px;vertical-align:middle}
.picker-foot{padding:12px 20px;border-top:1px solid var(--border-soft);font-size:12px;color:var(--faint)}
.info-body{padding:20px;font-size:13px;line-height:1.6;color:var(--muted)}
.info-body b{color:var(--txt)}
@ -770,6 +848,10 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.netbar.warn:hover{background:rgba(232,176,75,.2)}
/* explicit "Work Offline" mode — violet, calmer than the error-red disconnect bar */
.netbar.offmode{background:rgba(124,110,246,.13);color:var(--accent-2);border-bottom:1px solid rgba(124,110,246,.3)}
/* "you're behind the server" — informational blue, clickable to Get Latest */
.netbar.behind{background:rgba(90,160,255,.14);color:#4f8fe0;border-bottom:1px solid rgba(90,160,255,.32);cursor:pointer}
.netbar.behind:hover{background:rgba(90,160,255,.2)}
.behind-names{font-weight:500;opacity:.8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
.netbar-btn{margin-left:auto;border:1px solid currentColor;background:none;color:inherit;font-size:11.5px;font-weight:700;
border-radius:7px;padding:4px 12px;cursor:pointer;font-family:var(--font)}
.netbar-btn+.netbar-btn{margin-left:0}

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import hljs from "highlight.js/lib/core";
import cpp from "highlight.js/lib/languages/cpp";
import csharp from "highlight.js/lib/languages/csharp";
@ -104,32 +104,53 @@ export default function CodeView({ file, onFlash }: { file: OpenedFile; onFlash?
try { return lang ? hljs.highlight(code, { language: lang, ignoreIllegals: true }).value : esc(code); }
catch { return esc(code); }
}, [code, lang]);
// live-highlighted draft for the editor underlay (same highlighter as the preview)
const draftHi = useMemo(() => {
try { return lang ? hljs.highlight(draft, { language: lang, ignoreIllegals: true }).value : esc(draft); }
catch { return esc(draft); }
}, [draft, lang]);
const preRef = useRef<HTMLPreElement>(null);
const gutRef = useRef<HTMLDivElement>(null);
const draftRows = draft.split("\n").length;
// editing mode — plain textarea over the working copy
// editing mode — a real code editor: transparent textarea over a syntax-
// highlighted underlay, with a line-number gutter (looks like the preview).
if (editing) {
const syncScroll = (e: { currentTarget: HTMLTextAreaElement }) => {
const ta = e.currentTarget;
if (preRef.current) { preRef.current.scrollTop = ta.scrollTop; preRef.current.scrollLeft = ta.scrollLeft; }
if (gutRef.current) gutRef.current.scrollTop = ta.scrollTop;
};
return (
<div className="right-body">
<div className="codebar">
<span className="cb-badge editing">{t("Editing")}</span>
<span className="cb-name">{name}</span>
{lang ? <span className="cb-lines">{LANG_LABEL[lang]}</span> : null}
<span className="ce-actions">
<button className="ce-btn" onClick={cancelEdit} disabled={saving}>{t("Cancel")}</button>
<button className="ce-btn save" onClick={save} disabled={saving || draft === code}>{saving ? <span className="ldr sm" /> : null}{t("Save")}</button>
</span>
</div>
<textarea className="code-edit" value={draft} spellCheck={false} onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") cancelEdit();
if (e.key === "s" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); if (draft !== code) save(); }
// Tab inserts a tab instead of leaving the field
if (e.key === "Tab") {
e.preventDefault();
const ta = e.currentTarget; const s = ta.selectionStart, en = ta.selectionEnd;
const nv = draft.slice(0, s) + "\t" + draft.slice(en);
setDraft(nv);
requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = s + 1; });
}
}} />
<div className="code editing">
<div className="code-gutter" ref={gutRef}>{Array.from({ length: draftRows }, (_, i) => <span key={i}>{i + 1}</span>)}</div>
<div className="ce-wrap">
<pre className="ce-under" aria-hidden="true" ref={preRef}><code className="hljs" dangerouslySetInnerHTML={{ __html: draftHi + (draft.endsWith("\n") ? " " : "") }} /></pre>
<textarea className="ce-ta" value={draft} spellCheck={false} autoFocus onScroll={syncScroll} onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") cancelEdit();
if (e.key === "s" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); if (draft !== code) save(); }
// Tab inserts a tab instead of leaving the field
if (e.key === "Tab") {
e.preventDefault();
const ta = e.currentTarget; const s = ta.selectionStart, en = ta.selectionEnd;
const nv = draft.slice(0, s) + "\t" + draft.slice(en);
setDraft(nv);
requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = s + 1; });
}
}} />
</div>
</div>
</div>
);
}

View File

@ -148,6 +148,11 @@ const D: Record<string, Tr> = {
"Save": { ru: "Сохранить", de: "Speichern", fr: "Enregistrer", es: "Guardar" },
"Scanning disk for changes…": { ru: "Ищу изменения на диске…", de: "Suche Änderungen auf der Festplatte…", fr: "Recherche de modifications sur le disque…", es: "Buscando cambios en el disco…" },
" for the whole workspace this is slow — pick a project folder above": { ru: " для всего workspace это долго — выбери рабочую папку проекта сверху", de: " für den ganzen Workspace ist das langsam — wähle oben einen Projektordner", fr: " pour tout le workspace cest lent — choisissez un dossier de projet en haut", es: " para todo el workspace es lento — elige una carpeta de proyecto arriba" },
"found {n} files": { ru: "найдено {n} файлов", de: "{n} Dateien gefunden", fr: "{n} fichiers trouvés", es: "{n} archivos encontrados" },
"Committing…": { ru: "Коммичу…", de: "Übertrage…", fr: "Validation…", es: "Confirmando…" },
"Cancelling…": { ru: "Отменяю…", de: "Breche ab…", fr: "Annulation…", es: "Cancelando…" },
"Stop before the server commit. Files already sent are not applied; the changelist stays pending.": { ru: "Остановить до серверного коммита. Уже отправленные файлы не применяются; changelist останется в pending.", de: "Vor dem Server-Commit stoppen. Bereits gesendete Dateien werden nicht übernommen; die Changelist bleibt ausstehend.", fr: "Arrêter avant le commit serveur. Les fichiers déjà envoyés ne sont pas appliqués ; la changelist reste en attente.", es: "Detener antes del commit del servidor. Los archivos ya enviados no se aplican; la changelist queda pendiente." },
"Stop the scan. Files already found stay staged.": { ru: "Остановить скан. Уже найденные файлы останутся помеченными.", de: "Scan stoppen. Bereits gefundene Dateien bleiben vorgemerkt.", fr: "Arrêter lanalyse. Les fichiers déjà trouvés restent préparés.", es: "Detener el escaneo. Los archivos ya encontrados quedan preparados." },
"{a} of {b} selected": { ru: "{a} из {b} выбрано", de: "{a} von {b} ausgewählt", fr: "{a} sur {b} sélectionnés", es: "{a} de {b} seleccionados" },
"Select all": { ru: "Выделить все", de: "Alle auswählen", fr: "Tout sélectionner", es: "Seleccionar todo" },
"Deselect all": { ru: "Снять выделение", de: "Auswahl aufheben", fr: "Tout désélectionner", es: "Deseleccionar todo" },
@ -539,6 +544,7 @@ const D: Record<string, Tr> = {
"Perforce's task/bug tracker — create jobs and attach them to changelists.": { ru: "Трекер задач/багов Perforce — создавай джобы и привязывай к changelists.", de: "Perforce-Aufgaben-/Bug-Tracker — Jobs erstellen und an Changelists anhängen.", fr: "Le suivi de tâches/bugs de Perforce — crée des jobs et attache-les aux changelists.", es: "El rastreador de tareas/errores de Perforce — crea jobs y adjúntalos a changelists." },
"Edit which files reconcile / add ignore (build output, caches).": { ru: "Изменить, какие файлы игнорируют reconcile / add (сборка, кэши).", de: "Bearbeiten, welche Dateien reconcile / add ignorieren (Build-Ausgabe, Caches).", fr: "Modifier quels fichiers reconcile / add ignorent (sortie de build, caches).", es: "Editar qué archivos ignoran reconcile / add (salida de compilación, cachés)." },
"Compile the Visual Studio solution found in the working folder.": { ru: "Скомпилировать решение Visual Studio, найденное в рабочей папке.", de: "Die im Arbeitsordner gefundene Visual-Studio-Solution kompilieren.", fr: "Compiler la solution Visual Studio trouvée dans le dossier de travail.", es: "Compilar la solución de Visual Studio encontrada en la carpeta de trabajo." },
"Compile the game C++ — Unreal projects build via UnrealBuildTool (game module only), plain C++ via MSBuild.": { ru: "Скомпилировать C++ игры — Unreal-проекты собираются через UnrealBuildTool (только модуль игры), обычный C++ — через MSBuild.", de: "Game-C++ kompilieren — Unreal-Projekte über UnrealBuildTool (nur Spielmodul), reines C++ über MSBuild.", fr: "Compiler le C++ du jeu — les projets Unreal via UnrealBuildTool (module de jeu seulement), le C++ simple via MSBuild.", es: "Compilar el C++ del juego — los proyectos Unreal vía UnrealBuildTool (solo el módulo del juego), C++ simple vía MSBuild." },
"See who works on this depot and their roles.": { ru: "Посмотреть, кто работает с этим депо и их роли.", de: "Sehen, wer an diesem Depot arbeitet und welche Rollen sie haben.", fr: "Voir qui travaille sur ce depot et leurs rôles.", es: "Ver quién trabaja en este depot y sus roles." },
"App preferences — language, theme, editor, and more.": { ru: "Настройки приложения — язык, тема, редактор и прочее.", de: "App-Einstellungen — Sprache, Design, Editor und mehr.", fr: "Préférences de l'app — langue, thème, éditeur et plus.", es: "Preferencias de la app — idioma, tema, editor y más." },
"Version and information about Exbyte Depot.": { ru: "Версия и информация о Exbyte Depot.", de: "Version und Informationen zu Exbyte Depot.", fr: "Version et informations sur Exbyte Depot.", es: "Versión e información sobre Exbyte Depot." },
@ -554,6 +560,55 @@ const D: Record<string, Tr> = {
"Empty.": { ru: "Пусто.", de: "Leer.", fr: "Vide.", es: "Vacío." },
"Sizes come from the server (p4 sizes): the total bytes each depot folder stores across all its files. Sorted heaviest first.": { ru: "Размеры берутся с сервера (p4 sizes): суммарный объём, который каждая папка депо хранит по всем файлам. Сортировка по убыванию веса.", de: "Größen kommen vom Server (p4 sizes): die Gesamtbytes, die jeder Depot-Ordner über alle Dateien speichert. Nach Größe absteigend sortiert.", fr: "Les tailles viennent du serveur (p4 sizes) : le total d'octets que chaque dossier du depot stocke sur tous ses fichiers. Trié du plus lourd au plus léger.", es: "Los tamaños vienen del servidor (p4 sizes): el total de bytes que cada carpeta del depot almacena en todos sus archivos. Ordenado de mayor a menor." },
"Sizes are measured on your disk: how much each folder and file of your local workspace actually takes up. Sorted heaviest first.": { ru: "Размеры измеряются на твоём диске: сколько реально занимает каждая папка и файл локального workspace. Сортировка по убыванию веса.", de: "Größen werden auf deiner Festplatte gemessen: wie viel jeder Ordner und jede Datei deines lokalen Arbeitsbereichs tatsächlich belegt. Nach Größe absteigend sortiert.", fr: "Les tailles sont mesurées sur ton disque : l'espace réellement occupé par chaque dossier et fichier de ton espace de travail local. Trié du plus lourd au plus léger.", es: "Los tamaños se miden en tu disco: cuánto ocupa realmente cada carpeta y archivo de tu espacio de trabajo local. Ordenado de mayor a menor." },
"Browse the Depot and your Workspace as a tree and see how much every file and folder weighs. Sorted heaviest first.": { ru: "Просматривай Depot и Workspace деревом и смотри вес каждого файла и папки. Сортировка по убыванию.", de: "Durchsuche das Depot und deinen Arbeitsbereich als Baum und sieh das Gewicht jeder Datei und jedes Ordners. Nach Größe absteigend.", fr: "Parcours le Depot et ton espace de travail en arbre et vois le poids de chaque fichier et dossier. Trié du plus lourd au plus léger.", es: "Explora el Depot y tu espacio de trabajo como árbol y ve el peso de cada archivo y carpeta. De mayor a menor." },
// ---- view panel (File Viewer / File Tree tabs) ----
"File Viewer": { ru: "Просмотр файла", de: "Dateivorschau", fr: "Visionneuse", es: "Visor de archivos" },
"File Tree": { ru: "Дерево файлов", de: "Dateibaum", fr: "Arborescence", es: "Árbol de archivos" },
"No panel open.\nAdd File Viewer or File Tree from the Window menu.": { ru: "Нет открытых панелей.\nДобавь «Просмотр файла» или «Дерево файлов» из меню Window.", de: "Kein Panel geöffnet.\nFüge „Dateivorschau“ oder „Dateibaum“ über das Menü Window hinzu.", fr: "Aucun panneau ouvert.\nAjoute « Visionneuse » ou « Arborescence » depuis le menu Window.", es: "Ningún panel abierto.\nAñade «Visor de archivos» o «Árbol de archivos» desde el menú Window." },
"The preview panel for the selected file (image, 3D model, code, diff).": { ru: "Панель предпросмотра выбранного файла (картинка, 3D-модель, код, diff).", de: "Das Vorschau-Panel für die ausgewählte Datei (Bild, 3D-Modell, Code, Diff).", fr: "Le panneau de prévisualisation du fichier sélectionné (image, modèle 3D, code, diff).", es: "El panel de vista previa del archivo seleccionado (imagen, modelo 3D, código, diff)." },
"A folder hierarchy of the Depot and your Workspace, docked into the view panel.": { ru: "Иерархия папок Depot и Workspace, встроенная в панель просмотра.", de: "Eine Ordnerhierarchie des Depots und deines Arbeitsbereichs, angedockt im Ansichts-Panel.", fr: "Une hiérarchie de dossiers du Depot et de ton espace de travail, ancrée dans le panneau.", es: "Una jerarquía de carpetas del Depot y tu espacio de trabajo, anclada en el panel." },
"Show in File Tree": { ru: "Показать в дереве файлов", de: "Im Dateibaum zeigen", fr: "Afficher dans l'arborescence", es: "Mostrar en el árbol de archivos" },
"Total size of files in this changelist": { ru: "Суммарный вес файлов в этом ченджлисте", de: "Gesamtgröße der Dateien in dieser Changelist", fr: "Taille totale des fichiers de cette changelist", es: "Tamaño total de los archivos de esta changelist" },
// ---- server activity / behind + tray notifications ----
"Server has newer content — {n} file(s) behind.": { ru: "На сервере есть свежее — ты отстал на {n} файл(ов).", de: "Der Server hat neueren Inhalt — {n} Datei(en) im Rückstand.", fr: "Le serveur a du contenu plus récent — {n} fichier(s) en retard.", es: "El servidor tiene contenido más nuevo — {n} archivo(s) por detrás." },
"Your workspace is behind": { ru: "Твой воркспейс отстал", de: "Dein Arbeitsbereich ist veraltet", fr: "Ton espace de travail est en retard", es: "Tu espacio de trabajo está desactualizado" },
"{n} file(s) on the server are newer than your copy. Get Latest to update.": { ru: "{n} файл(ов) на сервере новее твоих. Нажми Get Latest, чтобы обновить.", de: "{n} Datei(en) auf dem Server sind neuer als deine. Get Latest zum Aktualisieren.", fr: "{n} fichier(s) sur le serveur sont plus récents que les tiens. Fais Get Latest pour mettre à jour.", es: "{n} archivo(s) del servidor son más nuevos que los tuyos. Pulsa Get Latest para actualizar." },
"Exbyte Depot is still running": { ru: "Exbyte Depot всё ещё работает", de: "Exbyte Depot läuft weiter", fr: "Exbyte Depot fonctionne toujours", es: "Exbyte Depot sigue en ejecución" },
"It's in the system tray and will notify you about teammates' submits. Right-click the tray icon to quit.": { ru: "Он в системном трее и будет присылать уведомления о сабмитах коллег. ПКМ по иконке в трее — выход.", de: "Es liegt im System-Tray und benachrichtigt dich über Submits von Kollegen. Rechtsklick auf das Tray-Symbol zum Beenden.", fr: "Il est dans la zone de notification et t'avertira des soumissions de tes collègues. Clic droit sur l'icône pour quitter.", es: "Está en la bandeja del sistema y te avisará de los envíos de tus compañeros. Clic derecho en el icono para salir." },
// ---- audio/video preview + docked locks ----
"Audio": { ru: "Аудио", de: "Audio", fr: "Audio", es: "Audio" },
"Video": { ru: "Видео", de: "Video", fr: "Vidéo", es: "Vídeo" },
"Duration": { ru: "Длительность", de: "Dauer", fr: "Durée", es: "Duración" },
"server revision": { ru: "ревизия с сервера", de: "Server-Revision", fr: "révision serveur", es: "revisión del servidor" },
"Everyone's checked-out / exclusively-locked files, docked into the view panel.": { ru: "Все чекнутые / эксклюзивно залоченные файлы всех участников, встроенные в панель просмотра.", de: "Alle ausgecheckten / exklusiv gesperrten Dateien aller Nutzer, angedockt im Ansichts-Panel.", fr: "Les fichiers extraits / verrouillés en exclusivité de tout le monde, ancrés dans le panneau.", es: "Los archivos extraídos / bloqueados en exclusiva de todos, anclados en el panel." },
// ---- new folder / project ----
"New": { ru: "Создать", de: "Neu", fr: "Nouveau", es: "Nuevo" },
"workspace root": { ru: "корень воркспейса", de: "Arbeitsbereich-Root", fr: "racine de l'espace de travail", es: "raíz del espacio de trabajo" },
"New folder / project…": { ru: "Новая папка / проект…", de: "Neuer Ordner / Projekt…", fr: "Nouveau dossier / projet…", es: "Nueva carpeta / proyecto…" },
"Create a new folder in the working area (with a README placeholder) — e.g. a separate project.": { ru: "Создать новую папку в рабочей области (с плейсхолдером README) — например, отдельный проект.", de: "Einen neuen Ordner im Arbeitsbereich erstellen (mit README-Platzhalter) — z. B. ein separates Projekt.", fr: "Créer un nouveau dossier dans l'espace de travail (avec un README) — p. ex. un projet distinct.", es: "Crear una nueva carpeta en el área de trabajo (con un README) — p. ej. un proyecto aparte." },
"New folder in {where}": { ru: "Новая папка в {where}", de: "Neuer Ordner in {where}", fr: "Nouveau dossier dans {where}", es: "Nueva carpeta en {where}" },
"Folder name — nest with / (e.g. NewGame or NewGame/Source)": { ru: "Имя папки — вложенность через / (напр. NewGame или NewGame/Source)", de: "Ordnername — verschachteln mit / (z. B. NewGame oder NewGame/Source)", fr: "Nom du dossier — imbriquer avec / (p. ex. NewGame ou NewGame/Source)", es: "Nombre de la carpeta — anida con / (p. ej. NewGame o NewGame/Source)" },
"Created “{name}” with a README placeholder — commit it to keep the folder on the server.": { ru: "Создано «{name}» с плейсхолдером README — закоммить, чтобы папка сохранилась на сервере.", de: "„{name}“ mit README-Platzhalter erstellt — committe es, damit der Ordner auf dem Server bleibt.", fr: "« {name} » créé avec un README — committe-le pour conserver le dossier sur le serveur.", es: "«{name}» creado con un README — haz commit para conservar la carpeta en el servidor." },
// ---- rename / delete / properties folder ----
"Rename…": { ru: "Переименовать…", de: "Umbenennen…", fr: "Renommer…", es: "Renombrar…" },
"Rename folder": { ru: "Переименовать папку", de: "Ordner umbenennen", fr: "Renommer le dossier", es: "Renombrar carpeta" },
"New name for “{name}”": { ru: "Новое имя для «{name}»", de: "Neuer Name für „{name}“", fr: "Nouveau nom pour « {name} »", es: "Nuevo nombre para «{name}»" },
"Renamed to “{name}” — {n} file(s) moved. Commit to apply.": { ru: "Переименовано в «{name}» — перемещено {n} файл(ов). Закоммить, чтобы применить.", de: "In „{name}“ umbenannt — {n} Datei(en) verschoben. Zum Anwenden committen.", fr: "Renommé en « {name} » — {n} fichier(s) déplacés. Committe pour appliquer.", es: "Renombrado a «{name}» — {n} archivo(s) movidos. Haz commit para aplicar." },
"Delete folder…": { ru: "Удалить папку…", de: "Ordner löschen…", fr: "Supprimer le dossier…", es: "Eliminar carpeta…" },
"Delete folder “{name}”?": { ru: "Удалить папку «{name}»?", de: "Ordner „{name}“ löschen?", fr: "Supprimer le dossier « {name} » ?", es: "¿Eliminar la carpeta «{name}»?" },
"Every file under this folder is marked for delete. Nothing leaves the server until you Submit; freshly-added files are removed right away.": { ru: "Все файлы под этой папкой помечаются на удаление. С сервера ничего не уходит до Submit; свежедобавленные файлы убираются сразу.", de: "Jede Datei unter diesem Ordner wird zum Löschen markiert. Nichts verlässt den Server bis zum Submit; frisch hinzugefügte Dateien werden sofort entfernt.", fr: "Chaque fichier de ce dossier est marqué pour suppression. Rien ne quitte le serveur avant le Submit ; les fichiers récemment ajoutés sont retirés immédiatement.", es: "Todos los archivos de esta carpeta se marcan para eliminar. Nada sale del servidor hasta el Submit; los archivos recién añadidos se quitan al instante." },
"Marked {n} file(s) for delete — Submit to remove the folder.": { ru: "Помечено {n} файл(ов) на удаление — Submit уберёт папку.", de: "{n} Datei(en) zum Löschen markiert — Submit entfernt den Ordner.", fr: "{n} fichier(s) marqués pour suppression — Submit retire le dossier.", es: "{n} archivo(s) marcados para eliminar — Submit quita la carpeta." },
"Folder removed.": { ru: "Папка удалена.", de: "Ordner entfernt.", fr: "Dossier supprimé.", es: "Carpeta eliminada." },
"Delete": { ru: "Удалить", de: "Löschen", fr: "Supprimer", es: "Eliminar" },
"Properties": { ru: "Свойства", de: "Eigenschaften", fr: "Propriétés", es: "Propiedades" },
"Folder properties": { ru: "Свойства папки", de: "Ordner-Eigenschaften", fr: "Propriétés du dossier", es: "Propiedades de la carpeta" },
"Files": { ru: "Файлов", de: "Dateien", fr: "Fichiers", es: "Archivos" },
"Total size": { ru: "Общий размер", de: "Gesamtgröße", fr: "Taille totale", es: "Tamaño total" },
"Location": { ru: "Расположение", de: "Speicherort", fr: "Emplacement", es: "Ubicación" },
"depot": { ru: "депо", de: "Depot", fr: "depot", es: "depot" },
"local only": { ru: "только локально", de: "nur lokal", fr: "local uniquement", es: "solo local" },
"Recently": { ru: "Недавние", de: "Zuletzt", fr: "Récents", es: "Recientes" },
"local": { ru: "локально", de: "lokal", fr: "local", es: "local" },
};
export function t(en: string, vars?: Record<string, string | number>): string {

View File

@ -75,6 +75,7 @@ export const p4 = {
cleanPreview: (scope = "") => invoke<OpenedFile[]>("p4_clean_preview", { scope }),
cleanApply: (scope = "") => invoke<string>("p4_clean_apply", { scope }),
depotLs: (path = "") => invoke<FsEntry[]>("depot_ls", { path }),
changeSizes: (changes: string[], scope = "") => invoke<{ change: string; size: number }[]>("p4_change_sizes", { changes, scope }),
fsLs: (path = "") => invoke<FsEntry[]>("fs_ls", { path }),
reconcilePreview: (scope = "") => invoke<OpenedFile[]>("p4_reconcile_preview", { scope }),
reconcileApply: (paths: string[]) => invoke<OpenedFile[]>("p4_reconcile_apply", { paths }),
@ -101,12 +102,18 @@ export const p4 = {
del: (files: string[]) => invoke<OpenedFile[]>("p4_delete", { files }),
reconcile: (path: string) => invoke<OpenedFile[]>("p4_reconcile", { path }),
scan: (scope = "") => invoke<OpenedFile[]>("p4_scan", { scope }),
// streaming disk scan: emits `p4-scan` progress events, returns the file count.
// The caller re-reads opened() for the actual list.
scanStream: (scope = "") => invoke<number>("p4_scan_stream", { scope }),
scanCancel: () => invoke<void>("p4_scan_cancel"),
// ask an in-progress submit/sync transfer to stop (safe pre-commit for submit)
transferCancel: () => invoke<void>("p4_transfer_cancel"),
describe: (change: string) => invoke<Describe>("p4_describe", { change }),
undoChange: (change: string) => invoke<string>("p4_undo_change", { change }),
openInExplorer: (target: string) => invoke<void>("open_in_explorer", { target }),
findUproject: (scope: string) => invoke<string>("find_uproject", { scope }),
findSln: (scope: string) => invoke<string>("find_sln", { scope }),
buildSln: (path: string, config: string) => invoke<string>("build_sln", { path, config }),
buildSln: (path: string, config: string, uproject = "") => invoke<string>("build_sln", { path, config, uproject }),
launchFile: (path: string) => invoke<void>("launch_file", { path }),
listEditors: () => invoke<Editor[]>("list_editors"),
openInEditor: (depot: string, editor: string) => invoke<void>("open_in_editor", { depot, editor }),
@ -157,6 +164,11 @@ export const p4 = {
searchFiles: (query: string, scope = "") => invoke<{ depotFile?: string; rev?: string }[]>("p4_search_files", { query, scope }),
reopenTo: (change: string, files: string[]) => invoke<string>("p4_reopen_to", { change, files }),
latestChange: (scope = "") => invoke<Change | null>("p4_latest_change", { scope }),
behind: (scope = "") => invoke<{ count: number; sample: string[] }>("p4_behind", { scope }),
makeFolder: (scope: string, name: string) => invoke<string>("make_folder", { scope, name }),
deleteFolder: (path: string) => invoke<OpenedFile[]>("delete_folder", { path }),
renameFolder: (path: string, newName: string) => invoke<OpenedFile[]>("rename_folder", { path, newName }),
folderInfo: (path: string) => invoke<{ path: string; fileCount: number; size: number }>("folder_info", { path }),
// embedded Unreal .uasset thumbnail (PNG bytes), for working copy or history
uassetThumb: async (spec: string, historical: boolean): Promise<ArrayBuffer> => {
const r: unknown = await invoke("uasset_thumbnail", { spec, historical });
@ -183,7 +195,7 @@ export interface Editor { id: string; name: string; exe: string; args: string[]
export function getEditor(): string { try { return localStorage.getItem("exd-editor") || ""; } catch { return ""; } }
export function setEditorPref(id: string) { try { localStorage.setItem("exd-editor", id); } catch {} }
export interface Dir { dir?: string; [k: string]: unknown }
export interface Dir { dir?: string; local?: boolean; [k: string]: unknown }
// depot path -> short leaf name (last segment)
export function leaf(path?: string): string {
if (!path) return "";
@ -254,6 +266,17 @@ export function saveScope(client: string, path: string) {
export function loadScope(client: string): string {
try { return client ? (localStorage.getItem("exd-scope-" + client) || "") : ""; } catch { return ""; }
}
// last 3 chosen working folders (Recently) — most-recent first, per workspace
export function recentScopes(client: string): string[] {
try { const a = JSON.parse(localStorage.getItem("exd-recent-" + client) || "[]"); return Array.isArray(a) ? a.slice(0, 3) : []; } catch { return []; }
}
export function pushRecentScope(client: string, path: string) {
try {
const cur = recentScopes(client).filter((p) => p !== path);
const next = [path, ...cur].slice(0, 3);
localStorage.setItem("exd-recent-" + client, JSON.stringify(next));
} catch {}
}
// format a unix-epoch string as a short local datetime
export function fmtTime(t?: string): string {
@ -281,12 +304,14 @@ export function splitPath(p?: string): { name: string; dir: string } {
}
// classify by extension for the preview panel
export type Kind = "model" | "uasset" | "image" | "code";
export type Kind = "model" | "uasset" | "image" | "audio" | "video" | "code";
export function kindOf(name: string): Kind {
const n = name.toLowerCase();
if (/\.(fbx|obj|gltf|glb|stl|ply|3mf|dae)$/.test(n)) return "model";
if (/\.(uasset|umap)$/.test(n)) return "uasset";
if (/\.(png|jpe?g|webp|gif|bmp|svg)$/.test(n)) return "image";
if (/\.(wav|mp3|ogg|flac|m4a|aac|opus)$/.test(n)) return "audio";
if (/\.(mp4|webm|mov|m4v|ogv)$/.test(n)) return "video";
return "code";
}

View File

@ -0,0 +1,18 @@
# Copy to .env and fill in. NEVER commit .env.
# Admin login
ADMIN_USER=admin
# Generate with: npm run set-password
ADMIN_PASS_HASH=
# Session signing key (>=24 chars). Generate with: npm run gen-secret
SESSION_SECRET=
# Server
PORT=8787
NODE_ENV=development
# Set to 1 (or the hop count) when running behind nginx/Caddy so client IPs are correct
TRUST_PROXY=0
# Path to the landing page folder (default: ../landing)
# LANDING_DIR=../landing

4
waitlist-server/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
.env
data/
*.log

51
waitlist-server/README.md Normal file
View File

@ -0,0 +1,51 @@
# Exbyte Depot — Waitlist server + admin
Collects waitlist emails from the landing page (deduplicated) and gives you a
password-protected admin panel to browse them and export to CSV.
## Stack
- **express** — HTTP + static landing
- **better-sqlite3** — storage; `UNIQUE COLLATE NOCASE` on email → no duplicates, ever
- **express-rate-limit** — brute-force protection on the login endpoint
- **node:crypto** — scrypt password hashing + HMAC-signed session cookies (no extra deps)
## Setup
```bash
cd waitlist-server
npm install
cp .env.example .env
npm run gen-secret # → paste into SESSION_SECRET in .env
npm run set-password # type a password → paste the ADMIN_PASS_HASH line into .env
# set ADMIN_USER in .env (default: admin)
npm start
```
- Landing: http://localhost:8787/
- Admin: http://localhost:8787/admin
## How it works
- The landing form POSTs `{ email }` to **`POST /api/waitlist`**.
- Invalid emails are rejected; a repeat email is silently deduped (no dup row).
- A hidden honeypot field (`website`) traps bots.
- **`/admin`** requires login. Session is a signed, HttpOnly, SameSite=Strict cookie
(Secure in production), valid 8h. Wrong logins are rate-limited (8 / 15 min / IP).
- **Export CSV**: `GET /admin/export.csv` streams all signups (UTF-8 + BOM for Excel).
## Security notes
- No plaintext password anywhere — only a scrypt hash in `.env` (git-ignored).
- Refuses to start without `ADMIN_USER`, `ADMIN_PASS_HASH`, and a strong `SESSION_SECRET`.
- Security headers set (CSP, X-Frame-Options: DENY, nosniff, no-referrer).
- Behind a reverse proxy, set `TRUST_PROXY=1` so rate-limiting and stored IPs are correct,
and terminate TLS at the proxy (cookies become `Secure` when `NODE_ENV=production`).
## Deploy (Linux VPS, sketch)
1. `NODE_ENV=production`, real `.env`, `npm ci --omit=dev`.
2. Run under systemd (or pm2); put nginx/Caddy in front for TLS + `TRUST_PROXY=1`.
3. Point your domain at it; the landing is served from `../landing` (or set `LANDING_DIR`).
## Data
SQLite file at `data/waitlist.db` (git-ignored). Back it up to keep your list.

129
waitlist-server/lib/auth.js Normal file
View File

@ -0,0 +1,129 @@
// Authentication for the admin panel — no external crypto deps, all from node:crypto.
//
// • Passwords are stored as scrypt hashes ("scrypt$salt$hash", both base64url) in
// the ADMIN_PASS_HASH env var. Never a plaintext password anywhere.
// • Sessions are stateless, signed cookies: base64url(payload).base64url(hmac).
// Tamper-proof (HMAC-SHA256 with SESSION_SECRET) and self-expiring. No server
// session store to leak or lose on restart.
// • Every compare is constant-time (timingSafeEqual) to avoid timing oracles.
import crypto from "node:crypto";
const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 32 };
/** Hash a plaintext password → "scrypt$<salt>$<hash>" (for storing in .env). */
export function hashPassword(password) {
const salt = crypto.randomBytes(16);
const hash = crypto.scryptSync(password, salt, SCRYPT.keylen, SCRYPT);
return `scrypt$${salt.toString("base64url")}$${hash.toString("base64url")}`;
}
/** Verify a plaintext password against a stored "scrypt$salt$hash" string. */
export function verifyPassword(password, stored) {
if (typeof stored !== "string") return false;
const parts = stored.split("$");
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
let salt, expected;
try {
salt = Buffer.from(parts[1], "base64url");
expected = Buffer.from(parts[2], "base64url");
} catch {
return false;
}
if (expected.length !== SCRYPT.keylen) return false;
const got = crypto.scryptSync(password, salt, SCRYPT.keylen, SCRYPT);
return crypto.timingSafeEqual(got, expected);
}
/** Constant-time string compare (for the username). */
export function safeEqual(a, b) {
const ba = Buffer.from(String(a));
const bb = Buffer.from(String(b));
if (ba.length !== bb.length) return false;
return crypto.timingSafeEqual(ba, bb);
}
// ---- signed session cookies -------------------------------------------------
const COOKIE_NAME = "exd_admin";
function sign(data, secret) {
return crypto.createHmac("sha256", secret).update(data).digest("base64url");
}
/** Build a signed session token that expires after `ttlMs`. */
export function issueToken(username, secret, ttlMs) {
const payload = JSON.stringify({ u: username, exp: Date.now() + ttlMs });
const body = Buffer.from(payload).toString("base64url");
return `${body}.${sign(body, secret)}`;
}
/** Verify a session token → { u } if valid & unexpired, else null. */
export function verifyToken(token, secret) {
if (typeof token !== "string" || !token.includes(".")) return null;
const [body, mac] = token.split(".");
if (!body || !mac) return null;
const expected = sign(body, secret);
const a = Buffer.from(mac);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
let payload;
try {
payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
} catch {
return null;
}
if (!payload || typeof payload.exp !== "number" || Date.now() > payload.exp) return null;
return { u: payload.u };
}
/** Parse the admin session cookie out of a raw Cookie header. */
export function readSessionCookie(req) {
const raw = req.headers.cookie || "";
for (const part of raw.split(";")) {
const i = part.indexOf("=");
if (i === -1) continue;
if (part.slice(0, i).trim() === COOKIE_NAME) {
return decodeURIComponent(part.slice(i + 1).trim());
}
}
return null;
}
export function setSessionCookie(res, token, { secure, ttlMs }) {
const attrs = [
`${COOKIE_NAME}=${encodeURIComponent(token)}`,
"HttpOnly",
"SameSite=Strict",
"Path=/",
`Max-Age=${Math.floor(ttlMs / 1000)}`,
];
if (secure) attrs.push("Secure");
res.append("Set-Cookie", attrs.join("; "));
}
export function clearSessionCookie(res, { secure }) {
const attrs = [`${COOKIE_NAME}=`, "HttpOnly", "SameSite=Strict", "Path=/", "Max-Age=0"];
if (secure) attrs.push("Secure");
res.append("Set-Cookie", attrs.join("; "));
}
/** Express middleware: allow through only requests with a valid session. */
export function requireAuth(secret) {
return (req, res, next) => {
const tok = readSessionCookie(req);
const sess = tok && verifyToken(tok, secret);
if (!sess) {
// req.path is stripped of the mount prefix, so match on originalUrl
const url = req.originalUrl || req.url || "";
if (url.startsWith("/admin/api") || url.endsWith(".csv")) {
return res.status(401).json({ error: "unauthorized" });
}
return res.redirect("/admin/login");
}
req.admin = sess;
next();
};
}
export { COOKIE_NAME };

56
waitlist-server/lib/db.js Normal file
View File

@ -0,0 +1,56 @@
// SQLite storage for waitlist signups. One row per unique email — dedup is
// enforced by the DB itself (UNIQUE, case-insensitive), so a repeat signup is a
// no-op rather than a duplicate row.
import Database from "better-sqlite3";
import path from "node:path";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.join(__dirname, "..", "data");
const DB_PATH = process.env.DB_PATH || path.join(DATA_DIR, "waitlist.db");
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
const db = new Database(DB_PATH);
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS signups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
source TEXT,
ip TEXT,
user_agent TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
`);
const insertStmt = db.prepare(
`INSERT INTO signups (email, source, ip, user_agent)
VALUES (@email, @source, @ip, @user_agent)
ON CONFLICT(email) DO NOTHING`
);
const countStmt = db.prepare(`SELECT COUNT(*) AS n FROM signups`);
const listStmt = db.prepare(
`SELECT id, email, source, ip, created_at FROM signups ORDER BY created_at DESC, id DESC`
);
/**
* Add an email. Returns { added: true } on a new signup, { added: false } if the
* email was already on the list (deduped — no duplicate stored).
*/
export function addEmail({ email, source = null, ip = null, userAgent = null }) {
const info = insertStmt.run({ email, source, ip, user_agent: userAgent });
return { added: info.changes > 0 };
}
export function countEmails() {
return countStmt.get().n;
}
export function listEmails() {
return listStmt.all();
}
export default db;

1271
waitlist-server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
{
"name": "exbyte-depot-waitlist",
"version": "1.0.0",
"private": true,
"description": "Waitlist collector + secure admin for the Exbyte Depot landing page",
"type": "module",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"set-password": "node scripts/set-password.js",
"gen-secret": "node -e \"console.log(require('crypto').randomBytes(48).toString('base64url'))\""
},
"engines": {
"node": ">=20"
},
"dependencies": {
"better-sqlite3": "^11.8.1",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0"
}
}

View File

@ -0,0 +1,37 @@
// Generate an ADMIN_PASS_HASH for .env from a password.
//
// node scripts/set-password.js "my super secret"
// node scripts/set-password.js # prompts (hidden input)
//
// Paste the printed line into .env — the plaintext password is never stored.
import readline from "node:readline";
import { hashPassword } from "../lib/auth.js";
function ask(question) {
return new Promise((resolve) => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
// hide typed characters
const orig = rl._writeToOutput.bind(rl);
rl._writeToOutput = (str) => {
if (str.includes(question)) orig(str);
else orig("*");
};
rl.question(question, (answer) => {
rl.close();
process.stdout.write("\n");
resolve(answer);
});
});
}
const argPw = process.argv.slice(2).join(" ").trim();
const pw = argPw || (await ask("New admin password: "));
if (!pw || pw.length < 8) {
console.error("Password must be at least 8 characters.");
process.exit(1);
}
console.log("\nAdd this line to your .env:\n");
console.log(`ADMIN_PASS_HASH=${hashPassword(pw)}\n`);

209
waitlist-server/server.js Normal file
View File

@ -0,0 +1,209 @@
// Exbyte Depot — waitlist collector + secure admin panel.
//
// Public: serves the landing page and accepts POST /api/waitlist (dedup).
// Admin: password-gated (scrypt), rate-limited, signed-cookie session.
// view signups + export CSV. No plaintext secrets in the codebase.
import express from "express";
import rateLimit from "express-rate-limit";
import path from "node:path";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { addEmail, countEmails, listEmails } from "./lib/db.js";
import {
verifyPassword,
safeEqual,
issueToken,
requireAuth,
setSessionCookie,
clearSessionCookie,
} from "./lib/auth.js";
// --- load .env (built into Node, no dotenv dependency) ----------------------
const __dirname = path.dirname(fileURLToPath(import.meta.url));
try {
process.loadEnvFile(path.join(__dirname, ".env"));
} catch {
/* no .env file — rely on real environment variables */
}
const {
PORT = 8787,
NODE_ENV = "development",
ADMIN_USER,
ADMIN_PASS_HASH,
SESSION_SECRET,
TRUST_PROXY = "0",
LANDING_DIR = path.join(__dirname, "..", "landing"),
} = process.env;
const IS_PROD = NODE_ENV === "production";
const SESSION_TTL_MS = 8 * 60 * 60 * 1000; // 8 hours
// --- refuse to boot without the security-critical config --------------------
const missing = [];
if (!ADMIN_USER) missing.push("ADMIN_USER");
if (!ADMIN_PASS_HASH) missing.push("ADMIN_PASS_HASH");
if (!SESSION_SECRET || SESSION_SECRET.length < 24) missing.push("SESSION_SECRET (>=24 chars)");
if (missing.length) {
console.error(
`\n[waitlist] Refusing to start — missing/weak config: ${missing.join(", ")}\n` +
` 1) cp .env.example .env\n` +
` 2) npm run gen-secret # paste into SESSION_SECRET\n` +
` 3) npm run set-password # paste into ADMIN_PASS_HASH\n`
);
process.exit(1);
}
const app = express();
app.disable("x-powered-by");
if (TRUST_PROXY !== "0") app.set("trust proxy", Number(TRUST_PROXY) || 1);
app.use(express.json({ limit: "16kb" }));
app.use(express.urlencoded({ extended: false, limit: "16kb" }));
// --- security headers (self-contained; no external origins) -----------------
app.use((req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Referrer-Policy", "no-referrer");
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
res.setHeader("Permissions-Policy", "geolocation=(), microphone=(), camera=()");
res.setHeader(
"Content-Security-Policy",
[
"default-src 'self'",
"style-src 'self' 'unsafe-inline'",
"script-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
].join("; ")
);
next();
});
// --- rate limiters ----------------------------------------------------------
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 8, // per IP per window
standardHeaders: true,
legacyHeaders: false,
message: { error: "Too many attempts. Try again later." },
});
const waitlistLimiter = rateLimit({
windowMs: 10 * 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: { error: "Too many requests. Slow down." },
});
// --- helpers ----------------------------------------------------------------
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function normalizeEmail(v) {
return String(v || "").trim().toLowerCase();
}
function clientIp(req) {
return (req.ip || "").replace(/^::ffff:/, "") || null;
}
function sendView(res, file, replacements = {}) {
let html = fs.readFileSync(path.join(__dirname, "views", file), "utf8");
for (const [k, v] of Object.entries(replacements)) {
html = html.replaceAll(`{{${k}}}`, v);
}
res.type("html").send(html);
}
// ============================================================================
// PUBLIC — waitlist signup
// ============================================================================
app.post("/api/waitlist", waitlistLimiter, (req, res) => {
// honeypot: bots fill hidden fields; humans leave them blank
if (req.body && typeof req.body.website === "string" && req.body.website.trim() !== "") {
return res.json({ ok: true, deduped: true }); // silently accept, store nothing
}
const email = normalizeEmail(req.body?.email);
if (!email || email.length > 254 || !EMAIL_RE.test(email)) {
return res.status(400).json({ ok: false, error: "Enter a valid email." });
}
try {
const { added } = addEmail({
email,
source: (req.body?.source || "landing").toString().slice(0, 64),
ip: clientIp(req),
userAgent: (req.headers["user-agent"] || "").slice(0, 300),
});
return res.json({ ok: true, added, deduped: !added });
} catch (e) {
console.error("[waitlist] insert failed:", e.message);
return res.status(500).json({ ok: false, error: "Server error." });
}
});
// ============================================================================
// ADMIN — auth
// ============================================================================
app.get("/admin/login", (req, res) => sendView(res, "login.html"));
app.post("/admin/login", loginLimiter, (req, res) => {
const user = String(req.body?.username || "");
const pass = String(req.body?.password || "");
const ok = safeEqual(user, ADMIN_USER) && verifyPassword(pass, ADMIN_PASS_HASH);
if (!ok) {
return res.status(401).redirect("/admin/login?error=1");
}
const token = issueToken(ADMIN_USER, SESSION_SECRET, SESSION_TTL_MS);
setSessionCookie(res, token, { secure: IS_PROD, ttlMs: SESSION_TTL_MS });
res.redirect("/admin");
});
app.post("/admin/logout", (req, res) => {
clearSessionCookie(res, { secure: IS_PROD });
res.redirect("/admin/login");
});
// everything below requires a valid session
app.use("/admin", requireAuth(SESSION_SECRET));
app.get("/admin", (req, res) => sendView(res, "admin.html", { USER: req.admin.u }));
app.get("/admin/api/emails", (req, res) => {
res.json({ count: countEmails(), emails: listEmails() });
});
// CSV export
app.get("/admin/export.csv", (req, res) => {
const rows = listEmails();
const esc = (v) => {
const s = v == null ? "" : String(v);
return /[",\n\r]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
};
const header = ["email", "source", "ip", "created_at"];
const lines = [header.join(",")];
for (const r of rows) {
lines.push([r.email, r.source, r.ip, r.created_at].map(esc).join(","));
}
const csv = "" + lines.join("\r\n"); // BOM so Excel reads UTF-8
const stamp = new Date().toISOString().slice(0, 10);
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="waitlist-${stamp}.csv"`);
res.send(csv);
});
// ============================================================================
// LANDING (static) — served last so /api and /admin win
// ============================================================================
app.use(
express.static(LANDING_DIR, {
extensions: ["html"],
setHeaders: (res) => res.setHeader("Cache-Control", "no-cache"),
})
);
app.listen(PORT, () => {
console.log(`[waitlist] listening on http://localhost:${PORT}`);
console.log(`[waitlist] landing: ${LANDING_DIR}`);
console.log(`[waitlist] admin: http://localhost:${PORT}/admin (${IS_PROD ? "prod" : "dev"})`);
});

View File

@ -0,0 +1,155 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<title>Waitlist · Exbyte Depot Admin</title>
<style>
:root{
--bg:#0a0a0f; --bg-2:#0d0d14; --panel:#12121b; --panel-2:#16161f; --elevated:#1a1a24;
--border:#26263340; --txt:#ececf4; --muted:#a2a2b4; --faint:#6d6d80;
--acc:#7c6ef6; --acc-2:#9d93ff; --acc-deep:#5b4ee0; --ok:#3ecf8e; --del:#f2637e;
}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;background:var(--bg);color:var(--txt);
font:14.5px/1.5 -apple-system,Segoe UI,Roboto,Inter,system-ui,sans-serif}
.wrap{max-width:1000px;margin:0 auto;padding:24px 22px 60px}
header{display:flex;align-items:center;gap:12px;padding:6px 0 22px}
.logo{width:34px;height:34px;border-radius:10px;flex:0 0 auto;
background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));
box-shadow:0 0 18px rgba(124,110,246,.45);display:grid;place-items:center}
.logo svg{width:18px;height:18px}
.htitle b{font-size:15px;font-weight:650}
.htitle span{display:block;font-size:11.5px;color:var(--faint)}
.spacer{flex:1}
.who{font-size:12.5px;color:var(--muted);margin-right:4px}
.who b{color:var(--txt);font-weight:600}
.btn{display:inline-flex;align-items:center;gap:7px;font-size:13px;font-weight:600;cursor:pointer;
background:var(--panel-2);border:1px solid var(--border);color:var(--txt);
border-radius:10px;padding:8px 13px;transition:.15s;text-decoration:none}
.btn:hover{border-color:var(--acc);color:#fff}
.btn svg{width:15px;height:15px}
.btn.accent{background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));border:0;color:#fff}
.btn.accent:hover{filter:brightness(1.08)}
.btn.ghost{background:transparent}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:14px;margin-bottom:20px}
.stat{background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:16px 18px}
.stat .n{font-size:28px;font-weight:700;letter-spacing:-.5px;font-variant-numeric:tabular-nums}
.stat .l{font-size:11.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-top:2px}
.stat.accent .n{color:var(--acc-2)}
.toolbar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
.search{flex:1;min-width:200px;display:flex;align-items:center;gap:8px;
background:var(--elevated);border:1px solid var(--border);border-radius:10px;padding:0 12px}
.search svg{width:15px;height:15px;color:var(--faint);flex:0 0 auto}
.search input{flex:1;background:none;border:0;outline:none;color:var(--txt);font-size:14px;padding:10px 0}
.panel{background:var(--panel);border:1px solid var(--border);border-radius:14px;overflow:hidden}
table{width:100%;border-collapse:collapse}
th,td{text-align:left;padding:11px 16px;font-size:13.5px}
th{background:var(--panel-2);color:var(--muted);font-weight:600;font-size:11.5px;
text-transform:uppercase;letter-spacing:.4px;position:sticky;top:0}
tbody tr{border-top:1px solid var(--border)}
tbody tr:hover{background:#ffffff06}
td.email{font-weight:550}
td.mono{font-variant-numeric:tabular-nums;color:var(--muted);white-space:nowrap}
.tag{display:inline-block;font-size:11px;color:var(--muted);background:var(--panel-2);
border:1px solid var(--border);border-radius:6px;padding:1px 8px}
.idx{color:var(--faint);font-variant-numeric:tabular-nums;width:42px}
.empty{padding:44px;text-align:center;color:var(--faint);font-size:13.5px}
.foot{margin-top:16px;font-size:11.5px;color:var(--faint);text-align:center}
</style>
</head>
<body>
<div class="wrap">
<header>
<span class="logo"><svg viewBox="0 0 24 24" fill="none"><path d="M4 7l8-4 8 4v10l-8 4-8-4V7Z" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/><path d="M4 7l8 4 8-4M12 11v10" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
<div class="htitle"><b>Waitlist</b><span>Exbyte Depot · admin</span></div>
<div class="spacer"></div>
<span class="who">Signed in as <b>{{USER}}</b></span>
<form method="POST" action="/admin/logout" style="display:inline">
<button class="btn ghost" type="submit">Log out</button>
</form>
</header>
<div class="stats">
<div class="stat accent"><div class="n" id="statTotal"></div><div class="l">Total signups</div></div>
<div class="stat"><div class="n" id="statShown"></div><div class="l">Showing</div></div>
<div class="stat"><div class="n" id="statLatest"></div><div class="l">Latest signup</div></div>
</div>
<div class="toolbar">
<div class="search">
<svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.7"/><path d="m20 20-3-3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
<input id="search" type="search" placeholder="Filter emails…" autocomplete="off" />
</div>
<button class="btn" id="refresh" type="button">
<svg viewBox="0 0 24 24" fill="none"><path d="M20 11a8 8 0 1 0-.5 3M20 5v6h-6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
Refresh
</button>
<a class="btn accent" href="/admin/export.csv">
<svg viewBox="0 0 24 24" fill="none"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke="#fff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
Export CSV
</a>
</div>
<div class="panel">
<table>
<thead>
<tr><th class="idx">#</th><th>Email</th><th>Source</th><th>Date (UTC)</th></tr>
</thead>
<tbody id="rows"></tbody>
</table>
<div class="empty" id="empty" style="display:none">No signups yet.</div>
</div>
<div class="foot" id="foot"></div>
</div>
<script>
let all = [];
const $ = (id) => document.getElementById(id);
function fmtDate(s){ return (s || "").replace("T"," ").replace("Z",""); }
function render(){
const q = $("search").value.trim().toLowerCase();
const list = q ? all.filter(r => r.email.toLowerCase().includes(q)) : all;
const tbody = $("rows");
tbody.textContent = "";
$("empty").style.display = list.length ? "none" : "block";
list.forEach((r, i) => {
const tr = document.createElement("tr");
const idx = document.createElement("td"); idx.className = "idx"; idx.textContent = i + 1;
const em = document.createElement("td"); em.className = "email"; em.textContent = r.email;
const src = document.createElement("td");
const tag = document.createElement("span"); tag.className = "tag"; tag.textContent = r.source || "—";
src.appendChild(tag);
const dt = document.createElement("td"); dt.className = "mono"; dt.textContent = fmtDate(r.created_at);
tr.append(idx, em, src, dt);
tbody.appendChild(tr);
});
$("statShown").textContent = list.length;
$("foot").textContent = list.length
? `${list.length} of ${all.length} shown` : "";
}
async function load(){
try {
const res = await fetch("/admin/api/emails", { headers: { "Accept": "application/json" } });
if (res.status === 401) { location.href = "/admin/login"; return; }
const data = await res.json();
all = data.emails || [];
$("statTotal").textContent = data.count ?? all.length;
$("statLatest").textContent = all.length ? fmtDate(all[0].created_at).slice(0,10) : "—";
render();
} catch (e) {
$("empty").style.display = "block";
$("empty").textContent = "Failed to load. " + e.message;
}
}
$("search").addEventListener("input", render);
$("refresh").addEventListener("click", load);
load();
</script>
</body>
</html>

View File

@ -0,0 +1,72 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<title>Admin · Exbyte Depot</title>
<style>
:root{
--bg:#0a0a0f; --panel:#12121b; --elevated:#1a1a24; --border:#26263340;
--txt:#ececf4; --muted:#a2a2b4; --faint:#6d6d80;
--acc:#7c6ef6; --acc-2:#9d93ff; --acc-deep:#5b4ee0; --del:#f2637e;
}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;
background:radial-gradient(1200px 600px at 50% -10%,#171626 0%,var(--bg) 55%);
font:15px/1.5 -apple-system,Segoe UI,Roboto,Inter,system-ui,sans-serif;color:var(--txt)}
.card{width:100%;max-width:380px;background:var(--panel);border:1px solid var(--border);
border-radius:18px;padding:34px 30px;box-shadow:0 30px 80px -30px #000}
.brand{display:flex;align-items:center;gap:11px;margin-bottom:24px}
.logo{width:38px;height:38px;border-radius:11px;flex:0 0 auto;
background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));
box-shadow:0 0 22px rgba(124,110,246,.5);display:grid;place-items:center}
.logo svg{width:20px;height:20px}
.brand b{font-size:15px;font-weight:650;letter-spacing:.2px}
.brand span{display:block;font-size:11.5px;color:var(--faint);font-weight:500}
h1{font-size:19px;font-weight:650;margin:0 0 4px}
.sub{color:var(--muted);font-size:12.5px;margin:0 0 22px}
label{display:block;font-size:11.5px;color:var(--muted);font-weight:600;margin:0 0 6px;
text-transform:uppercase;letter-spacing:.4px}
.field{margin-bottom:16px}
input{width:100%;background:var(--elevated);border:1px solid var(--border);border-radius:11px;
padding:11px 13px;color:var(--txt);font-size:14px;outline:none;transition:.15s}
input:focus{border-color:var(--acc);box-shadow:0 0 0 3px rgba(124,110,246,.16)}
button{width:100%;margin-top:6px;padding:12px;border:0;border-radius:11px;cursor:pointer;
background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));color:#fff;
font-size:14.5px;font-weight:650;letter-spacing:.2px;transition:.15s}
button:hover{filter:brightness(1.08)}
.err{display:none;background:rgba(242,99,126,.1);border:1px solid rgba(242,99,126,.35);
color:#ff9db0;font-size:12.5px;padding:9px 12px;border-radius:10px;margin-bottom:18px}
.err.show{display:block}
.foot{margin-top:20px;text-align:center;font-size:11px;color:var(--faint)}
</style>
</head>
<body>
<form class="card" method="POST" action="/admin/login" autocomplete="off">
<div class="brand">
<span class="logo"><svg viewBox="0 0 24 24" fill="none"><path d="M4 7l8-4 8 4v10l-8 4-8-4V7Z" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/><path d="M4 7l8 4 8-4M12 11v10" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
<div><b>Exbyte Depot</b><span>Waitlist admin</span></div>
</div>
<h1>Sign in</h1>
<p class="sub">Restricted area — authorized access only.</p>
<div class="err" id="err">Invalid username or password.</div>
<div class="field">
<label for="u">Username</label>
<input id="u" name="username" type="text" autocomplete="username" required autofocus />
</div>
<div class="field">
<label for="p">Password</label>
<input id="p" name="password" type="password" autocomplete="current-password" required />
</div>
<button type="submit">Sign in</button>
<div class="foot">Protected endpoint · rate-limited</div>
</form>
<script>
if (new URLSearchParams(location.search).get("error")) {
document.getElementById("err").classList.add("show");
history.replaceState(null, "", "/admin/login");
}
</script>
</body>
</html>