feat: add desktop updater and release automation
This commit is contained in:
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Generated
+20
@@ -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])':
|
||||
|
||||
@@ -489,7 +489,6 @@ server = ["quic://1.2.3.4:29872"]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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
@@ -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::*;
|
||||
|
||||
@@ -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、pkt1,pkt2 丢失,随后收到校验包
|
||||
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()
|
||||
);
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -278,7 +278,6 @@ pub async fn aaaa_dns(
|
||||
Ok(rs)
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`。需要重新生成各平台图标时,在仓库根目录执行:
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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