feat: add desktop updater and release automation
This commit is contained in:
+66
-16
@@ -120,8 +120,7 @@ impl HttpAppState {
|
||||
}
|
||||
inst.vnt.take();
|
||||
inst.status = VntStatus::Stopped;
|
||||
inst
|
||||
.start_logs
|
||||
inst.start_logs
|
||||
.push(format!("[{}] 启动中断", HttpAppState::timestamp()));
|
||||
}
|
||||
fn starting_to_running(&self, file_name: &str) {
|
||||
@@ -475,8 +474,34 @@ pub struct VntService {
|
||||
router: Router,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ServiceRuntime {
|
||||
StandaloneWeb,
|
||||
DesktopWeb,
|
||||
}
|
||||
|
||||
impl ServiceRuntime {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::StandaloneWeb => "standalone_web",
|
||||
Self::DesktopWeb => "desktop_web",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VntService {
|
||||
pub async fn new(start_config_file_name: Option<PathBuf>) -> anyhow::Result<Self> {
|
||||
Self::new_with_runtime(start_config_file_name, ServiceRuntime::StandaloneWeb).await
|
||||
}
|
||||
|
||||
pub async fn new_desktop(start_config_file_name: Option<PathBuf>) -> anyhow::Result<Self> {
|
||||
Self::new_with_runtime(start_config_file_name, ServiceRuntime::DesktopWeb).await
|
||||
}
|
||||
|
||||
async fn new_with_runtime(
|
||||
start_config_file_name: Option<PathBuf>,
|
||||
runtime: ServiceRuntime,
|
||||
) -> anyhow::Result<Self> {
|
||||
fs::create_dir_all(CONFIG_DIR)
|
||||
.await
|
||||
.context("Failed to create config directory")?;
|
||||
@@ -496,7 +521,7 @@ impl VntService {
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
router: api_router(state),
|
||||
router: api_router(state, runtime),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -547,9 +572,12 @@ pub fn generate_access_token() -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
fn api_router(state: HttpAppState) -> Router {
|
||||
fn api_router(state: HttpAppState, runtime: ServiceRuntime) -> Router {
|
||||
let get_runtime =
|
||||
move || async move { Json(ApiResponse::success(runtime.as_str().to_string())) };
|
||||
Router::new()
|
||||
.route("/api/version", get(get_version))
|
||||
.route("/api/runtime", get(get_runtime))
|
||||
.route("/api/info", get(get_info))
|
||||
.route("/api/peers", get(get_peers))
|
||||
.route("/api/routes", get(get_routes))
|
||||
@@ -594,10 +622,7 @@ fn http_router(api: Router, token: String) -> Router {
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
Router::new()
|
||||
.merge(api.layer(middleware::from_fn_with_state(
|
||||
token,
|
||||
token_auth_middleware,
|
||||
)))
|
||||
.merge(api.layer(middleware::from_fn_with_state(token, token_auth_middleware)))
|
||||
.fallback(static_handler)
|
||||
.layer(cors)
|
||||
.layer(middleware::from_fn(logging_middleware))
|
||||
@@ -610,7 +635,9 @@ pub async fn run_http_server(
|
||||
) -> anyhow::Result<()> {
|
||||
let service = VntService::new(start_config_file_name).await?;
|
||||
let cancellation = CancellationToken::new();
|
||||
let handle = service.start_http(addr, token, cancellation.clone()).await?;
|
||||
let handle = service
|
||||
.start_http(addr, token, cancellation.clone())
|
||||
.await?;
|
||||
shutdown_signal().await;
|
||||
cancellation.cancel();
|
||||
handle.await??;
|
||||
@@ -644,7 +671,11 @@ async fn determine_auto_start_files(
|
||||
};
|
||||
|
||||
for p in paths {
|
||||
let Some(file_name) = p.file_name().and_then(|s| s.to_str()).map(|s| s.to_string()) else {
|
||||
let Some(file_name) = p
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if result.iter().any(|(name, _)| *name == file_name) {
|
||||
@@ -830,9 +861,7 @@ async fn start_vnt_internal(
|
||||
let running: Vec<&StartConfig> = inner
|
||||
.instances
|
||||
.iter()
|
||||
.filter(|(name, inst)| {
|
||||
name.as_str() != file_name && inst.status != VntStatus::Stopped
|
||||
})
|
||||
.filter(|(name, inst)| name.as_str() != file_name && inst.status != VntStatus::Stopped)
|
||||
.filter_map(|(_, inst)| {
|
||||
inst.vnt
|
||||
.as_ref()
|
||||
@@ -1592,17 +1621,38 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_ipc_request_uses_in_process_router() {
|
||||
let service = VntService {
|
||||
router: api_router(new_test_state()),
|
||||
router: api_router(new_test_state(), ServiceRuntime::StandaloneWeb),
|
||||
};
|
||||
let response = service.request("GET", "/api/version", None).await.unwrap();
|
||||
assert_eq!(response["code"], 0);
|
||||
assert!(response["data"].as_str().is_some_and(|value| !value.is_empty()));
|
||||
assert!(
|
||||
response["data"]
|
||||
.as_str()
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
);
|
||||
|
||||
let response = service.request("GET", "/api/runtime", None).await.unwrap();
|
||||
assert_eq!(response["code"], 0);
|
||||
assert_eq!(response["data"], "standalone_web");
|
||||
|
||||
let desktop_service = VntService {
|
||||
router: api_router(new_test_state(), ServiceRuntime::DesktopWeb),
|
||||
};
|
||||
let response = desktop_service
|
||||
.request("GET", "/api/runtime", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response["code"], 0);
|
||||
assert_eq!(response["data"], "desktop_web");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_http_api_requires_bearer_token() {
|
||||
let token = "test-token-with-enough-entropy".to_string();
|
||||
let app = http_router(api_router(new_test_state()), token.clone());
|
||||
let app = http_router(
|
||||
api_router(new_test_state(), ServiceRuntime::StandaloneWeb),
|
||||
token.clone(),
|
||||
);
|
||||
let unauthorized = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -19,8 +19,8 @@
|
||||
if (dark) document.documentElement.classList.add("dark");
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-BXMGH-t3.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CusMU-l9.css">
|
||||
<script type="module" crossorigin src="/assets/index-CU8fPWbk.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CqFhxcSz.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user