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:
29
src/App.css
29
src/App.css
@ -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}
|
||||
|
||||
17
src/App.tsx
17
src/App.tsx
@ -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 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
83
src/Updater.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user