Add non-intrusive auto-updater

Tauri updater + process plugins. Checks Gitea Releases endpoint a few
seconds after launch; on a newer signed version shows a dismissible
bottom-right banner with download progress and auto-relaunch. Public
signing key in tauri.conf.json; release flow documented in RELEASING.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-07 13:51:42 +03:00
parent f33ab7435b
commit fdcc9efb31
11 changed files with 612 additions and 15 deletions

View File

@ -201,6 +201,35 @@ body.resizing{cursor:col-resize!important;user-select:none}
.ctxitem:hover{background:var(--hover)}
.ctxitem.danger{color:var(--del)}
.ctxitem.danger:hover{background:rgba(242,99,126,.12)}
/* non-intrusive update banner (bottom-right) */
.upd{position:fixed;right:18px;bottom:18px;z-index:400;width:320px;padding:15px 16px;border-radius:14px;
background:var(--elevated);border:1px solid var(--border);box-shadow:0 22px 50px -14px rgba(0,0,0,.55);
animation:updin .35s cubic-bezier(.2,.9,.3,1)}
@keyframes updin{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
.upd-head{display:flex;align-items:center;gap:11px}
.upd-ic{width:34px;height:34px;flex:0 0 auto;border-radius:10px;display:grid;place-items:center;color:#fff;
background:linear-gradient(145deg,var(--accent-2),var(--accent-deep));box-shadow:0 0 18px rgba(124,110,246,.5)}
.upd-ic svg{width:18px;height:18px}
.upd-ttl{display:flex;flex-direction:column;gap:1px;min-width:0}
.upd-ttl b{font-size:13.5px}
.upd-ttl span{font-size:11.5px;color:var(--faint);font-variant-numeric:tabular-nums}
.upd-x{margin-left:auto;width:24px;height:24px;flex:0 0 auto;border:none;background:none;color:var(--faint);
border-radius:7px;cursor:pointer;display:grid;place-items:center}
.upd-x:hover{background:var(--hover);color:var(--txt)}
.upd-notes{margin-top:10px;font-size:11.5px;line-height:1.5;color:var(--muted);max-height:70px;overflow:auto;white-space:pre-line}
.upd-notes.err{color:var(--del)}
.upd-prog{margin-top:12px;display:flex;flex-direction:column;gap:7px}
.upd-prog span{font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
.upd-track{height:7px;border-radius:5px;background:var(--panel-3);overflow:hidden}
.upd-fill{height:100%;border-radius:5px;background:linear-gradient(90deg,var(--accent-2),var(--accent-deep));transition:width .2s;box-shadow:0 0 10px rgba(124,110,246,.6)}
.upd-actions{margin-top:13px;display:flex;gap:9px}
.upd-later{flex:0 0 auto;padding:8px 14px;border-radius:9px;border:1px solid var(--border);background:var(--panel);
color:var(--muted);font-size:12.5px;cursor:pointer;font-family:var(--font)}
.upd-later:hover{color:var(--txt);border-color:var(--accent)}
.upd-go{flex:1;padding:8px 14px;border:none;border-radius:9px;font-size:12.5px;font-weight:600;color:#fff;cursor:pointer;
background:linear-gradient(135deg,var(--accent-2),var(--accent-deep));box-shadow:0 8px 20px -8px rgba(124,110,246,.7)}
.upd-go:hover{filter:brightness(1.08)}
.tzone.push .ic{color:var(--add)}
.tzone.push .val{color:var(--add)}
.commit .row{display:flex;gap:10px;align-items:center}

View File

@ -3,6 +3,7 @@ import type { MouseEvent as ReactMouseEvent, CSSProperties } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import ModelViewer from "./ModelViewer";
import UpdateBanner from "./Updater";
import "./App.css";
import { p4, statusOf, splitPath, kindOf, fmtTime, describeFiles, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, P4Info, OpenedFile, Client, Change, Session, Describe, Dir } from "./p4";
@ -64,8 +65,9 @@ export default function App() {
.catch(() => setPhase("connect"));
}, []); // eslint-disable-line
let content;
if (phase === "loading")
return (
content = (
<div className="win">
<div className="titlebar" data-tauri-drag-region>
<div className="brand" data-tauri-drag-region><div className="logo">{I.hex}</div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
@ -75,14 +77,15 @@ export default function App() {
<div className="overlay"><div className="nofile"><span className="ldr" /><span>Восстановление сессии</span></div></div>
</div>
);
if (phase === "connect")
return <Connect light={light} toggleTheme={toggleTheme} initial={saved}
else if (phase === "connect")
content = <Connect light={light} toggleTheme={toggleTheme} initial={saved}
onDone={(i, s) => { saveSession(s); setInfo(i); setSession(s); setPhase("main"); }} />;
else
content = <Workbench info={info} session={session} light={light} toggleTheme={toggleTheme}
onInfo={setInfo} onSession={(s) => { setSession(s); saveSession(s); }}
onDisconnect={() => { p4.disconnect(); clearSession(); setInfo(null); setSession(null); setPhase("connect"); }} />;
return <Workbench info={info} session={session} light={light} toggleTheme={toggleTheme}
onInfo={setInfo} onSession={(s) => { setSession(s); saveSession(s); }}
onDisconnect={() => { p4.disconnect(); clearSession(); setInfo(null); setSession(null); setPhase("connect"); }} />;
return <>{content}<UpdateBanner /></>;
}
/* ---------------- connect screen ---------------- */

83
src/Updater.tsx Normal file
View File

@ -0,0 +1,83 @@
import { useEffect, useState } from "react";
import { check, type Update } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
type Phase = "idle" | "available" | "downloading" | "done" | "error";
// Non-intrusive update prompt: quietly checks the Gitea Releases endpoint a few
// seconds after launch. If a newer signed version exists, slides a small card into
// the bottom-right corner. The user chooses when to install — nothing is forced.
export default function UpdateBanner() {
const [update, setUpdate] = useState<Update | null>(null);
const [phase, setPhase] = useState<Phase>("idle");
const [pct, setPct] = useState(0);
const [err, setErr] = useState("");
const [hidden, setHidden] = useState(false);
useEffect(() => {
let live = true;
const t = setTimeout(async () => {
try {
const u = await check();
if (live && u) { setUpdate(u); setPhase("available"); }
} catch {
// offline, or no release published yet — stay silent, never nag
}
}, 4000);
return () => { live = false; clearTimeout(t); };
}, []);
async function install() {
if (!update) return;
setPhase("downloading"); setPct(0);
let total = 0, got = 0;
try {
await update.downloadAndInstall((e) => {
if (e.event === "Started") total = e.data.contentLength || 0;
else if (e.event === "Progress") { got += e.data.chunkLength; if (total) setPct(Math.min(100, Math.round((got / total) * 100))); }
else if (e.event === "Finished") setPct(100);
});
setPhase("done");
await relaunch();
} catch (e) { setErr(String(e)); setPhase("error"); }
}
if (hidden || phase === "idle" || !update) return null;
return (
<div className="upd">
<div className="upd-head">
<span className="upd-ic">
<svg viewBox="0 0 24 24" fill="none"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
</span>
<div className="upd-ttl">
<b>Доступно обновление</b>
<span>Exbyte Depot v{update.version}</span>
</div>
{(phase === "available" || phase === "error") && (
<button className="upd-x" onClick={() => setHidden(true)} title="Позже">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
</button>
)}
</div>
{update.body?.trim() && phase === "available" && <div className="upd-notes">{update.body.trim()}</div>}
{phase === "downloading" && (
<div className="upd-prog">
<div className="upd-track"><div className="upd-fill" style={{ width: `${pct}%` }} /></div>
<span>Загрузка {pct}%</span>
</div>
)}
{phase === "done" && <div className="upd-notes">Установлено перезапускаю</div>}
{phase === "error" && <div className="upd-notes err">{err}</div>}
{phase === "available" && (
<div className="upd-actions">
<button className="upd-later" onClick={() => setHidden(true)}>Позже</button>
<button className="upd-go" onClick={install}>Обновить сейчас</button>
</div>
)}
</div>
);
}