feat: add desktop updater and release automation

This commit is contained in:
lbl
2026-08-22 10:00:24 +08:00
parent da72670154
commit 60a4403f0e
42 changed files with 1374 additions and 257 deletions
+3
View File
@@ -45,6 +45,9 @@ export const getStartStatus = (fileName) =>
// GET /api/version
export const getVersion = () => request("/api/version");
// GET /api/runtime
export const getRuntime = () => request("/api/runtime");
// GET /api/instances
export const getInstances = () => request("/api/instances");
+6
View File
@@ -36,6 +36,12 @@ export const navItems = [
desktopOnly: true,
icon: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0 0c2.2-2.5 3.3-5.5 3.3-9S14.2 5.5 12 3m0 18c-2.2-2.5-3.3-5.5-3.3-9S9.8 5.5 12 3M3.5 9h17m-17 6h17",
},
{
to: "/about",
label: "关于",
shortLabel: "关于",
icon: "M12 17v-6m0-4h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z",
},
];
export const visibleNavItems = () =>
+2
View File
@@ -5,6 +5,7 @@ import ConfigView from "../views/ConfigView.vue";
import PeersView from "../views/PeersView.vue";
import RoutesView from "../views/RoutesView.vue";
import WebAccessView from "../views/WebAccessView.vue";
import AboutView from "../views/AboutView.vue";
const routes = [
{ path: "/", component: DashboardView },
@@ -15,6 +16,7 @@ const routes = [
{ path: "/peers", component: PeersView },
{ path: "/routes", component: RoutesView },
{ path: "/web-access", component: WebAccessView },
{ path: "/about", component: AboutView },
];
export default createRouter({
+231
View File
@@ -0,0 +1,231 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { useAppStore } from "../stores/app";
import { getRuntime, getVersion } from "../api";
import { isDesktop } from "../auth";
import vntIcon from "../assets/vnt-icon.png";
const PROJECT_URL = "https://github.com/vnt-dev/vnt";
const RELEASES_URL = `${PROJECT_URL}/releases`;
const RELEASES_API = "https://api.github.com/repos/vnt-dev/vnt/releases?per_page=20";
const app = useAppStore();
const runtime = ref(isDesktop ? "desktop" : "");
const checking = ref(false);
const installing = ref(false);
const updateInfo = ref(null);
const resultKind = ref("");
const message = ref("");
const downloaded = ref(0);
const contentLength = ref(0);
const currentVersion = computed(() => app.version || "2.0.0");
const progress = computed(() => {
if (!contentLength.value) return 0;
return Math.min(100, Math.round((downloaded.value / contentLength.value) * 100));
});
const versionParts = (value) => {
const match = String(value || "").trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:[+-].*)?$/);
return match ? match.slice(1).map(Number) : null;
};
const compareVersions = (left, right) => {
const a = versionParts(left);
const b = versionParts(right);
if (!a || !b) return 0;
for (let index = 0; index < 3; index += 1) {
if (a[index] !== b[index]) return a[index] > b[index] ? 1 : -1;
}
return 0;
};
const openUrl = async (url) => {
if (globalThis.__VNT_WEB_ACCESS__?.openUrl) {
await globalThis.__VNT_WEB_ACCESS__.openUrl(url);
} else {
window.open(url, "_blank", "noopener,noreferrer");
}
};
const checkGithubRelease = async () => {
const response = await fetch(RELEASES_API, {
headers: { Accept: "application/vnd.github+json" },
cache: "no-store",
});
if (!response.ok) throw new Error(`GitHub 返回 ${response.status}`);
const releases = (await response.json()).filter(
(release) => !release.draft && !release.prerelease && versionParts(release.tag_name),
);
releases.sort((a, b) => compareVersions(b.tag_name, a.tag_name));
const latest = releases[0];
if (!latest) throw new Error("没有找到可用的发布版本");
return {
version: latest.tag_name.replace(/^v/, ""),
body: latest.body || "",
url: latest.html_url || RELEASES_URL,
};
};
const checkUpdate = async () => {
checking.value = true;
resultKind.value = "";
message.value = "";
updateInfo.value = null;
try {
if (isDesktop) {
let update;
try {
update = await globalThis.__VNT_UPDATER__?.check();
} catch {
const latest = await checkGithubRelease();
if (compareVersions(latest.version, currentVersion.value) <= 0) {
resultKind.value = "latest";
message.value = "当前已是最新版本";
return;
}
updateInfo.value = { ...latest, manualOnly: true };
resultKind.value = "update";
message.value = `发现新版本 v${latest.version},该版本暂未提供自动更新包。`;
return;
}
if (!update) {
resultKind.value = "latest";
message.value = "当前已是最新版本";
return;
}
updateInfo.value = { ...update, url: RELEASES_URL };
resultKind.value = "update";
message.value = `发现新版本 v${update.version},可以直接下载并更新。`;
return;
}
runtime.value ||= await getRuntime();
const latest = await checkGithubRelease();
if (compareVersions(latest.version, currentVersion.value) <= 0) {
resultKind.value = "latest";
message.value = "当前已是最新版本";
return;
}
updateInfo.value = latest;
resultKind.value = "update";
message.value = runtime.value === "desktop_web"
? `发现新版本 v${latest.version},请回到 VNT Desktop 的“关于”页面完成更新。`
: `发现新版本 v${latest.version},请下载新版本并替换当前 vnt2_web 程序。`;
} catch (error) {
resultKind.value = "error";
message.value = `检查更新失败:${error?.message || error}`;
} finally {
checking.value = false;
}
};
const downloadAndInstall = async () => {
installing.value = true;
downloaded.value = 0;
contentLength.value = 0;
message.value = "正在准备下载更新…";
try {
await globalThis.__VNT_UPDATER__.downloadAndInstall((event) => {
downloaded.value = event.downloaded;
contentLength.value = event.contentLength;
message.value = event.event === "Finished" ? "下载完成,正在安装…" : "正在下载更新…";
});
} catch (error) {
resultKind.value = "error";
message.value = `更新失败:${error?.message || error}`;
installing.value = false;
}
};
onMounted(async () => {
if (!app.version) {
try {
app.version = await getVersion();
} catch {
// 顶栏的版本加载逻辑仍会继续重试。
}
}
if (!isDesktop) {
try {
runtime.value = await getRuntime();
} catch {
runtime.value = "standalone_web";
}
}
});
</script>
<template>
<div class="mx-auto max-w-3xl space-y-5">
<div>
<h1 class="page-title">关于</h1>
<p class="page-subtitle">VNT 客户端信息与软件更新</p>
</div>
<section class="card flex items-center gap-4">
<img :src="vntIcon" alt="VNT" class="h-16 w-16 shrink-0 rounded-2xl" />
<div class="min-w-0">
<h2 class="text-lg font-bold text-slate-900 dark:text-white">VNT</h2>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">简单高效的异地组网与内网穿透工具</p>
<p class="mt-2 font-mono text-xs text-slate-400">当前版本 v{{ currentVersion }}</p>
</div>
</section>
<section class="card">
<h2 class="text-sm font-semibold text-slate-900 dark:text-white">开源项目</h2>
<p class="mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400">项目代码使用说明和问题反馈均托管在 GitHub</p>
<button class="btn-ghost mt-4" type="button" @click="openUrl(PROJECT_URL)">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M14 5h5v5m0-5-9 9M19 13v5a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" />
</svg>
github.com/vnt-dev/vnt
</button>
</section>
<section class="card">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-sm font-semibold text-slate-900 dark:text-white">软件更新</h2>
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
{{ isDesktop ? "检查并安装 VNT Desktop 的最新版本。" : "检查 GitHub 上发布的最新版本。" }}
</p>
</div>
<button class="btn-primary" type="button" :disabled="checking || installing" @click="checkUpdate">
{{ checking ? "正在检查" : "检查更新" }}
</button>
</div>
<div
v-if="message"
class="mt-5 rounded-lg border px-4 py-3 text-sm"
:class="resultKind === 'error'
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300'
: resultKind === 'update'
? 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-900 dark:bg-indigo-950/40 dark:text-indigo-300'
: 'border-slate-200 bg-slate-50 text-slate-600 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300'"
>
{{ message }}
</div>
<div v-if="installing && contentLength" class="mt-4">
<div class="mb-1.5 flex justify-between text-xs text-slate-400">
<span>下载进度</span>
<span>{{ progress }}%</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
<div class="h-full rounded-full bg-indigo-600 transition-[width] dark:bg-indigo-500" :style="{ width: `${progress}%` }"></div>
</div>
</div>
<div v-if="resultKind === 'update'" class="mt-4 flex flex-wrap gap-2">
<button v-if="isDesktop && !updateInfo?.manualOnly" class="btn-primary" type="button" :disabled="installing" @click="downloadAndInstall">
{{ installing ? "正在更新" : "下载并更新" }}
</button>
<button v-else-if="updateInfo?.manualOnly || runtime === 'standalone_web'" class="btn-ghost" type="button" @click="openUrl(updateInfo?.url || RELEASES_URL)">查看发布版本</button>
</div>
<p v-if="isDesktop" class="mt-4 text-xs leading-5 text-slate-400">安装更新时桌面客户端可能自动退出完成后将重新启动</p>
</section>
</div>
</template>