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
@@ -0,0 +1,218 @@
import { execFileSync } from "node:child_process";
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
const START_MARKER = "<!-- vnt-downloads:start -->";
const END_MARKER = "<!-- vnt-downloads:end -->";
const TOOL_TARGETS = [
["Windows", "x86_64", "x86_64-pc-windows-msvc"],
["Windows", "x86", "i686-pc-windows-msvc"],
["Windows", "ARM64", "aarch64-pc-windows-msvc"],
["Linux", "x86_64", "x86_64-unknown-linux-musl"],
["Linux", "ARM64", "aarch64-unknown-linux-musl"],
["Linux", "ARMv7 hard-float", "armv7-unknown-linux-musleabihf"],
["Linux", "ARMv7 soft-float", "armv7-unknown-linux-musleabi"],
["Linux", "ARM hard-float", "arm-unknown-linux-musleabihf"],
["Linux", "ARM soft-float", "arm-unknown-linux-musleabi"],
["Linux", "MIPS little-endian", "mipsel-unknown-linux-musl"],
["Linux", "MIPS big-endian", "mips-unknown-linux-musl"],
["macOS", "Apple Silicon", "aarch64-apple-darwin"],
["macOS", "Intel", "x86_64-apple-darwin"],
["FreeBSD 13.2", "x86_64", "x86_64-unknown-freebsd"],
];
const DOWNLOADS = [
{
product: "VNT Desktop",
platform: "Windows",
architecture: "x86_64",
format: "EXE 安装包",
filename: ({ version }) => `VNT.Desktop_${version}_windows_x64-setup.exe`,
},
{
product: "VNT Desktop",
platform: "Windows",
architecture: "x86_64",
format: "MSI 安装包",
filename: ({ version }) => `VNT.Desktop_${version}_windows_x64.msi`,
},
...TOOL_TARGETS.map(([platform, architecture, target]) => ({
product: "VNT 工具包",
platform,
architecture,
format: "ZIP",
filename: ({ tag }) => `vnt2-${target}-${tag}.zip`,
})),
];
export function buildDownloadSection({ version, tag, releaseUrl, assetNames }) {
const rows = DOWNLOADS.flatMap((download) => {
const filename = download.filename({ version, tag });
if (!assetNames.has(filename)) return [];
const url = `${releaseUrl}/${encodeURIComponent(filename)}`;
return [
`| ${download.product} | ${download.platform} | ${download.architecture} | [${download.format}](${url}) |`,
];
});
if (rows.length === 0) return undefined;
return [
START_MARKER,
"## 下载",
"",
"> VNT 工具包包含 `vnt2_web`、`vnt2_cli` 和 `vnt2_ctrl`。",
"",
"| 产品 | 平台 | 架构 | 下载 |",
"|---|---|---|---|",
...rows,
END_MARKER,
].join("\n");
}
export function mergeDownloadSection(notes, section) {
const start = notes.indexOf(START_MARKER);
if (start === -1) {
const existing = notes.trimEnd();
return existing.length === 0 ? `${section}\n` : `${existing}\n\n${section}\n`;
}
const end = notes.indexOf(END_MARKER, start);
if (end === -1) {
throw new Error("release notes contain an incomplete download section");
}
return `${notes.slice(0, start)}${section}${notes.slice(end + END_MARKER.length)}`;
}
function releaseAssetId(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return undefined;
}
if (parsed.hostname !== "api.github.com") return undefined;
return parsed.pathname.match(/\/releases\/assets\/(\d+)$/)?.[1];
}
export function normalizeUpdaterManifest(manifest, assets) {
const downloadUrls = new Map(
assets.map((asset) => [String(asset.id), asset.browser_download_url]),
);
let changed = false;
const platforms = Object.fromEntries(
Object.entries(manifest.platforms || {}).map(([target, platform]) => {
const assetId = releaseAssetId(platform.url);
if (!assetId) return [target, platform];
const downloadUrl = downloadUrls.get(assetId);
if (!downloadUrl) {
throw new Error(
`updater platform ${target} references unknown release asset ${assetId}`,
);
}
changed = true;
return [target, { ...platform, url: downloadUrl }];
}),
);
return {
changed,
manifest: changed ? { ...manifest, platforms } : manifest,
};
}
function normalizeReleaseUpdaterManifest({ repository, tag, release }) {
const latestAsset = release.assets.find((asset) => asset.name === "latest.json");
if (!latestAsset) throw new Error(`release ${tag} does not contain latest.json`);
const manifest = JSON.parse(
execFileSync(
"gh",
[
"api",
"-H",
"Accept: application/octet-stream",
`repos/${repository}/releases/assets/${latestAsset.id}`,
],
{ encoding: "utf8", env: process.env, windowsHide: true },
),
);
const normalized = normalizeUpdaterManifest(manifest, release.assets);
if (!normalized.changed) return;
const manifestDir = mkdtempSync(join(tmpdir(), "vnt-updater-manifest-"));
const manifestPath = join(manifestDir, "latest.json");
try {
writeFileSync(manifestPath, `${JSON.stringify(normalized.manifest, null, 2)}\n`);
execFileSync(
"gh",
["release", "upload", tag, manifestPath, "--repo", repository, "--clobber"],
{ stdio: "inherit", env: process.env, windowsHide: true },
);
} finally {
rmSync(manifestDir, { recursive: true, force: true });
}
}
function requiredEnv(name) {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return value;
}
function main() {
const repository = requiredEnv("GITHUB_REPOSITORY");
const tag = requiredEnv("GITHUB_REF_NAME");
const serverUrl = requiredEnv("GITHUB_SERVER_URL").replace(/\/$/, "");
const workspace = process.env.GITHUB_WORKSPACE || process.cwd();
const configPath = resolve(workspace, "vnt-desktop/src-tauri/tauri.conf.json");
const version = JSON.parse(readFileSync(configPath, "utf8")).version;
const release = JSON.parse(
execFileSync(
"gh",
["api", `repos/${repository}/releases/tags/${tag}`],
{ encoding: "utf8", env: process.env, windowsHide: true },
),
);
normalizeReleaseUpdaterManifest({ repository, tag, release });
const assetNames = new Set(release.assets.map((asset) => asset.name));
const releaseUrl = `${serverUrl}/${repository}/releases/download/${tag}`;
const section = buildDownloadSection({ version, tag, releaseUrl, assetNames });
if (!section) throw new Error(`release ${tag} does not contain recognized assets`);
const notes = release.body || "";
const updatedNotes = mergeDownloadSection(notes, section);
if (updatedNotes === notes) return;
const notesFile = join(
process.env.RUNNER_TEMP || tmpdir(),
`vnt-release-notes-${process.pid}.md`,
);
try {
writeFileSync(notesFile, updatedNotes);
execFileSync(
"gh",
["release", "edit", tag, "--repo", repository, "--notes-file", notesFile],
{ stdio: "inherit", env: process.env, windowsHide: true },
);
} finally {
if (existsSync(notesFile)) unlinkSync(notesFile);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
main();
}
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildDownloadSection,
mergeDownloadSection,
normalizeUpdaterManifest,
} from "./update-release-downloads.mjs";
test("builds links for desktop installers and target tool bundles", () => {
const section = buildDownloadSection({
version: "2.0.0",
tag: "v2.0.0",
releaseUrl: "https://github.com/vnt-dev/vnt/releases/download/v2.0.0",
assetNames: new Set([
"VNT.Desktop_2.0.0_windows_x64-setup.exe",
"vnt2-x86_64-unknown-linux-musl-v2.0.0.zip",
]),
});
assert.match(section, /VNT\.Desktop_2\.0\.0_windows_x64-setup\.exe/);
assert.match(section, /vnt2-x86_64-unknown-linux-musl-v2\.0\.0\.zip/);
assert.doesNotMatch(section, /VNT\.Desktop_2\.0\.0_windows_x64\.msi/);
});
test("replaces an existing release download section", () => {
const oldSection = [
"<!-- vnt-downloads:start -->",
"old links",
"<!-- vnt-downloads:end -->",
].join("\n");
const merged = mergeDownloadSection(`Release notes\n\n${oldSection}\n`, "new links");
assert.equal(merged, "Release notes\n\nnew links\n");
});
test("replaces GitHub API updater URLs with public download URLs", () => {
const manifest = {
version: "2.0.0",
platforms: {
"windows-x86_64": {
signature: "windows-signature",
url: "https://api.github.com/repos/vnt-dev/vnt/releases/assets/101",
},
},
};
const assets = [
{
id: 101,
browser_download_url:
"https://github.com/vnt-dev/vnt/releases/download/v2.0.0/VNT.exe",
},
];
const normalized = normalizeUpdaterManifest(manifest, assets);
assert.equal(normalized.changed, true);
assert.equal(
normalized.manifest.platforms["windows-x86_64"].url,
assets[0].browser_download_url,
);
assert.equal(
normalized.manifest.platforms["windows-x86_64"].signature,
"windows-signature",
);
});
test("rejects updater URLs for unknown release assets", () => {
assert.throws(
() =>
normalizeUpdaterManifest(
{
platforms: {
"windows-x86_64": {
signature: "signature",
url: "https://api.github.com/repos/vnt-dev/vnt/releases/assets/999",
},
},
},
[],
),
/unknown release asset 999/,
);
});
+82
View File
@@ -0,0 +1,82 @@
name: PR Checks
on:
pull_request:
permissions:
contents: read
concurrency:
group: pr-checks-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
frontend:
name: Frontend checks
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
version: 10.34.5
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Build shared Web frontend
run: pnpm build:web
- name: Build Tauri frontend
run: pnpm build:desktop-ui
- name: Test release metadata scripts
run: node --test .github/scripts/*.test.mjs
rust:
name: ${{ matrix.name }} Rust checks
strategy:
fail-fast: false
matrix:
include:
- name: Windows
os: windows-latest
- name: Linux
os: ubuntu-22.04
- name: macOS
os: macos-latest
runs-on: ${{ matrix.os }}
steps:
- name: Check out repository
uses: actions/checkout@v5
- name: Install Linux dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Check workspace
run: cargo check --workspace --all-targets
- name: Run Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Run tests
run: cargo test --workspace --all-targets
+96 -3
View File
@@ -1,4 +1,4 @@
name: Rust
name: Release
on:
push:
@@ -17,8 +17,36 @@ permissions:
packages: write
jobs:
build-web:
name: Build Web frontend
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
version: 10.34.5
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Build shared Web frontend
run: pnpm build:web
- name: Upload Web static files
uses: actions/upload-artifact@v4
with:
name: vnt-web-static
path: vnt-web/static
retention-days: 1
build:
name: Build ${{ matrix.TARGET }}
needs: build-web
strategy:
fail-fast: false
matrix:
@@ -60,7 +88,14 @@ jobs:
OS: ${{ matrix.OS }}
FEATURES: ${{ matrix.FEATURES }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Replace Web static files with current frontend build
run: rm -rf vnt-web/static && mkdir -p vnt-web/static
- name: Download Web static files
uses: actions/download-artifact@v4
with:
name: vnt-web-static
path: vnt-web/static
- name: Init submodules
run: git submodule update --init --recursive --remote && git submodule status
- name: Cargo cache
@@ -279,4 +314,62 @@ jobs:
file: ./artifacts/*/*.zip
tag: ${{ github.ref }}
overwrite: true
file_glob: true
file_glob: true
desktop-windows:
name: Build and publish VNT Desktop for Windows
if: startsWith(github.ref, 'refs/tags/')
needs: deploy
runs-on: windows-latest
steps:
- name: Check out repository
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
version: 10.34.5
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Verify tag matches desktop version
shell: pwsh
run: |
$config = Get-Content vnt-desktop/src-tauri/tauri.conf.json | ConvertFrom-Json
$tagVersion = "${{ github.ref_name }}" -replace '^v', ''
if ($tagVersion -ne $config.version) {
throw "Tag ${{ github.ref_name }} does not match desktop version $($config.version)"
}
- name: Build and publish Windows desktop installers
uses: tauri-apps/tauri-action@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
projectPath: vnt-desktop
tagName: ${{ github.ref_name }}
releaseName: VNT ${{ github.ref_name }}
releaseDraft: false
prerelease: false
releaseAssetNamePattern: 'VNT.Desktop_[version]_windows_[arch][setup][ext]'
args: --bundles nsis,msi
release-notes:
name: Add download links to release notes
if: startsWith(github.ref, 'refs/tags/')
needs: desktop-windows
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v5
- name: Update updater metadata and release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node .github/scripts/update-release-downloads.mjs
Generated
+271 -9
View File
@@ -108,6 +108,15 @@ version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arc-swap"
version = "1.8.1"
@@ -1110,6 +1119,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -1456,6 +1476,16 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -2129,6 +2159,21 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -2471,19 +2516,68 @@ dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys",
"jni-sys 0.3.0",
"log",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.18",
"walkdir",
"windows-link 0.2.1",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.114",
]
[[package]]
name = "jni-sys"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.114",
]
[[package]]
name = "js-sys"
version = "0.3.85"
@@ -2783,6 +2877,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2844,7 +2944,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags 2.10.0",
"jni-sys",
"jni-sys 0.3.0",
"log",
"ndk-sys",
"num_enum",
@@ -2858,7 +2958,7 @@ version = "0.6.0+11769913"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
dependencies = [
"jni-sys",
"jni-sys 0.3.0",
]
[[package]]
@@ -3197,6 +3297,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.10.0",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -3212,6 +3313,18 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.10.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -3332,6 +3445,20 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -4141,15 +4268,20 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http 0.6.11",
@@ -4324,6 +4456,33 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.9"
@@ -4749,6 +4908,22 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.3"
@@ -5022,7 +5197,7 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"jni 0.21.1",
"libc",
"log",
"ndk",
@@ -5055,6 +5230,17 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -5078,7 +5264,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"jni",
"jni 0.21.1",
"libc",
"log",
"mime",
@@ -5212,6 +5398,16 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
dependencies = [
"tauri",
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.3"
@@ -5228,6 +5424,39 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]]
name = "tauri-runtime"
version = "2.11.3"
@@ -5238,7 +5467,7 @@ dependencies = [
"dpi",
"gtk",
"http",
"jni",
"jni 0.21.1",
"objc2",
"objc2-ui-kit",
"objc2-web-kit",
@@ -5261,7 +5490,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
dependencies = [
"gtk",
"http",
"jni",
"jni 0.21.1",
"log",
"objc2",
"objc2-app-kit",
@@ -6137,7 +6366,9 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tokio",
"tokio-util",
"toml 0.9.11+spec-1.1.0",
@@ -6170,7 +6401,7 @@ dependencies = [
"anyhow",
"hostname",
"ipnet",
"jni",
"jni 0.21.1",
"lazy_static",
"log",
"parking_lot 0.12.5",
@@ -6431,6 +6662,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -7051,7 +7291,7 @@ dependencies = [
"gtk",
"http",
"javascriptcore-rs",
"jni",
"jni 0.21.1",
"libc",
"ndk",
"objc2",
@@ -7116,6 +7356,16 @@ dependencies = [
"time",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yasna"
version = "0.5.2"
@@ -7298,6 +7548,18 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.13.0",
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.19"
+20
View File
@@ -37,6 +37,12 @@ importers:
'@tauri-apps/api':
specifier: ^2.8.0
version: 2.11.1
'@tauri-apps/plugin-process':
specifier: ^2.3.1
version: 2.3.1
'@tauri-apps/plugin-updater':
specifier: ^2.10.1
version: 2.10.1
pinia:
specifier: 'catalog:'
version: 3.0.4([email protected])
@@ -600,6 +606,12 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
'@types/[email protected]':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
@@ -1204,6 +1216,14 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.11.4
'@tauri-apps/cli-win32-x64-msvc': 2.11.4
'@tauri-apps/[email protected]':
dependencies:
'@tauri-apps/api': 2.11.1
'@tauri-apps/[email protected]':
dependencies:
'@tauri-apps/api': 2.11.1
'@types/[email protected]': {}
'@vitejs/[email protected]([email protected]([email protected])([email protected]))([email protected])':
-1
View File
@@ -489,7 +489,6 @@ server = ["quic://1.2.3.4:29872"]
}
}
#[cfg(test)]
mod tests {
use super::*;
+5 -1
View File
@@ -48,7 +48,11 @@ mod tests {
use super::*;
fn temp_dll_path(tag: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("vnt_wintun_test_{}_{}.dll", std::process::id(), tag))
std::env::temp_dir().join(format!(
"vnt_wintun_test_{}_{}.dll",
std::process::id(),
tag
))
}
#[test]
+3 -3
View File
@@ -1,6 +1,6 @@
fn main() {
let protoc_path = protoc_bin_vendored::protoc_bin_path()
.expect("failed to find vendored protoc");
let protoc_path =
protoc_bin_vendored::protoc_bin_path().expect("failed to find vendored protoc");
let mut config = prost_build::Config::new();
@@ -19,4 +19,4 @@ fn main() {
&["proto"],
)
.unwrap();
}
}
@@ -100,7 +100,6 @@ impl QuicInnerInboundReceiver {
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -350,7 +350,6 @@ fn spawn_dest_sender(
});
}
#[cfg(test)]
mod tests {
use super::*;
+15 -7
View File
@@ -412,11 +412,7 @@ mod tests {
build_packet(fec, 0x81, 0)
}
fn build_packet(
fec: FecPacket,
type_byte: u8,
flags_byte: u8,
) -> NetPacket<TransmissionBytes> {
fn build_packet(fec: FecPacket, type_byte: u8, flags_byte: u8) -> NetPacket<TransmissionBytes> {
let fec_payload = fec.encode_to_vec();
let buffer = TransmissionBytes::zeroed(HEAD_LENGTH + fec_payload.len());
let mut pkt = NetPacket::new(buffer).unwrap();
@@ -467,13 +463,25 @@ mod tests {
// 收到 pkt0、pkt1pkt2 丢失,随后收到校验包
assert!(
decoder
.receive(build_data_packet(group_id, 0, type_bytes[0], 0, payloads[0]))
.receive(build_data_packet(
group_id,
0,
type_bytes[0],
0,
payloads[0]
))
.unwrap()
.is_some()
);
assert!(
decoder
.receive(build_data_packet(group_id, 1, type_bytes[1], 0, payloads[1]))
.receive(build_data_packet(
group_id,
1,
type_bytes[1],
0,
payloads[1]
))
.unwrap()
.is_some()
);
+6 -3
View File
@@ -194,7 +194,6 @@ async fn inner_icmp_socket_recv(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -221,8 +220,12 @@ mod tests {
let now = Instant::now();
let mut map: IcmpNatMap = HashMap::new();
let (k_fresh, v_fresh) = entry("8.8.8.8", 1, 1, now);
let (k_stale, v_stale) =
entry("1.1.1.1", 2, 2, now - ICMP_NAT_TIMEOUT - Duration::from_secs(1));
let (k_stale, v_stale) = entry(
"1.1.1.1",
2,
2,
now - ICMP_NAT_TIMEOUT - Duration::from_secs(1),
);
map.insert(k_fresh, v_fresh);
map.insert(k_stale, v_stale);
@@ -98,7 +98,6 @@ async fn stream_copy(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -174,7 +174,6 @@ async fn udp_mapping_handle(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
+1 -2
View File
@@ -349,8 +349,7 @@ mod tests {
/// 接收方以 metric = max_ttl - curr_ttl 计算路由距离。
#[test]
fn relay_reply_survives_one_hop() {
let mut packet =
NetPacket::new(BytesMut::from(&[0u8; HEAD_LENGTH][..])).unwrap();
let mut packet = NetPacket::new(BytesMut::from(&[0u8; HEAD_LENGTH][..])).unwrap();
packet.set_msg_type(MsgType::RelayProbeReply);
// 目标方回复时 TTL 必须允许一次中继
packet.set_ttl(2);
+6 -1
View File
@@ -86,7 +86,12 @@ impl DeviceIOManager {
let device = Arc::new(create_tun(device_config)?);
let receiver = receiver.take().unwrap();
let enhanced_outbound = enhanced_outbound.take().unwrap();
let task = create(&self.task_group, device, receiver.receiver, enhanced_outbound);
let task = create(
&self.task_group,
device,
receiver.receiver,
enhanced_outbound,
);
self.device.lock().await.0.replace(task);
Ok(())
}
+2 -4
View File
@@ -281,10 +281,8 @@ impl P2pInboundHandler {
}
MsgType::RelayProbeReply => {
let metric = ctx.max_ttl - ctx.ttl;
self.route_table.add_route(
ctx.src_ip,
Route::from_default_rt(route_key, metric),
);
self.route_table
.add_route(ctx.src_ip, Route::from_default_rt(route_key, metric));
}
_ => {}
}
@@ -321,7 +321,6 @@ impl RouteTableInner {
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -437,7 +437,6 @@ fn default_tcp_stun() -> Vec<String> {
]
}
#[cfg(test)]
mod tests {
use super::*;
@@ -452,7 +451,10 @@ mod tests {
let scanned = vec![ip("192.168.1.2"), ip("10.0.0.3")];
let (primary, list) = select_local_ipv4(Some(ip("172.16.0.2")), &scanned).unwrap();
assert_eq!(primary, ip("172.16.0.2"));
assert_eq!(list, vec![ip("172.16.0.2"), ip("192.168.1.2"), ip("10.0.0.3")]);
assert_eq!(
list,
vec![ip("172.16.0.2"), ip("192.168.1.2"), ip("10.0.0.3")]
);
}
/// 探测失败/0.0.0.0 时:回退到扫描结果首个地址
@@ -463,8 +465,7 @@ mod tests {
assert_eq!(primary, ip("192.168.1.2"));
assert_eq!(list, scanned);
let (primary, _) =
select_local_ipv4(Some(Ipv4Addr::UNSPECIFIED), &scanned).unwrap();
let (primary, _) = select_local_ipv4(Some(Ipv4Addr::UNSPECIFIED), &scanned).unwrap();
assert_eq!(primary, ip("192.168.1.2"));
}
@@ -160,10 +160,7 @@ pub async fn ping_all(
}
}
}
pub async fn route_timeout_task(
route_table: RouteTable,
packet_loss_stats: PacketLossStats,
) {
pub async fn route_timeout_task(route_table: RouteTable, packet_loss_stats: PacketLossStats) {
loop {
tokio::time::sleep(Duration::from_secs(10)).await;
let expired_time = std::time::Instant::now() - Duration::from_secs(10);
@@ -227,10 +224,7 @@ pub async fn relay_probe_task(
if non_direct_targets.len() <= 10 {
non_direct_targets
} else {
non_direct_targets
.sample(&mut rng, 10)
.copied()
.collect()
non_direct_targets.sample(&mut rng, 10).copied().collect()
}
};
@@ -187,7 +187,6 @@ impl ConnectConfig {
}
}
#[cfg(test)]
mod tests {
use super::*;
-1
View File
@@ -278,7 +278,6 @@ pub async fn aaaa_dns(
Ok(rs)
}
#[cfg(test)]
mod tests {
use super::*;
+3 -6
View File
@@ -321,11 +321,8 @@ mod tests {
);
let _ = exit_tx.send(());
tokio::time::timeout(
std::time::Duration::from_secs(2),
group.wait_all_stopped(),
)
.await
.expect("wait_all_stopped should return after observer exits");
tokio::time::timeout(std::time::Duration::from_secs(2), group.wait_all_stopped())
.await
.expect("wait_all_stopped should return after observer exits");
}
}
+15
View File
@@ -16,6 +16,7 @@
- 桌面工作台通过 Tauri IPC 直接调用进程内 `vnt-core`,不监听本地 API 端口
- 可选 Web 访问:启停、端口、本机/局域网监听范围、访问令牌、打开浏览器
- Web API 强制使用 Bearer 令牌鉴权,令牌可在桌面端重新生成
- 关于页提供 GitHub 开源地址与更新检查;桌面端可通过 Tauri Updater 下载并安装更新
桌面数据存放在系统应用数据目录的 `com.vnt.desktop` 下,包括 `vnt_config`、自启动记录、`web_access.toml`、日志及 Windows 下的 `wintun.dll`
@@ -45,11 +46,25 @@ cargo check -p vnt-desktop
## 打包
```powershell
$env:TAURI_SIGNING_PRIVATE_KEY=Get-Content -Raw -LiteralPath "$HOME\.tauri\vnt-desktop.key"
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD=""
pnpm build:desktop
```
Windows 使用虚拟网卡模式时,可能需要以管理员身份运行。
### 发布桌面更新
Tauri Updater 会从以下 GitHub Release 资源检查更新:
```text
https://github.com/vnt-dev/vnt/releases/latest/download/latest.json
```
更新包必须使用与 `tauri.conf.json` 内公钥匹配的私钥签名。当前密钥默认保存在 `~/.tauri/vnt-desktop.key`,请妥善备份并在 CI 中将私钥内容配置为 `TAURI_SIGNING_PRIVATE_KEY`;私钥不得提交到仓库。发布时需将 Tauri 生成的安装包、`.sig` 文件以及对应的 `latest.json` 一并上传到 GitHub Release。
仓库的 Release 流水线会在推送版本标签后自动构建 Windows 桌面安装包及 updater 元数据。请在 GitHub Actions Secrets 中配置 `TAURI_SIGNING_PRIVATE_KEY`;如果私钥设置了密码,同时配置 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`。流水线还会将桌面安装包和各平台 VNT 工具包的直接下载链接写入 Release Notes。
## 应用图标
图标母版保存在 `vnt-desktop/assets/vnt-icon-master.png`。需要重新生成各平台图标时,在仓库根目录执行:
+2
View File
@@ -10,6 +10,8 @@
},
"dependencies": {
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"pinia": "catalog:",
"vue": "catalog:",
"vue-router": "catalog:"
+2
View File
@@ -24,3 +24,5 @@ serde.workspace = true
serde_json.workspace = true
toml.workspace = true
anyhow.workspace = true
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
@@ -11,6 +11,8 @@
"core:window:allow-unmaximize",
"core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"opener:allow-open-url"
"opener:allow-open-url",
"process:allow-restart",
"updater:default"
]
}
+3 -1
View File
@@ -245,6 +245,8 @@ pub fn run() {
open_web_url
])
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
show_main_window(app);
}))
@@ -260,7 +262,7 @@ pub fn run() {
let config_path = data_dir.join(WEB_ACCESS_CONFIG);
let config = load_web_config(&config_path);
save_web_config(&config_path, &config)?;
let service = tauri::async_runtime::block_on(VntService::new(None))?;
let service = tauri::async_runtime::block_on(VntService::new_desktop(None))?;
let mut web = WebRuntime {
config,
cancellation: None,
+13 -1
View File
@@ -25,11 +25,12 @@
}
],
"security": {
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: data:; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*"
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: data:; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* https://api.github.com"
}
},
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"targets": "all",
"icon": [
"icons/32x32.png",
@@ -45,5 +46,16 @@
"wix": { "language": "zh-CN" },
"nsis": { "displayLanguageSelector": false }
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEE3MTQzQjc3NTJGQjBGRTEKUldUaEQvdFNkenNVcHdwd0pRNEhDV1lTdkMzTDZLa1V3WGllMDRoMmxBNDdobi8wclN1dmo3cFoK",
"endpoints": [
"https://github.com/vnt-dev/vnt/releases/latest/download/latest.json"
],
"windows": {
"installMode": "passive"
}
}
}
}
+30
View File
@@ -1,4 +1,8 @@
import { invoke } from "@tauri-apps/api/core";
import { relaunch } from "@tauri-apps/plugin-process";
import { check } from "@tauri-apps/plugin-updater";
let pendingUpdate = null;
globalThis.__VNT_DESKTOP__ = true;
globalThis.__VNT_IPC_REQUEST__ = ({ method, path, body }) =>
@@ -9,6 +13,32 @@ globalThis.__VNT_WEB_ACCESS__ = {
generateToken: () => invoke("generate_web_token"),
openUrl: (url) => invoke("open_web_url", { url }),
};
globalThis.__VNT_UPDATER__ = {
check: async () => {
pendingUpdate = await check();
if (!pendingUpdate) return null;
return {
currentVersion: pendingUpdate.currentVersion,
version: pendingUpdate.version,
date: pendingUpdate.date || "",
body: pendingUpdate.body || "",
};
},
downloadAndInstall: async (onProgress) => {
if (!pendingUpdate) throw new Error("请先检查更新");
let downloaded = 0;
let contentLength = 0;
await pendingUpdate.downloadAndInstall((event) => {
if (event.event === "Started") {
contentLength = event.data.contentLength || 0;
} else if (event.event === "Progress") {
downloaded += event.data.chunkLength;
}
onProgress?.({ event: event.event, downloaded, contentLength });
});
await relaunch();
},
};
// Tauri 与 Web 端共用同一个 Vue 应用,这里只负责平台初始化。
await import("@shared/main.js");
+2 -2
View File
@@ -1,6 +1,6 @@
fn main() {
let protoc_path = protoc_bin_vendored::protoc_bin_path()
.expect("failed to find vendored protoc");
let protoc_path =
protoc_bin_vendored::protoc_bin_path().expect("failed to find vendored protoc");
let mut config = prost_build::Config::new();
+66 -16
View File
@@ -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
+2 -2
View File
@@ -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>
+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>