Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 597dffe92d | |||
| 30e77664d5 |
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "exbyte-depot",
|
||||
"private": true,
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@ -992,7 +992,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "exbyte-depot"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"reqwest 0.12.28",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "exbyte-depot"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2"
|
||||
description = "Exbyte Depot — native Perforce client by Exbyte Studios"
|
||||
authors = ["Exbyte Studios"]
|
||||
edition = "2021"
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Exbyte Depot",
|
||||
"mainBinaryName": "Exbyte Depot",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.2",
|
||||
"identifier": "com.bonchellon.exbyte-depot",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@ -947,6 +947,11 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.dropinner svg{width:44px;height:44px}
|
||||
.dropinner span{font-size:15px;font-weight:600;color:var(--txt)}
|
||||
|
||||
/* re-auth: who we're signing back in as */
|
||||
.reauth-who{display:flex;flex-wrap:wrap;gap:6px;margin:2px 0 14px}
|
||||
.reauth-who span{font-family:var(--mono);font-size:11px;color:var(--muted);
|
||||
background:var(--panel-3);border:1px solid var(--border);border-radius:6px;padding:3px 8px}
|
||||
|
||||
/* toast */
|
||||
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:200;max-width:72%;
|
||||
background:var(--elevated);border:1px solid var(--border);color:var(--txt);
|
||||
|
||||
73
src/App.tsx
73
src/App.tsx
@ -9,7 +9,7 @@ import ModelViewer from "./ModelViewer";
|
||||
import CodeView from "./CodeView";
|
||||
import { useUpdater } from "./Updater";
|
||||
import "./App.css";
|
||||
import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, humanSize, leaf, syncSummary, saveSession, loadSession, clearSession, saveScope, loadScope, recentScopes, pushRecentScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev, FsEntry, PluginInfo } from "./p4";
|
||||
import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, humanSize, leaf, syncSummary, isAuthError, onAuthLost, saveSession, loadSession, clearSession, saveScope, loadScope, recentScopes, pushRecentScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev, FsEntry, PluginInfo } from "./p4";
|
||||
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
|
||||
import { Theme, THEME_TOKENS, BUILTIN_THEMES, allThemes, loadCustomThemes, saveCustomThemes, loadActiveId, saveActiveId, resolveTheme, applyTheme, tokenValue, newCustomId, exportTheme, importTheme } from "./themes";
|
||||
import { loadPlugins, getRegistry, subscribeRegistry, runSubmitHooks, PluginView, PluginDock, fetchRegistry, installFromRegistry, RegistryEntry } from "./plugins";
|
||||
@ -408,6 +408,7 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
|
||||
const [needResolve, setNeedResolve] = useState<OpenedFile[]>([]); // files awaiting conflict resolution
|
||||
const [resolveOpen, setResolveOpen] = useState(false);
|
||||
const [offline, setOffline] = useState(false); // lost connection to the server
|
||||
const [reauth, setReauth] = useState(false); // p4 ticket expired — prompt for the password
|
||||
const [workOffline, setWorkOffline] = useState(() => { try { return localStorage.getItem("exd-workoffline") === "1"; } catch { return false; } }); // explicit offline-work mode
|
||||
const [behind, setBehind] = useState<{ count: number; sample: string[] } | null>(null); // server has newer content than the workspace
|
||||
const lastSubmit = useRef<string>(""); // newest submitted CL seen (new-submit toast)
|
||||
@ -674,6 +675,8 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
|
||||
if (loaded) flash(t("{n} plugin(s) loaded", { n: loaded }));
|
||||
})().catch(() => {});
|
||||
}, [info?.clientName]); // eslint-disable-line
|
||||
// any p4 command that comes back "not logged in" raises the re-auth prompt
|
||||
useEffect(() => { onAuthLost(() => setReauth(true)); return () => onAuthLost(null); }, []);
|
||||
useEffect(() => subscribeRegistry(() => setPluginVer((v) => v + 1)), []);
|
||||
// live Perforce command log — the Rust backend emits one event per p4 call
|
||||
useEffect(() => {
|
||||
@ -796,7 +799,10 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
|
||||
checkBehind(); // a new submit landed → am I now behind?
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
// an expired ticket is not an outage: the server answered, it just
|
||||
// refused us. The re-auth prompt handles it — don't also claim offline.
|
||||
if (isAuthError(e)) return;
|
||||
if (alive && !offline) setOffline(true); // p4 unreachable → show offline banner
|
||||
}
|
||||
};
|
||||
@ -1804,6 +1810,10 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
|
||||
{showXfer && transfer && xferMin && <TransferChip transfer={transfer} onExpand={() => setXferMin(false)} onCancel={cancelTransfer} />}
|
||||
{buildPick && <BuildPicker onPick={buildSln} onClose={() => setBuildPick(false)} />}
|
||||
{buildOpen && !buildMin && <BuildOverlay lines={buildLog} building={building} ok={buildOk} sln={slnPath} onMinimize={() => setBuildMin(true)} onClose={() => setBuildOpen(false)} />}
|
||||
{/* last in the tree so it sits over every other overlay — nothing else
|
||||
can succeed until the session is back */}
|
||||
{reauth && <ReauthModal session={session} onSignOut={onDisconnect}
|
||||
onDone={(i) => { setReauth(false); setOffline(false); onInfo(i); flash(t("Reconnected to the server.")); void refresh(); }} />}
|
||||
{buildOpen && buildMin && <BuildChip building={building} ok={buildOk} onExpand={() => setBuildMin(false)} onClose={() => setBuildOpen(false)} />}
|
||||
{ctx && <ContextMenu x={ctx.x} y={ctx.y} items={ctx.items} onClose={() => setCtx(null)} />}
|
||||
{toast && <div className={"toast" + (toast.err ? " err" : "")}>{toast.text}</div>}
|
||||
@ -2406,6 +2416,65 @@ function TextPrompt({ title, label, value, onSave, onClose }: { title: string; l
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- re-authenticate (expired ticket) ----------------
|
||||
Perforce expires the login ticket on its own schedule, so a long day
|
||||
outlives it and every command starts failing. The server is still there and
|
||||
the workspace is untouched — only the ticket died — so we ask for the
|
||||
password over the running app instead of dropping back to the Connect
|
||||
screen, which would throw away the scope, selection and open panels.
|
||||
Deliberately not dismissable by clicking away: nothing works until it's
|
||||
resolved one way or the other. */
|
||||
function ReauthModal({ session, onDone, onSignOut }: {
|
||||
session: Session | null; onDone: (i: P4Info) => void; onSignOut: () => void;
|
||||
}) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => { ref.current?.focus(); }, []);
|
||||
|
||||
async function reconnect() {
|
||||
if (!session || !password || busy) return;
|
||||
setBusy(true); setErr("");
|
||||
try {
|
||||
const i = await p4.login(session.server, session.user, password, session.client);
|
||||
setPassword("");
|
||||
onDone(i);
|
||||
} catch (e) { setErr(String(e)); setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-back">
|
||||
<div className="modal-card prompt" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{t("Session expired")}</h3>
|
||||
<div className="prompt-label">
|
||||
{t("Perforce ended the session. Sign in again to carry on — your workspace and open changes are untouched.")}
|
||||
</div>
|
||||
<div className="reauth-who">
|
||||
<span>{session?.user}</span>
|
||||
<span>{session?.client}</span>
|
||||
<span>{session?.server}</span>
|
||||
</div>
|
||||
<div className="fld"><label>{t("Password")}</label>
|
||||
<div className="inp">
|
||||
<svg viewBox="0 0 24 24" fill="none"><rect x="4" y="10" width="16" height="10" rx="2" stroke="currentColor" strokeWidth="1.6" /><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="currentColor" strokeWidth="1.6" /></svg>
|
||||
<input ref={ref} type="password" value={password} disabled={busy}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && reconnect()} />
|
||||
</div>
|
||||
</div>
|
||||
{err && <div className="err"><span>⚠</span><span>{err}</span></div>}
|
||||
<div className="modal-actions">
|
||||
<button className="mbtn ghost" onClick={onSignOut} disabled={busy}>{t("Sign out")}</button>
|
||||
<button className="mbtn primary" onClick={reconnect} disabled={busy || !password}>
|
||||
{busy ? t("Connecting…") : t("Reconnect")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- confirm modal ---------------- */
|
||||
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {
|
||||
const btn = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@ -447,6 +447,11 @@ const D: Record<string, Tr> = {
|
||||
"Not a text file.": { ru: "Не текстовый файл.", de: "Keine Textdatei.", fr: "Pas un fichier texte.", es: "No es un archivo de texto." },
|
||||
"{user} submitted #{n}": { ru: "{user} отправил #{n}", de: "{user} hat #{n} übermittelt", fr: "{user} a soumis #{n}", es: "{user} envió #{n}" },
|
||||
"Reconnected to the server.": { ru: "Соединение с сервером восстановлено.", de: "Wieder mit dem Server verbunden.", fr: "Reconnecté au serveur.", es: "Reconectado al servidor." },
|
||||
// session expired → re-auth prompt
|
||||
"Session expired": { ru: "Сессия истекла", de: "Sitzung abgelaufen", fr: "Session expirée", es: "Sesión expirada" },
|
||||
"Perforce ended the session. Sign in again to carry on — your workspace and open changes are untouched.": { ru: "Perforce завершил сессию. Войдите снова, чтобы продолжить — workspace и открытые правки не тронуты.", de: "Perforce hat die Sitzung beendet. Melde dich erneut an — dein Workspace und offene Änderungen bleiben unberührt.", fr: "Perforce a mis fin à la session. Reconnectez-vous pour continuer — votre workspace et vos modifications ouvertes sont intacts.", es: "Perforce terminó la sesión. Inicia sesión de nuevo para continuar: tu workspace y los cambios abiertos están intactos." },
|
||||
"Reconnect": { ru: "Переподключиться", de: "Neu verbinden", fr: "Se reconnecter", es: "Reconectar" },
|
||||
"Sign out": { ru: "Выйти", de: "Abmelden", fr: "Se déconnecter", es: "Cerrar sesión" },
|
||||
"Connected server": { ru: "Сервер подключения", de: "Verbundener Server", fr: "Serveur connecté", es: "Servidor conectado" },
|
||||
"No embedded thumbnail. Load the 3D model below (needs Unreal Engine installed).": { ru: "Встроенного превью нет. Загрузи 3D-модель ниже (нужен установленный Unreal Engine).", de: "Kein eingebettetes Vorschaubild. Lade unten das 3D-Modell (Unreal Engine muss installiert sein).", fr: "Pas de miniature intégrée. Charge le modèle 3D ci-dessous (Unreal Engine requis).", es: "Sin miniatura incrustada. Carga el modelo 3D abajo (requiere Unreal Engine instalado)." },
|
||||
"Load 3D model": { ru: "Загрузить 3D-модель", de: "3D-Modell laden", fr: "Charger le modèle 3D", es: "Cargar modelo 3D" },
|
||||
|
||||
28
src/p4.ts
28
src/p4.ts
@ -1,5 +1,31 @@
|
||||
// Typed wrappers around the Rust p4 commands.
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { invoke as rawInvoke } from "@tauri-apps/api/core";
|
||||
|
||||
/** True when p4 rejected us for auth reasons — an expired or dropped ticket —
|
||||
* as opposed to the server being unreachable. Perforce expires tickets on its
|
||||
* own schedule (often 12h), so a workday can outlive a login. */
|
||||
export function isAuthError(e: unknown): boolean {
|
||||
return /P4PASSWD|invalid or unset|session has expired|session was logged out|please login again/i
|
||||
.test(String(e));
|
||||
}
|
||||
|
||||
// Set by the app: called when any command comes back with an auth failure, so
|
||||
// the UI can ask for the password again instead of dead-ending on every action.
|
||||
let authLost: (() => void) | null = null;
|
||||
export function onAuthLost(fn: (() => void) | null) { authLost = fn; }
|
||||
|
||||
// Commands that legitimately report bad credentials — the login screen shows
|
||||
// those inline, and firing the re-auth prompt from them would loop.
|
||||
const AUTH_CMDS = new Set(["p4_login", "p4_restore", "p4_clients"]);
|
||||
|
||||
/** Every p4 command funnels through here so a lost ticket is caught in one
|
||||
* place rather than at each of the ~110 call sites. */
|
||||
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
return rawInvoke<T>(cmd, args).catch((e) => {
|
||||
if (!AUTH_CMDS.has(cmd) && isAuthError(e)) authLost?.();
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
export interface P4Info {
|
||||
serverAddress?: string;
|
||||
|
||||
Reference in New Issue
Block a user