Compare commits

...

12 Commits

44 changed files with 867 additions and 381 deletions

View File

@@ -90,7 +90,7 @@ jobs:
### Windows (不再支持Win7)
#### 正常版本(推荐)
- [64位(常用)](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_x64-setup_windows.exe) | [ARM64(不常用)](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_arm64-setup.exe)
- [64位(常用)](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_x64-setup_windows.exe) | [ARM64(不常用)](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_arm64-setup_windows.exe)
#### 内置Webview2版(体积较大仅在企业版系统或无法安装webview2时使用)
- [64位](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_x64_fixed_webview2-setup.exe) | [ARM64](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_arm64_fixed_webview2-setup.exe)
@@ -493,6 +493,43 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-updater-manifests:
name: Publish Updater Manifests
runs-on: ubuntu-latest
needs:
[
update_tag,
autobuild-x86-windows-macos-linux,
autobuild-arm-linux,
autobuild-x86-arm-windows_webview2,
]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Install dependencies
run: pnpm i
- name: Publish updater manifests
run: pnpm updater
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Publish WebView2 updater manifests
run: pnpm updater-fixed-webview2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
notify-telegram:
name: Notify Telegram
runs-on: ubuntu-latest
@@ -502,6 +539,7 @@ jobs:
autobuild-x86-windows-macos-linux,
autobuild-arm-linux,
autobuild-x86-arm-windows_webview2,
publish-updater-manifests,
]
steps:
- name: Checkout repository
@@ -578,7 +616,7 @@ jobs:
- [64位](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_amd64_linux.deb) | [ARM64](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_arm64.deb) | [ARMv7](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_armhf.deb)
#### RPM包(Redhat系) 使用 dnf ./路径 安装
- [64位](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}-1.x86_64_linux.rpm) | [ARM64](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}-1.aarch64.rpm) | [ARMv7](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}-1.armhfp.rpm)
- [64位](${{ env.DOWNLOAD_URL }}/Clash.Verge-${{ env.VERSION }}-1.x86_64_linux.rpm) | [ARM64](${{ env.DOWNLOAD_URL }}/Clash.Verge-${{ env.VERSION }}-1.aarch64.rpm) | [ARMv7](${{ env.DOWNLOAD_URL }}/Clash.Verge-${{ env.VERSION }}-1.armhfp.rpm)
### FAQ
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)

View File

@@ -1,6 +1,6 @@
## v2.4.3
感谢 @Slinetrac, @oomeow 以及 @Lythrilla 的出色贡献
感谢 @Slinetrac, @oomeow, @Lythrilla, @Dragon1573 的出色贡献
### 🐞 修复问题
@@ -36,6 +36,7 @@
- 修复 macOS 从 Dock 栏退出轻量模式状态不同步
- 修复 Linux 系统主题切换不生效
- 修复 `允许自动更新` 字段使手动订阅刷新失效
- 修复轻量模式托盘状态不同步
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
@@ -54,6 +55,7 @@
- 托盘 `更多` 中新增 `关闭所有连接` 按钮
- 新增左侧菜单栏的排序功能(右键点击左侧菜单栏)
- 托盘 `打开目录` 中新增 `应用日志``内核日志`
- 支持更新通道切换 (Stable / Autobuild)
</details>
<details>

View File

@@ -84,7 +84,7 @@
"@tauri-apps/cli": "2.9.2",
"@types/js-yaml": "^4.0.9",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.9.2",
"@types/node": "^24.10.0",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"@vitejs/plugin-legacy": "^7.2.1",

48
pnpm-lock.yaml generated
View File

@@ -154,8 +154,8 @@ importers:
specifier: ^4.17.12
version: 4.17.12
'@types/node':
specifier: ^24.9.2
version: 24.9.2
specifier: ^24.10.0
version: 24.10.0
'@types/react':
specifier: 19.2.2
version: 19.2.2
@@ -164,10 +164,10 @@ importers:
version: 19.2.2(@types/react@19.2.2)
'@vitejs/plugin-legacy':
specifier: ^7.2.1
version: 7.2.1(terser@5.44.0)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1))
version: 7.2.1(terser@5.44.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1))
'@vitejs/plugin-react-swc':
specifier: ^4.2.0
version: 4.2.0(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1))
version: 4.2.0(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1))
adm-zip:
specifier: ^0.5.16
version: 0.5.16
@@ -248,16 +248,16 @@ importers:
version: 8.46.2(eslint@9.39.0(jiti@2.6.1))(typescript@5.9.3)
vite:
specifier: ^7.1.12
version: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1)
version: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1)
vite-plugin-monaco-editor-esm:
specifier: ^2.0.2
version: 2.0.2(monaco-editor@0.54.0)
vite-plugin-svgr:
specifier: ^4.5.0
version: 4.5.0(rollup@4.46.2)(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1))
version: 4.5.0(rollup@4.46.2)(typescript@5.9.3)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1))
vitest:
specifier: ^4.0.6
version: 4.0.6(@types/debug@4.1.12)(@types/node@24.9.2)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1)
version: 4.0.6(@types/debug@4.1.12)(@types/node@24.10.0)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1)
packages:
@@ -1821,8 +1821,8 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@24.9.2':
resolution: {integrity: sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==}
'@types/node@24.10.0':
resolution: {integrity: sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==}
'@types/parse-json@4.0.2':
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
@@ -5982,7 +5982,7 @@ snapshots:
'@types/ms@2.1.0': {}
'@types/node@24.9.2':
'@types/node@24.10.0':
dependencies:
undici-types: 7.16.0
@@ -6160,7 +6160,7 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
'@vitejs/plugin-legacy@7.2.1(terser@5.44.0)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1))':
'@vitejs/plugin-legacy@7.2.1(terser@5.44.0)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1))':
dependencies:
'@babel/core': 7.28.4
'@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.28.4)
@@ -6175,15 +6175,15 @@ snapshots:
regenerator-runtime: 0.14.1
systemjs: 6.15.1
terser: 5.44.0
vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1)
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-react-swc@4.2.0(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1))':
'@vitejs/plugin-react-swc@4.2.0(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.43
'@swc/core': 1.14.0
vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1)
transitivePeerDependencies:
- '@swc/helpers'
@@ -6196,13 +6196,13 @@ snapshots:
chai: 6.2.0
tinyrainbow: 3.0.3
'@vitest/mocker@4.0.6(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1))':
'@vitest/mocker@4.0.6(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1))':
dependencies:
'@vitest/spy': 4.0.6
estree-walker: 3.0.3
magic-string: 0.30.19
optionalDependencies:
vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1)
'@vitest/pretty-format@4.0.6':
dependencies:
@@ -8844,18 +8844,18 @@ snapshots:
dependencies:
monaco-editor: 0.54.0
vite-plugin-svgr@4.5.0(rollup@4.46.2)(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1)):
vite-plugin-svgr@4.5.0(rollup@4.46.2)(typescript@5.9.3)(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1)):
dependencies:
'@rollup/pluginutils': 5.2.0(rollup@4.46.2)
'@svgr/core': 8.1.0(typescript@5.9.3)
'@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3))
vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1)
transitivePeerDependencies:
- rollup
- supports-color
- typescript
vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1):
vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1):
dependencies:
esbuild: 0.25.4
fdir: 6.5.0(picomatch@4.0.3)
@@ -8864,17 +8864,17 @@ snapshots:
rollup: 4.46.2
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 24.9.2
'@types/node': 24.10.0
fsevents: 2.3.3
jiti: 2.6.1
sass: 1.93.3
terser: 5.44.0
yaml: 2.8.1
vitest@4.0.6(@types/debug@4.1.12)(@types/node@24.9.2)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1):
vitest@4.0.6(@types/debug@4.1.12)(@types/node@24.10.0)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1):
dependencies:
'@vitest/expect': 4.0.6
'@vitest/mocker': 4.0.6(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1))
'@vitest/mocker': 4.0.6(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1))
'@vitest/pretty-format': 4.0.6
'@vitest/runner': 4.0.6
'@vitest/snapshot': 4.0.6
@@ -8891,11 +8891,11 @@ snapshots:
tinyexec: 0.3.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(sass@1.93.3)(terser@5.44.0)(yaml@2.8.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 24.9.2
'@types/node': 24.10.0
transitivePeerDependencies:
- jiti
- less

View File

@@ -5,12 +5,12 @@
* pnpm release-version <version>
*
* <version> can be:
* - A full semver version (e.g., 1.2.3, v1.2.3, 1.2.3-beta, v1.2.3+build)
* - A full semver version (e.g., 1.2.3, v1.2.3, 1.2.3-beta, v1.2.3-rc.1)
* - A tag: "alpha", "beta", "rc", "autobuild", "autobuild-latest", or "deploytest"
* - "alpha", "beta", "rc": Appends the tag to the current base version (e.g., 1.2.3-beta)
* - "autobuild": Appends a timestamped autobuild tag (e.g., 1.2.3+autobuild.2406101530)
* - "autobuild-latest": Appends an autobuild tag with latest Tauri commit (e.g., 1.2.3+autobuild.0614.a1b2c3d)
* - "deploytest": Appends a timestamped deploytest tag (e.g., 1.2.3+deploytest.2406101530)
* - "autobuild": Appends a timestamped autobuild tag (e.g., 1.2.3-autobuild.0610.cc39b2.r2)
* - "autobuild-latest": Appends an autobuild tag with latest Tauri commit (e.g., 1.2.3-autobuild.0610.a1b2c3d.r2)
* - "deploytest": Appends a timestamped deploytest tag (e.g., 1.2.3-deploytest.0610.cc39b2.r2)
*
* Examples:
* pnpm release-version 1.2.3
@@ -30,10 +30,12 @@
*/
import { execSync } from "child_process";
import { program } from "commander";
import fs from "fs/promises";
import process from "node:process";
import path from "path";
import { program } from "commander";
/**
* 获取当前 git 短 commit hash
* @returns {string}
@@ -73,41 +75,91 @@ function getLatestTauriCommit() {
}
/**
* 生成短时间戳格式MMDD或带 commit格式MMDD.cc39b27
* 使用 Asia/Shanghai 时区
* @param {boolean} withCommit 是否带 commit
* @param {boolean} useTauriCommit 是否使用 Tauri 相关的 commit仅当 withCommit 为 true 时有效)
* 获取 Asia/Shanghai 时区的日期片段
* @returns {string}
*/
function generateShortTimestamp(withCommit = false, useTauriCommit = false) {
function getLocalDatePart() {
const now = new Date();
const formatter = new Intl.DateTimeFormat("en-CA", {
const dateFormatter = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
});
const dateParts = Object.fromEntries(
dateFormatter.formatToParts(now).map((part) => [part.type, part.value]),
);
const parts = formatter.formatToParts(now);
const month = parts.find((part) => part.type === "month").value;
const day = parts.find((part) => part.type === "day").value;
const month = dateParts.month ?? "00";
const day = dateParts.day ?? "00";
if (withCommit) {
const gitShort = useTauriCommit
? getLatestTauriCommit()
: getGitShortCommit();
return `${month}${day}.${gitShort}`;
}
return `${month}${day}`;
}
/**
* 获取 GitHub Actions 运行编号(若存在)
* @returns {string|null}
*/
function getRunIdentifier() {
const attempt = process.env.GITHUB_RUN_ATTEMPT;
if (attempt && /^[0-9]+$/.test(attempt)) {
const attemptNumber = Number.parseInt(attempt, 10);
if (!Number.isNaN(attemptNumber)) {
return `r${attemptNumber.toString(36)}`;
}
}
const runNumber = process.env.GITHUB_RUN_NUMBER;
if (runNumber && /^[0-9]+$/.test(runNumber)) {
const runNum = Number.parseInt(runNumber, 10);
if (!Number.isNaN(runNum)) {
return `r${runNum.toString(36)}`;
}
}
return null;
}
/**
* 生成用于自动构建类渠道的版本后缀
* @param {Object} options
* @param {boolean} [options.includeCommit=false]
* @param {"current"|"tauri"} [options.commitSource="current"]
* @param {boolean} [options.includeRun=true]
* @returns {string}
*/
function generateChannelSuffix({
includeCommit = false,
commitSource = "current",
includeRun = true,
} = {}) {
const segments = [];
const date = getLocalDatePart();
segments.push(date);
if (includeCommit) {
const commit =
commitSource === "tauri" ? getLatestTauriCommit() : getGitShortCommit();
segments.push(commit);
}
if (includeRun) {
const run = getRunIdentifier();
if (run) {
segments.push(run);
}
}
return segments.join(".");
}
/**
* 验证版本号格式
* @param {string} version
* @returns {boolean}
*/
function isValidVersion(version) {
return /^v?\d+\.\d+\.\d+(-(alpha|beta|rc)(\.\d+)?)?(\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*)?$/i.test(
return /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(
version,
);
}
@@ -122,13 +174,14 @@ function normalizeVersion(version) {
}
/**
* 提取基础版本号(去掉所有 -tag+build 部分
* 提取基础版本号(去掉所有 pre-release 和 build metadata
* @param {string} version
* @returns {string}
*/
function getBaseVersion(version) {
let base = version.replace(/-(alpha|beta|rc)(\.\d+)?/i, "");
base = base.replace(/\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*/g, "");
const cleaned = version.startsWith("v") ? version.slice(1) : version;
const withoutBuild = cleaned.split("+")[0];
const [base] = withoutBuild.split("-");
return base;
}
@@ -273,17 +326,23 @@ async function main(versionArg) {
const baseVersion = getBaseVersion(currentVersion);
if (versionArg.toLowerCase() === "autobuild") {
// 格式: 2.3.0+autobuild.1004.cc39b27
// 使用 Tauri 相关的最新 commit hash
newVersion = `${baseVersion}+autobuild.${generateShortTimestamp(true, true)}`;
// 格式: 2.3.0-autobuild.0610.cc39b2.r2
newVersion = `${baseVersion}-autobuild.${generateChannelSuffix({
includeCommit: true,
commitSource: "tauri",
})}`;
} else if (versionArg.toLowerCase() === "autobuild-latest") {
// 格式: 2.3.0+autobuild.1004.a1b2c3d (使用最新 Tauri 提交)
const latestTauriCommit = getLatestTauriCommit();
newVersion = `${baseVersion}+autobuild.${generateShortTimestamp()}.${latestTauriCommit}`;
// 格式: 2.3.0-autobuild.0610.a1b2c3d.r2 (使用最新 Tauri 提交)
newVersion = `${baseVersion}-autobuild.${generateChannelSuffix({
includeCommit: true,
commitSource: "tauri",
})}`;
} else if (versionArg.toLowerCase() === "deploytest") {
// 格式: 2.3.0+deploytest.1004.cc39b27
// 使用 Tauri 相关的最新 commit hash
newVersion = `${baseVersion}+deploytest.${generateShortTimestamp(true, true)}`;
// 格式: 2.3.0-deploytest.0610.cc39b2.r2
newVersion = `${baseVersion}-deploytest.${generateChannelSuffix({
includeCommit: true,
commitSource: "tauri",
})}`;
} else {
newVersion = `${baseVersion}-${versionArg.toLowerCase()}`;
}

View File

@@ -1,5 +1,8 @@
import fetch from "node-fetch";
import process from "node:process";
import { getOctokit, context } from "@actions/github";
import fetch from "node-fetch";
import { resolveUpdateLog, resolveUpdateLogDefault } from "./updatelog.mjs";
// Add stable update JSON filenames
@@ -10,6 +13,11 @@ const UPDATE_JSON_PROXY = "update-proxy.json";
const ALPHA_TAG_NAME = "updater-alpha";
const ALPHA_UPDATE_JSON_FILE = "update.json";
const ALPHA_UPDATE_JSON_PROXY = "update-proxy.json";
// Add autobuild update JSON filenames
const AUTOBUILD_SOURCE_TAG_NAME = "autobuild";
const AUTOBUILD_TAG_NAME = "updater-autobuild";
const AUTOBUILD_UPDATE_JSON_FILE = "update.json";
const AUTOBUILD_UPDATE_JSON_PROXY = "update-proxy.json";
/// generate update.json
/// upload to update tag's release asset
@@ -48,12 +56,12 @@ async function resolveUpdater() {
// More flexible tag detection with regex patterns
const stableTagRegex = /^v\d+\.\d+\.\d+$/; // Matches vX.Y.Z format
// const preReleaseRegex = /^v\d+\.\d+\.\d+-(alpha|beta|rc|pre)/i; // Matches vX.Y.Z-alpha/beta/rc format
const preReleaseRegex = /^(alpha|beta|rc|pre)$/i; // Matches exact alpha/beta/rc/pre tags
// Get the latest stable tag and pre-release tag
// Get tags for known channels
const stableTag = tags.find((t) => stableTagRegex.test(t.name));
const preReleaseTag = tags.find((t) => preReleaseRegex.test(t.name));
const autobuildTag = tags.find((t) => t.name === AUTOBUILD_SOURCE_TAG_NAME);
console.log("All tags:", tags.map((t) => t.name).join(", "));
console.log("Stable tag:", stableTag ? stableTag.name : "None found");
@@ -61,32 +69,79 @@ async function resolveUpdater() {
"Pre-release tag:",
preReleaseTag ? preReleaseTag.name : "None found",
);
console.log(
"Autobuild tag:",
autobuildTag ? autobuildTag.name : "None found",
);
console.log();
// Process stable release
if (stableTag) {
await processRelease(github, options, stableTag, false);
}
const channels = [
{
name: "stable",
tagName: stableTag?.name,
updateReleaseTag: UPDATE_TAG_NAME,
jsonFile: UPDATE_JSON_FILE,
proxyFile: UPDATE_JSON_PROXY,
prerelease: false,
},
{
name: "alpha",
tagName: preReleaseTag?.name,
updateReleaseTag: ALPHA_TAG_NAME,
jsonFile: ALPHA_UPDATE_JSON_FILE,
proxyFile: ALPHA_UPDATE_JSON_PROXY,
prerelease: true,
},
{
name: "autobuild",
tagName: autobuildTag?.name ?? AUTOBUILD_SOURCE_TAG_NAME,
updateReleaseTag: AUTOBUILD_TAG_NAME,
jsonFile: AUTOBUILD_UPDATE_JSON_FILE,
proxyFile: AUTOBUILD_UPDATE_JSON_PROXY,
prerelease: true,
},
];
// Process pre-release if found
if (preReleaseTag) {
await processRelease(github, options, preReleaseTag, true);
for (const channel of channels) {
if (!channel.tagName) {
console.log(`[${channel.name}] tag not found, skipping...`);
continue;
}
await processRelease(github, options, channel);
}
}
// Process a release (stable or alpha) and generate update files
async function processRelease(github, options, tag, isAlpha) {
if (!tag) return;
// Process a release and generate update files for the specified channel
async function processRelease(github, options, channelConfig) {
if (!channelConfig) return;
const {
tagName,
name: channelName,
updateReleaseTag,
jsonFile,
proxyFile,
prerelease,
} = channelConfig;
const channelLabel =
channelName.charAt(0).toUpperCase() + channelName.slice(1);
try {
const { data: release } = await github.rest.repos.getReleaseByTag({
...options,
tag: tag.name,
tag: tagName,
});
const releaseTagName = release.tag_name ?? tagName;
console.log(
`[${channelName}] Preparing update metadata from release "${releaseTagName}"`,
);
const updateData = {
name: tag.name,
notes: await resolveUpdateLog(tag.name).catch(() =>
name: releaseTagName,
notes: await resolveUpdateLog(releaseTagName).catch(() =>
resolveUpdateLogDefault().catch(() => "No changelog available"),
),
pub_date: new Date().toISOString(),
@@ -186,13 +241,15 @@ async function processRelease(github, options, tag, isAlpha) {
});
await Promise.allSettled(promises);
console.log(updateData);
console.log(`[${channelName}] Update data snapshot:`, updateData);
// maybe should test the signature as well
// delete the null field
Object.entries(updateData.platforms).forEach(([key, value]) => {
if (!value.url) {
console.log(`[Error]: failed to parse release for "${key}"`);
console.log(
`[${channelName}] [Error]: failed to parse release for "${key}"`,
);
delete updateData.platforms[key];
}
});
@@ -205,15 +262,14 @@ async function processRelease(github, options, tag, isAlpha) {
updateDataNew.platforms[key].url =
"https://download.clashverge.dev/" + value.url;
} else {
console.log(`[Error]: updateDataNew.platforms.${key} is null`);
console.log(
`[${channelName}] [Error]: updateDataNew.platforms.${key} is null`,
);
}
});
// Get the appropriate updater release based on isAlpha flag
const releaseTag = isAlpha ? ALPHA_TAG_NAME : UPDATE_TAG_NAME;
console.log(
`Processing ${isAlpha ? "alpha" : "stable"} release:`,
releaseTag,
`[${channelName}] Processing update release target "${updateReleaseTag}"`,
);
try {
@@ -223,30 +279,28 @@ async function processRelease(github, options, tag, isAlpha) {
// Try to get the existing release
const response = await github.rest.repos.getReleaseByTag({
...options,
tag: releaseTag,
tag: updateReleaseTag,
});
updateRelease = response.data;
console.log(
`Found existing ${releaseTag} release with ID: ${updateRelease.id}`,
`[${channelName}] Found existing ${updateReleaseTag} release with ID: ${updateRelease.id}`,
);
} catch (error) {
// If release doesn't exist, create it
if (error.status === 404) {
console.log(
`Release with tag ${releaseTag} not found, creating new release...`,
`[${channelName}] Release with tag ${updateReleaseTag} not found, creating new release...`,
);
const createResponse = await github.rest.repos.createRelease({
...options,
tag_name: releaseTag,
name: isAlpha
? "Auto-update Alpha Channel"
: "Auto-update Stable Channel",
body: `This release contains the update information for ${isAlpha ? "alpha" : "stable"} channel.`,
prerelease: isAlpha,
tag_name: updateReleaseTag,
name: `Auto-update ${channelLabel} Channel`,
body: `This release contains the update information for the ${channelName} channel.`,
prerelease,
});
updateRelease = createResponse.data;
console.log(
`Created new ${releaseTag} release with ID: ${updateRelease.id}`,
`[${channelName}] Created new ${updateReleaseTag} release with ID: ${updateRelease.id}`,
);
} else {
// If it's another error, throw it
@@ -255,11 +309,8 @@ async function processRelease(github, options, tag, isAlpha) {
}
// File names based on release type
const jsonFile = isAlpha ? ALPHA_UPDATE_JSON_FILE : UPDATE_JSON_FILE;
const proxyFile = isAlpha ? ALPHA_UPDATE_JSON_PROXY : UPDATE_JSON_PROXY;
// Delete existing assets with these names
for (let asset of updateRelease.assets) {
for (const asset of updateRelease.assets) {
if (asset.name === jsonFile) {
await github.rest.repos.deleteReleaseAsset({
...options,
@@ -270,7 +321,12 @@ async function processRelease(github, options, tag, isAlpha) {
if (asset.name === proxyFile) {
await github.rest.repos
.deleteReleaseAsset({ ...options, asset_id: asset.id })
.catch(console.error); // do not break the pipeline
.catch((deleteError) =>
console.error(
`[${channelName}] Failed to delete existing proxy asset:`,
deleteError.message,
),
); // do not break the pipeline
}
}
@@ -290,20 +346,22 @@ async function processRelease(github, options, tag, isAlpha) {
});
console.log(
`Successfully uploaded ${isAlpha ? "alpha" : "stable"} update files to ${releaseTag}`,
`[${channelName}] Successfully uploaded update files to ${updateReleaseTag}`,
);
} catch (error) {
console.error(
`Failed to process ${isAlpha ? "alpha" : "stable"} release:`,
`[${channelName}] Failed to process update release:`,
error.message,
);
}
} catch (error) {
if (error.status === 404) {
console.log(`Release not found for tag: ${tag.name}, skipping...`);
console.log(
`[${channelName}] Release not found for tag: ${tagName}, skipping...`,
);
} else {
console.error(
`Failed to get release for tag: ${tag.name}`,
`[${channelName}] Failed to get release for tag: ${tagName}`,
error.message,
);
}

8
src-tauri/Cargo.lock generated
View File

@@ -152,6 +152,12 @@ dependencies = [
"x11rb",
]
[[package]]
name = "arc-swap"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
[[package]]
name = "arraydeque"
version = "0.5.1"
@@ -1093,6 +1099,7 @@ version = "2.4.3"
dependencies = [
"aes-gcm",
"anyhow",
"arc-swap",
"async-trait",
"backoff",
"base64 0.22.1",
@@ -1151,6 +1158,7 @@ dependencies = [
"tauri-plugin-window-state",
"tokio",
"tokio-stream",
"url",
"users",
"warp",
"winapi",

View File

@@ -86,6 +86,8 @@ smartstring = { version = "1.0.1", features = ["serde"] }
clash_verge_service_ipc = { version = "2.0.21", features = [
"client",
], git = "https://github.com/clash-verge-rev/clash-verge-service-ipc" }
arc-swap = "1.7.1"
url = "2.5.4"
[target.'cfg(windows)'.dependencies]
runas = "=1.2.0"

View File

@@ -16,6 +16,7 @@ pub mod runtime;
pub mod save_profile;
pub mod service;
pub mod system;
pub mod updater;
pub mod uwp;
pub mod validate;
pub mod verge;
@@ -34,6 +35,7 @@ pub use runtime::*;
pub use save_profile::*;
pub use service::*;
pub use system::*;
pub use updater::*;
pub use uwp::*;
pub use validate::*;
pub use verge::*;

View File

@@ -15,68 +15,19 @@ use crate::{
ret_err,
utils::{dirs, help, logging::Type},
};
use scopeguard::defer;
use smartstring::alias::String;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
// 全局请求序列号跟踪,用于避免队列化执行
static CURRENT_REQUEST_SEQUENCE: AtomicU64 = AtomicU64::new(0);
static CURRENT_SWITCHING_PROFILE: AtomicBool = AtomicBool::new(false);
#[tauri::command]
pub async fn get_profiles() -> CmdResult<IProfiles> {
// 策略1: 尝试快速获取latest数据
let latest_result = tokio::time::timeout(Duration::from_millis(500), async {
let profiles = Config::profiles().await;
let latest = profiles.latest_ref();
IProfiles {
current: latest.current.clone(),
items: latest.items.clone(),
}
})
.await;
match latest_result {
Ok(profiles) => {
logging!(info, Type::Cmd, "快速获取配置列表成功");
return Ok(profiles);
}
Err(_) => {
logging!(warn, Type::Cmd, "快速获取配置超时(500ms)");
}
}
// 策略2: 如果快速获取失败尝试获取data()
let data_result = tokio::time::timeout(Duration::from_secs(2), async {
let profiles = Config::profiles().await;
let data = profiles.latest_ref();
IProfiles {
current: data.current.clone(),
items: data.items.clone(),
}
})
.await;
match data_result {
Ok(profiles) => {
logging!(info, Type::Cmd, "获取draft配置列表成功");
return Ok(profiles);
}
Err(join_err) => {
logging!(
error,
Type::Cmd,
"获取draft配置任务失败或超时: {}",
join_err
);
}
}
// 策略3: fallback尝试重新创建配置
logging!(warn, Type::Cmd, "所有获取配置策略都失败尝试fallback");
Ok(IProfiles::new().await)
logging!(debug, Type::Cmd, "获取配置文件列表");
let draft = Config::profiles().await;
let latest = draft.latest_ref();
Ok((**latest).clone())
}
/// 增强配置文件
@@ -332,7 +283,7 @@ async fn restore_previous_profile(prev_profile: String) -> CmdResult<()> {
Config::profiles()
.await
.draft_mut()
.patch_config(restore_profiles)
.patch_config(&restore_profiles)
.stringify_err()?;
Config::profiles().await.apply();
crate::process::AsyncHandler::spawn(|| async move {
@@ -344,26 +295,7 @@ async fn restore_previous_profile(prev_profile: String) -> CmdResult<()> {
Ok(())
}
async fn handle_success(current_sequence: u64, current_value: Option<String>) -> CmdResult<bool> {
let latest_sequence = CURRENT_REQUEST_SEQUENCE.load(Ordering::SeqCst);
if current_sequence < latest_sequence {
logging!(
info,
Type::Cmd,
"内核操作后发现更新的请求 (序列号: {} < {}),忽略当前结果",
current_sequence,
latest_sequence
);
Config::profiles().await.discard();
return Ok(false);
}
logging!(
info,
Type::Cmd,
"配置更新成功,序列号: {}",
current_sequence
);
async fn handle_success(current_value: Option<String>) -> CmdResult<bool> {
Config::profiles().await.apply();
handle::Handle::refresh_clash();
@@ -380,17 +312,10 @@ async fn handle_success(current_sequence: u64, current_value: Option<String>) ->
}
if let Some(current) = &current_value {
logging!(
info,
Type::Cmd,
"向前端发送配置变更事件: {}, 序列号: {}",
current,
current_sequence
);
logging!(info, Type::Cmd, "向前端发送配置变更事件: {}", current,);
handle::Handle::notify_profile_changed(current.clone());
}
CURRENT_SWITCHING_PROFILE.store(false, Ordering::SeqCst);
Ok(true)
}
@@ -404,53 +329,31 @@ async fn handle_validation_failure(
restore_previous_profile(prev_profile).await?;
}
handle::Handle::notice_message("config_validate::error", error_msg);
CURRENT_SWITCHING_PROFILE.store(false, Ordering::SeqCst);
Ok(false)
}
async fn handle_update_error<E: std::fmt::Display>(e: E, current_sequence: u64) -> CmdResult<bool> {
logging!(
warn,
Type::Cmd,
"更新过程发生错误: {}, 序列号: {}",
e,
current_sequence
);
async fn handle_update_error<E: std::fmt::Display>(e: E) -> CmdResult<bool> {
logging!(warn, Type::Cmd, "更新过程发生错误: {}", e,);
Config::profiles().await.discard();
handle::Handle::notice_message("config_validate::boot_error", e.to_string());
CURRENT_SWITCHING_PROFILE.store(false, Ordering::SeqCst);
Ok(false)
}
async fn handle_timeout(current_profile: Option<String>, current_sequence: u64) -> CmdResult<bool> {
async fn handle_timeout(current_profile: Option<String>) -> CmdResult<bool> {
let timeout_msg = "配置更新超时(30秒),可能是配置验证或核心通信阻塞";
logging!(
error,
Type::Cmd,
"{}, 序列号: {}",
timeout_msg,
current_sequence
);
logging!(error, Type::Cmd, "{}", timeout_msg);
Config::profiles().await.discard();
if let Some(prev_profile) = current_profile {
restore_previous_profile(prev_profile).await?;
}
handle::Handle::notice_message("config_validate::timeout", timeout_msg);
CURRENT_SWITCHING_PROFILE.store(false, Ordering::SeqCst);
Ok(false)
}
async fn perform_config_update(
current_sequence: u64,
current_value: Option<String>,
current_profile: Option<String>,
) -> CmdResult<bool> {
logging!(
info,
Type::Cmd,
"开始内核配置更新,序列号: {}",
current_sequence
);
let update_result = tokio::time::timeout(
Duration::from_secs(30),
CoreManager::global().update_config(),
@@ -458,46 +361,36 @@ async fn perform_config_update(
.await;
match update_result {
Ok(Ok((true, _))) => handle_success(current_sequence, current_value).await,
Ok(Ok((true, _))) => handle_success(current_value).await,
Ok(Ok((false, error_msg))) => handle_validation_failure(error_msg, current_profile).await,
Ok(Err(e)) => handle_update_error(e, current_sequence).await,
Err(_) => handle_timeout(current_profile, current_sequence).await,
Ok(Err(e)) => handle_update_error(e).await,
Err(_) => handle_timeout(current_profile).await,
}
}
/// 修改profiles的配置
#[tauri::command]
pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
if CURRENT_SWITCHING_PROFILE.load(Ordering::SeqCst) {
if CURRENT_SWITCHING_PROFILE
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_err()
{
logging!(info, Type::Cmd, "当前正在切换配置,放弃请求");
return Ok(false);
return Err("switch_in_progress".into());
}
CURRENT_SWITCHING_PROFILE.store(true, Ordering::SeqCst);
// 为当前请求分配序列号
let current_sequence = CURRENT_REQUEST_SEQUENCE.fetch_add(1, Ordering::SeqCst) + 1;
defer! {
CURRENT_SWITCHING_PROFILE.store(false, Ordering::Release);
}
let target_profile = profiles.current.clone();
logging!(
info,
Type::Cmd,
"开始修改配置文件,请求序列号: {}, 目标profile: {:?}",
current_sequence,
"开始修改配置文件目标profile: {:?}",
target_profile
);
let latest_sequence = CURRENT_REQUEST_SEQUENCE.load(Ordering::SeqCst);
if current_sequence < latest_sequence {
logging!(
info,
Type::Cmd,
"获取锁后发现更新的请求 (序列号: {} < {}),放弃当前请求",
current_sequence,
latest_sequence
);
return Ok(false);
}
// 保存当前配置,以便在验证失败时恢复
let current_profile = Config::profiles().await.latest_ref().current.clone();
logging!(info, Type::Cmd, "当前配置: {:?}", current_profile);
@@ -507,50 +400,13 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
&& current_profile.as_ref() != Some(new_profile)
&& validate_new_profile(new_profile).await.is_err()
{
CURRENT_SWITCHING_PROFILE.store(false, Ordering::SeqCst);
return Ok(false);
}
// 检查请求有效性
let latest_sequence = CURRENT_REQUEST_SEQUENCE.load(Ordering::SeqCst);
if current_sequence < latest_sequence {
logging!(
info,
Type::Cmd,
"在核心操作前发现更新的请求 (序列号: {} < {}),放弃当前请求",
current_sequence,
latest_sequence
);
return Ok(false);
}
// 更新profiles配置
logging!(
info,
Type::Cmd,
"正在更新配置草稿,序列号: {}",
current_sequence
);
let _ = Config::profiles().await.draft_mut().patch_config(&profiles);
let current_value = profiles.current.clone();
let _ = Config::profiles().await.draft_mut().patch_config(profiles);
// 在调用内核前再次验证请求有效性
let latest_sequence = CURRENT_REQUEST_SEQUENCE.load(Ordering::SeqCst);
if current_sequence < latest_sequence {
logging!(
info,
Type::Cmd,
"在内核交互前发现更新的请求 (序列号: {} < {}),放弃当前请求",
current_sequence,
latest_sequence
);
Config::profiles().await.discard();
return Ok(false);
}
perform_config_update(current_sequence, current_value, current_profile).await
perform_config_update(current_value, current_profile).await
}
/// 根据profile name修改profiles

View File

@@ -0,0 +1,149 @@
use serde::Serialize;
use tauri::{Manager, ResourceId, Runtime, webview::Webview};
use tauri_plugin_updater::UpdaterExt;
use url::Url;
use super::{CmdResult, String};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateMetadata {
rid: ResourceId,
current_version: String,
version: String,
date: Option<String>,
body: Option<String>,
raw_json: serde_json::Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UpdateChannel {
Stable,
Autobuild,
}
impl TryFrom<&str> for UpdateChannel {
type Error = String;
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value {
"stable" => Ok(Self::Stable),
"autobuild" => Ok(Self::Autobuild),
other => Err(String::from(format!("Unsupported channel \"{other}\""))),
}
}
}
const CHANNEL_RELEASE_TAGS: &[(UpdateChannel, &str)] = &[
(UpdateChannel::Stable, "updater"),
(UpdateChannel::Autobuild, "updater-autobuild"),
];
const CHANNEL_ENDPOINT_TEMPLATES: &[&str] = &[
"https://download.clashverge.dev/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/{release}/update-proxy.json",
"https://gh-proxy.com/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/{release}/update-proxy.json",
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/{release}/update.json",
];
fn resolve_release_tag(channel: UpdateChannel) -> CmdResult<&'static str> {
CHANNEL_RELEASE_TAGS
.iter()
.find_map(|(entry_channel, tag)| (*entry_channel == channel).then_some(*tag))
.ok_or_else(|| {
String::from(format!(
"No release tag registered for update channel \"{channel:?}\""
))
})
}
fn resolve_channel_endpoints(channel: UpdateChannel) -> CmdResult<Vec<Url>> {
let release_tag = resolve_release_tag(channel)?;
CHANNEL_ENDPOINT_TEMPLATES
.iter()
.map(|template| {
let endpoint = template.replace("{release}", release_tag);
Url::parse(&endpoint).map_err(|err| {
String::from(format!(
"Failed to parse updater endpoint \"{endpoint}\": {err}"
))
})
})
.collect()
}
#[allow(clippy::too_many_arguments)]
#[tauri::command]
pub async fn check_update_channel<R: Runtime>(
webview: Webview<R>,
channel: String,
headers: Option<Vec<(String, String)>>,
timeout: Option<u64>,
proxy: Option<String>,
target: Option<String>,
allow_downgrades: Option<bool>,
) -> CmdResult<Option<UpdateMetadata>> {
let channel_enum = UpdateChannel::try_from(channel.as_str())?;
let endpoints = resolve_channel_endpoints(channel_enum)?;
let mut builder = webview
.updater_builder()
.endpoints(endpoints)
.map_err(|err| String::from(err.to_string()))?;
if let Some(headers) = headers {
for (key, value) in headers {
builder = builder
.header(key.as_str(), value.as_str())
.map_err(|err| String::from(err.to_string()))?;
}
}
if let Some(timeout) = timeout {
builder = builder.timeout(std::time::Duration::from_millis(timeout));
}
if let Some(proxy) = proxy {
let proxy_url = Url::parse(&proxy)
.map_err(|err| String::from(format!("Invalid proxy URL \"{proxy}\": {err}")))?;
builder = builder.proxy(proxy_url);
}
if let Some(target) = target {
builder = builder.target(target);
}
let allow_downgrades = allow_downgrades.unwrap_or(channel_enum != UpdateChannel::Stable);
if allow_downgrades {
builder = builder.version_comparator(|current, update| update.version != current);
}
let updater = builder
.build()
.map_err(|err| String::from(err.to_string()))?;
let update = updater
.check()
.await
.map_err(|err| String::from(err.to_string()))?;
let Some(update) = update else {
return Ok(None);
};
let formatted_date = update
.date
.as_ref()
.map(|date| String::from(date.to_string()));
let metadata = UpdateMetadata {
rid: webview.resources_table().add(update.clone()),
current_version: String::from(update.current_version.clone()),
version: String::from(update.version.clone()),
date: formatted_date,
body: update.body.clone().map(Into::into),
raw_json: update.raw_json.clone(),
};
Ok(Some(metadata))
}

View File

@@ -69,23 +69,16 @@ impl IProfiles {
}
Err(err) => {
logging!(error, Type::Config, "{err}");
Self::template()
Self::default()
}
},
Err(err) => {
logging!(error, Type::Config, "{err}");
Self::template()
Self::default()
}
}
}
pub fn template() -> Self {
Self {
items: Some(vec![]),
..Self::default()
}
}
pub async fn save_file(&self) -> Result<()> {
help::save_yaml(
&dirs::profiles_path()?,
@@ -96,17 +89,17 @@ impl IProfiles {
}
/// 只修改currentvalid和chain
pub fn patch_config(&mut self, patch: IProfiles) -> Result<()> {
pub fn patch_config(&mut self, patch: &IProfiles) -> Result<()> {
if self.items.is_none() {
self.items = Some(vec![]);
}
if let Some(current) = patch.current
if let Some(current) = &patch.current
&& let Some(items) = self.items.as_ref()
{
let some_uid = Some(current);
if items.iter().any(|e| e.uid == some_uid) {
self.current = some_uid;
if items.iter().any(|e| e.uid.as_ref() == some_uid) {
self.current = some_uid.cloned();
}
}

View File

@@ -6,8 +6,8 @@ use crate::{
utils::{dirs, logging::Type},
};
use anyhow::Error;
use arc_swap::{ArcSwap, ArcSwapOption};
use once_cell::sync::OnceCell;
use parking_lot::Mutex;
use reqwest_dav::list_cmd::{ListEntity, ListFile};
use smartstring::alias::String;
use std::{
@@ -56,24 +56,24 @@ impl Operation {
}
pub struct WebDavClient {
config: Arc<Mutex<Option<WebDavConfig>>>,
clients: Arc<Mutex<HashMap<Operation, reqwest_dav::Client>>>,
config: Arc<ArcSwapOption<WebDavConfig>>,
clients: Arc<ArcSwap<HashMap<Operation, reqwest_dav::Client>>>,
}
impl WebDavClient {
pub fn global() -> &'static WebDavClient {
static WEBDAV_CLIENT: OnceCell<WebDavClient> = OnceCell::new();
WEBDAV_CLIENT.get_or_init(|| WebDavClient {
config: Arc::new(Mutex::new(None)),
clients: Arc::new(Mutex::new(HashMap::new())),
config: Arc::new(ArcSwapOption::new(None)),
clients: Arc::new(ArcSwap::new(Arc::new(HashMap::new()))),
})
}
async fn get_client(&self, op: Operation) -> Result<reqwest_dav::Client, Error> {
// 先尝试从缓存获取
{
let clients = self.clients.lock();
if let Some(client) = clients.get(&op) {
let clients_map = self.clients.load();
if let Some(client) = clients_map.get(&op) {
return Ok(client.clone());
}
}
@@ -81,10 +81,10 @@ impl WebDavClient {
// 获取或创建配置
let config = {
// 首先检查是否已有配置
let existing_config = self.config.lock().as_ref().cloned();
let existing_config = self.config.load();
if let Some(cfg) = existing_config {
cfg
if let Some(cfg_arc) = existing_config.clone() {
(*cfg_arc).clone()
} else {
// 释放锁后获取异步配置
let verge = Config::verge().await.latest_ref().clone();
@@ -106,8 +106,8 @@ impl WebDavClient {
password: verge.webdav_password.unwrap_or_default(),
};
// 重新获取锁并存储配置
*self.config.lock() = Some(config.clone());
// 存储配置到 ArcSwapOption
self.config.store(Some(Arc::new(config.clone())));
config
}
};
@@ -161,18 +161,19 @@ impl WebDavClient {
}
}
// 缓存客户端
// 缓存客户端(替换 Arc<Mutex<HashMap<...>>> 的写法)
{
let mut clients = self.clients.lock();
clients.insert(op, client.clone());
let mut map = (**self.clients.load()).clone();
map.insert(op, client.clone());
self.clients.store(map.into());
}
Ok(client)
}
pub fn reset(&self) {
*self.config.lock() = None;
self.clients.lock().clear();
self.config.store(None);
self.clients.store(Arc::new(HashMap::new()));
}
pub async fn upload(&self, file_path: PathBuf, file_name: String) -> Result<(), Error> {

View File

@@ -5,7 +5,7 @@ use crate::{
singleton_with_logging, utils::logging::Type,
};
use anyhow::{Result, bail};
use parking_lot::Mutex;
use arc_swap::ArcSwap;
use smartstring::alias::String;
use std::{collections::HashMap, fmt, str::FromStr, sync::Arc};
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, ShortcutState};
@@ -93,13 +93,13 @@ impl SystemHotkey {
}
pub struct Hotkey {
current: Arc<Mutex<Vec<String>>>,
current: ArcSwap<Vec<String>>,
}
impl Hotkey {
fn new() -> Self {
Self {
current: Arc::new(Mutex::new(Vec::new())),
current: ArcSwap::new(Arc::new(Vec::new())),
}
}
@@ -272,9 +272,9 @@ impl Hotkey {
singleton_with_logging!(Hotkey, INSTANCE, "Hotkey");
impl Hotkey {
pub async fn init(&self) -> Result<()> {
pub async fn init(&self, skip: bool) -> Result<()> {
let verge = Config::verge().await;
let enable_global_hotkey = verge.latest_ref().enable_global_hotkey.unwrap_or(true);
let enable_global_hotkey = !skip && verge.latest_ref().enable_global_hotkey.unwrap_or(true);
logging!(
debug,
@@ -283,10 +283,6 @@ impl Hotkey {
enable_global_hotkey
);
if !enable_global_hotkey {
return Ok(());
}
// Extract hotkeys data before async operations
let hotkeys = verge.latest_ref().hotkeys.as_ref().cloned();
@@ -344,7 +340,7 @@ impl Hotkey {
}
}
}
self.current.lock().clone_from(&hotkeys);
self.current.store(Arc::new(hotkeys));
} else {
logging!(debug, Type::Hotkey, "No hotkeys configured");
}
@@ -375,8 +371,8 @@ impl Hotkey {
pub async fn update(&self, new_hotkeys: Vec<String>) -> Result<()> {
// Extract current hotkeys before async operations
let current_hotkeys = self.current.lock().clone();
let old_map = Self::get_map_from_vec(&current_hotkeys);
let current_hotkeys = &*self.current.load();
let old_map = Self::get_map_from_vec(current_hotkeys);
let new_map = Self::get_map_from_vec(&new_hotkeys);
let (del, add) = Self::get_diff(old_map, new_map);
@@ -390,7 +386,7 @@ impl Hotkey {
}
// Update the current hotkeys after all async operations
*self.current.lock() = new_hotkeys;
self.current.store(Arc::new(new_hotkeys));
Ok(())
}

View File

@@ -39,25 +39,20 @@ impl CoreManager {
return Ok((true, String::new()));
}
let _permit = self
.update_semaphore
.try_acquire()
.map_err(|_| anyhow!("Config update already in progress"))?;
self.perform_config_update().await
}
fn should_update_config(&self) -> Result<bool> {
let now = Instant::now();
let mut last = self.last_update.lock();
let last = self.get_last_update();
if let Some(last_time) = *last
&& now.duration_since(last_time) < timing::CONFIG_UPDATE_DEBOUNCE
if let Some(last_time) = last
&& now.duration_since(*last_time) < timing::CONFIG_UPDATE_DEBOUNCE
{
return Ok(false);
}
*last = Some(now);
self.set_last_update(now);
Ok(true)
}

View File

@@ -3,9 +3,8 @@ mod lifecycle;
mod state;
use anyhow::Result;
use parking_lot::Mutex;
use arc_swap::{ArcSwap, ArcSwapOption};
use std::{fmt, sync::Arc, time::Instant};
use tokio::sync::Semaphore;
use crate::process::CommandChildGuard;
use crate::singleton_lazy;
@@ -29,22 +28,21 @@ impl fmt::Display for RunningMode {
#[derive(Debug)]
pub struct CoreManager {
state: Arc<Mutex<State>>,
update_semaphore: Arc<Semaphore>,
last_update: Arc<Mutex<Option<Instant>>>,
state: ArcSwap<State>,
last_update: ArcSwapOption<Instant>,
}
#[derive(Debug)]
struct State {
running_mode: Arc<RunningMode>,
child_sidecar: Option<CommandChildGuard>,
running_mode: ArcSwap<RunningMode>,
child_sidecar: ArcSwapOption<CommandChildGuard>,
}
impl Default for State {
fn default() -> Self {
Self {
running_mode: Arc::new(RunningMode::NotRunning),
child_sidecar: None,
running_mode: ArcSwap::new(Arc::new(RunningMode::NotRunning)),
child_sidecar: ArcSwapOption::new(None),
}
}
}
@@ -52,24 +50,41 @@ impl Default for State {
impl Default for CoreManager {
fn default() -> Self {
Self {
state: Arc::new(Mutex::new(State::default())),
update_semaphore: Arc::new(Semaphore::new(1)),
last_update: Arc::new(Mutex::new(None)),
state: ArcSwap::new(Arc::new(State::default())),
last_update: ArcSwapOption::new(None),
}
}
}
impl CoreManager {
pub fn get_running_mode(&self) -> Arc<RunningMode> {
Arc::clone(&self.state.lock().running_mode)
Arc::clone(&self.state.load().running_mode.load())
}
pub fn take_child_sidecar(&self) -> Option<CommandChildGuard> {
self.state
.load()
.child_sidecar
.swap(None)
.and_then(|arc| Arc::try_unwrap(arc).ok())
}
pub fn get_last_update(&self) -> Option<Arc<Instant>> {
self.last_update.load_full()
}
pub fn set_running_mode(&self, mode: RunningMode) {
self.state.lock().running_mode = Arc::new(mode);
let state = self.state.load();
state.running_mode.store(Arc::new(mode));
}
pub fn set_running_child_sidecar(&self, child: CommandChildGuard) {
self.state.lock().child_sidecar = Some(child);
let state = self.state.load();
state.child_sidecar.store(Some(Arc::new(child)));
}
pub fn set_last_update(&self, time: Instant) {
self.last_update.store(Some(Arc::new(time)));
}
pub async fn init(&self) -> Result<()> {

View File

@@ -93,8 +93,7 @@ impl CoreManager {
defer! {
self.set_running_mode(RunningMode::NotRunning);
}
let mut state = self.state.lock();
if let Some(child) = state.child_sidecar.take() {
if let Some(child) = self.take_child_sidecar() {
let pid = child.pid();
drop(child);
logging!(trace, Type::Core, "Sidecar stopped (PID: {:?})", pid);

View File

@@ -241,11 +241,15 @@ pub async fn restore_local_backup(filename: String) -> Result<()> {
return Err(anyhow!("Backup file not found: {}", filename));
}
let verge = Config::verge().await;
let verge_data = verge.latest_ref().clone();
let webdav_url = verge_data.webdav_url.clone();
let webdav_username = verge_data.webdav_username.clone();
let webdav_password = verge_data.webdav_password.clone();
let (webdav_url, webdav_username, webdav_password) = {
let verge = Config::verge().await;
let verge = verge.latest_ref();
(
verge.webdav_url.clone(),
verge.webdav_username.clone(),
verge.webdav_password.clone(),
)
};
let file = AsyncHandler::spawn_blocking(move || std::fs::File::open(&target_path)).await??;
let mut zip = zip::ZipArchive::new(file)?;

View File

@@ -174,6 +174,7 @@ mod app_init {
cmd::get_runtime_logs,
cmd::get_runtime_proxy_chain_config,
cmd::update_proxy_chain_config_in_runtime,
cmd::check_update_channel,
cmd::invoke_uwp_tool,
cmd::copy_clash_env,
cmd::sync_tray_proxy_selection,
@@ -334,10 +335,7 @@ pub fn run() {
.register_system_hotkey(SystemHotkey::CmdW)
.await;
}
if !is_enable_global_hotkey {
let _ = hotkey::Hotkey::global().init().await;
}
let _ = hotkey::Hotkey::global().init(true).await;
return;
}
@@ -358,8 +356,18 @@ pub fn run() {
#[cfg(target_os = "macos")]
{
use crate::core::hotkey::SystemHotkey;
let _ = hotkey::Hotkey::global().unregister_system_hotkey(SystemHotkey::CmdQ);
let _ = hotkey::Hotkey::global().unregister_system_hotkey(SystemHotkey::CmdW);
AsyncHandler::spawn(move || async move {
let _ = hotkey::Hotkey::global().unregister_system_hotkey(SystemHotkey::CmdQ);
let _ = hotkey::Hotkey::global().unregister_system_hotkey(SystemHotkey::CmdW);
let is_enable_global_hotkey = Config::verge()
.await
.latest_ref()
.enable_global_hotkey
.unwrap_or(true);
if !is_enable_global_hotkey {
let _ = hotkey::Hotkey::global().reset();
}
});
}
}
}

View File

@@ -1,6 +1,6 @@
use crate::{
config::Config,
core::{handle, timer::Timer},
core::{handle, timer::Timer, tray::Tray},
log_err, logging,
process::AsyncHandler,
utils::logging::Type,
@@ -78,6 +78,12 @@ pub fn is_in_lightweight_mode() -> bool {
get_state() == LightweightState::In
}
async fn refresh_lightweight_tray_state() {
if let Err(err) = Tray::global().update_tray_display().await {
logging!(warn, Type::Lightweight, "更新托盘轻量模式状态失败: {err}");
}
}
pub async fn auto_lightweight_boot() -> Result<()> {
let verge_config = Config::verge().await;
let enable_auto = verge_config
@@ -130,11 +136,13 @@ pub fn disable_auto_light_weight_mode() {
pub async fn entry_lightweight_mode() -> bool {
if !try_transition(LightweightState::Normal, LightweightState::In) {
logging!(info, Type::Lightweight, "无需进入轻量模式,跳过调用");
refresh_lightweight_tray_state().await;
return false;
}
record_state_and_log(LightweightState::In);
WindowManager::destroy_main_window();
let _ = cancel_light_weight_timer();
refresh_lightweight_tray_state().await;
true
}
@@ -145,12 +153,14 @@ pub async fn exit_lightweight_mode() -> bool {
Type::Lightweight,
"轻量模式不在退出条件(可能已退出或正在退出),跳过调用"
);
refresh_lightweight_tray_state().await;
return false;
}
record_state_and_log(LightweightState::Exiting);
WindowManager::show_main_window().await;
let _ = cancel_light_weight_timer();
record_state_and_log(LightweightState::Normal);
refresh_lightweight_tray_state().await;
true
}

View File

@@ -365,7 +365,7 @@ async fn initialize_config_files() -> Result<()> {
if let Ok(path) = dirs::profiles_path()
&& !path.exists()
{
let template = IProfiles::template();
let template = IProfiles::default();
help::save_yaml(&path, &template, Some("# Clash Verge"))
.await
.map_err(|e| anyhow::anyhow!("Failed to create profiles config: {}", e))?;

View File

@@ -120,7 +120,7 @@ pub(super) async fn init_timer() {
}
pub(super) async fn init_hotkey() {
logging_error!(Type::Setup, Hotkey::global().init().await);
logging_error!(Type::Setup, Hotkey::global().init(false).await);
}
pub(super) async fn init_auto_lightweight_boot() {

View File

@@ -26,6 +26,7 @@ import { useServiceInstaller } from "@/hooks/useServiceInstaller";
import { getSystemInfo } from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
import { checkUpdateSafe as checkUpdate } from "@/services/update";
import { useUpdateChannel } from "@/services/updateChannel";
import { version as appVersion } from "@root/package.json";
import { EnhancedCard } from "./enhanced-card";
@@ -59,6 +60,7 @@ export const SystemInfoCard = () => {
const navigate = useNavigate();
const { isAdminMode, isSidecarMode } = useSystemState();
const { installServiceAndRestartCore } = useServiceInstaller();
const [updateChannel] = useUpdateChannel();
// 系统信息状态
const [systemState, dispatchSystemState] = useReducer(systemStateReducer, {
@@ -117,7 +119,7 @@ export const SystemInfoCard = () => {
timeoutId = window.setTimeout(() => {
if (verge?.auto_check_update) {
checkUpdate().catch(console.error);
checkUpdate(updateChannel).catch(console.error);
}
}, 5000);
}
@@ -126,11 +128,11 @@ export const SystemInfoCard = () => {
window.clearTimeout(timeoutId);
}
};
}, [verge?.auto_check_update, dispatchSystemState]);
}, [verge?.auto_check_update, dispatchSystemState, updateChannel]);
// 自动检查更新逻辑
useSWR(
verge?.auto_check_update ? "checkUpdate" : null,
verge?.auto_check_update ? ["checkUpdate", updateChannel] : null,
async () => {
const now = Date.now();
localStorage.setItem("last_check_update", now.toString());
@@ -138,7 +140,7 @@ export const SystemInfoCard = () => {
type: "set-last-check-update",
payload: new Date(now).toLocaleString(),
});
return await checkUpdate();
return await checkUpdate(updateChannel);
},
{
revalidateOnFocus: false,
@@ -172,7 +174,7 @@ export const SystemInfoCard = () => {
// 检查更新
const onCheckUpdate = useLockFn(async () => {
try {
const info = await checkUpdate();
const info = await checkUpdate(updateChannel);
if (!info?.available) {
showNotice("success", t("Currently on the Latest Version"));
} else {

View File

@@ -4,6 +4,7 @@ import useSWR from "swr";
import { useVerge } from "@/hooks/use-verge";
import { checkUpdateSafe } from "@/services/update";
import { useUpdateChannel } from "@/services/updateChannel";
import { DialogRef } from "../base";
import { UpdateViewer } from "../setting/mods/update-viewer";
@@ -16,12 +17,14 @@ export const UpdateButton = (props: Props) => {
const { className } = props;
const { verge } = useVerge();
const { auto_check_update } = verge || {};
const [updateChannel] = useUpdateChannel();
const viewerRef = useRef<DialogRef>(null);
const shouldCheck = auto_check_update || auto_check_update === null;
const { data: updateInfo } = useSWR(
auto_check_update || auto_check_update === null ? "checkUpdate" : null,
checkUpdateSafe,
shouldCheck ? ["checkUpdate", updateChannel] : null,
() => checkUpdateSafe(updateChannel),
{
errorRetryCount: 2,
revalidateIfStale: false,

View File

@@ -15,6 +15,7 @@ import { portableFlag } from "@/pages/_layout";
import { showNotice } from "@/services/noticeService";
import { useSetUpdateState, useUpdateState } from "@/services/states";
import { checkUpdateSafe as checkUpdate } from "@/services/update";
import { useUpdateChannel } from "@/services/updateChannel";
export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
const { t } = useTranslation();
@@ -26,12 +27,17 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
const updateState = useUpdateState();
const setUpdateState = useSetUpdateState();
const { addListener } = useListen();
const [updateChannel] = useUpdateChannel();
const { data: updateInfo } = useSWR("checkUpdate", checkUpdate, {
errorRetryCount: 2,
revalidateIfStale: false,
focusThrottleInterval: 36e5, // 1 hour
});
const { data: updateInfo } = useSWR(
["checkUpdate", updateChannel],
() => checkUpdate(updateChannel),
{
errorRetryCount: 2,
revalidateIfStale: false,
focusThrottleInterval: 36e5, // 1 hour
},
);
const [downloaded, setDownloaded] = useState(0);
const [buffer, setBuffer] = useState(0);

View File

@@ -15,6 +15,7 @@ import {
} from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
import { checkUpdateSafe as checkUpdate } from "@/services/update";
import { useUpdateChannel } from "@/services/updateChannel";
import { version } from "@root/package.json";
import { BackupViewer } from "./mods/backup-viewer";
@@ -42,10 +43,11 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => {
const updateRef = useRef<DialogRef>(null);
const backupRef = useRef<DialogRef>(null);
const liteModeRef = useRef<DialogRef>(null);
const [updateChannel] = useUpdateChannel();
const onCheckUpdate = async () => {
try {
const info = await checkUpdate();
const info = await checkUpdate(updateChannel);
if (!info?.available) {
showNotice("success", t("Currently on the Latest Version"));
} else {

View File

@@ -1,5 +1,11 @@
import { ContentCopyRounded } from "@mui/icons-material";
import { Button, Input, MenuItem, Select } from "@mui/material";
import {
Button,
Input,
MenuItem,
Select,
SelectChangeEvent,
} from "@mui/material";
import { open } from "@tauri-apps/plugin-dialog";
import { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
@@ -11,6 +17,11 @@ import { navItems } from "@/pages/_routers";
import { copyClashEnv } from "@/services/cmds";
import { supportedLanguages } from "@/services/i18n";
import { showNotice } from "@/services/noticeService";
import {
UPDATE_CHANNEL_OPTIONS,
type UpdateChannel,
useUpdateChannel,
} from "@/services/updateChannel";
import getSystem from "@/utils/get-system";
import { BackupViewer } from "./mods/backup-viewer";
@@ -69,6 +80,7 @@ const SettingVergeBasic = ({ onError }: Props) => {
const layoutRef = useRef<DialogRef>(null);
const updateRef = useRef<DialogRef>(null);
const backupRef = useRef<DialogRef>(null);
const [updateChannel, setUpdateChannel] = useUpdateChannel();
const onChangeData = (patch: any) => {
mutateVerge({ ...verge, ...patch }, false);
@@ -79,6 +91,14 @@ const SettingVergeBasic = ({ onError }: Props) => {
showNotice("success", t("Copy Success"), 1000);
}, [t]);
const onUpdateChannelChange = useCallback(
(event: SelectChangeEvent<UpdateChannel>) => {
const nextChannel = event.target.value as UpdateChannel;
setUpdateChannel(nextChannel);
},
[setUpdateChannel],
);
return (
<SettingList title={t("Verge Basic Setting")}>
<ThemeViewer ref={themeRef} />
@@ -89,6 +109,21 @@ const SettingVergeBasic = ({ onError }: Props) => {
<UpdateViewer ref={updateRef} />
<BackupViewer ref={backupRef} />
<SettingItem label={t("Update Channel")}>
<Select
size="small"
value={updateChannel}
onChange={onUpdateChannelChange}
sx={{ width: 160, "> div": { py: "7.5px" } }}
>
{UPDATE_CHANNEL_OPTIONS.map((option) => (
<MenuItem key={option.value} value={option.value}>
{t(option.labelKey)}
</MenuItem>
))}
</Select>
</SettingItem>
<SettingItem label={t("Language")}>
<GuardState
value={language ?? "en"}

View File

@@ -43,6 +43,9 @@
"Proxy detail": "تفاصيل الوكيل",
"Profiles": "الملفات الشخصية",
"Update All Profiles": "تحديث جميع الملفات الشخصية",
"Update Channel": "Update Channel",
"Update Channel Stable": "Stable",
"Update Channel Autobuild": "Autobuild",
"View Runtime Config": "عرض تكوين وقت التشغيل",
"Reactivate Profiles": "إعادة تنشيط الملفات الشخصية",
"Paste": "لصق",

View File

@@ -45,6 +45,9 @@
"Proxy detail": "Knotendetails anzeigen",
"Profiles": "Abonnement",
"Update All Profiles": "Alle Abonnements aktualisieren",
"Update Channel": "Update Channel",
"Update Channel Stable": "Stable",
"Update Channel Autobuild": "Autobuild",
"View Runtime Config": "Laufzeit-Abonnement anzeigen",
"Reactivate Profiles": "Abonnement erneut aktivieren",
"Paste": "Einfügen",

View File

@@ -59,6 +59,9 @@
"Proxy detail": "Proxy detail",
"Profiles": "Profiles",
"Update All Profiles": "Update All Profiles",
"Update Channel": "Update Channel",
"Update Channel Stable": "Stable",
"Update Channel Autobuild": "Autobuild",
"View Runtime Config": "View Runtime Config",
"Reactivate Profiles": "Reactivate Profiles",
"Paste": "Paste",

View File

@@ -45,6 +45,9 @@
"Proxy detail": "Mostrar detalles del nodo",
"Profiles": "Suscripciones",
"Update All Profiles": "Actualizar todas las suscripciones",
"Update Channel": "Update Channel",
"Update Channel Stable": "Stable",
"Update Channel Autobuild": "Autobuild",
"View Runtime Config": "Ver configuración en tiempo de ejecución",
"Reactivate Profiles": "Reactivar suscripciones",
"Paste": "Pegar",

View File

@@ -43,6 +43,9 @@
"Proxy detail": "جزئیات پراکسی",
"Profiles": "پروفایل‌ها",
"Update All Profiles": "به‌روزرسانی همه پروفایل‌ها",
"Update Channel": "Update Channel",
"Update Channel Stable": "Stable",
"Update Channel Autobuild": "Autobuild",
"View Runtime Config": "مشاهده پیکربندی زمان اجرا",
"Reactivate Profiles": "فعال‌سازی مجدد پروفایل‌ها",
"Paste": "چسباندن",

View File

@@ -43,6 +43,9 @@
"Proxy detail": "Detail Proksi",
"Profiles": "Profil",
"Update All Profiles": "Perbarui Semua Profil",
"Update Channel": "Update Channel",
"Update Channel Stable": "Stable",
"Update Channel Autobuild": "Autobuild",
"View Runtime Config": "Lihat Konfigurasi Runtime",
"Reactivate Profiles": "Reaktivasi Profil",
"Paste": "Tempel",

View File

@@ -45,6 +45,9 @@
"Proxy detail": "ノードの詳細を表示する",
"Profiles": "プロファイル",
"Update All Profiles": "すべてのプロファイルを更新",
"Update Channel": "Update Channel",
"Update Channel Stable": "Stable",
"Update Channel Autobuild": "Autobuild",
"View Runtime Config": "実行時のプロファイルを表示",
"Reactivate Profiles": "プロファイルを再アクティブ化",
"Paste": "貼り付け",

View File

@@ -46,6 +46,9 @@
"Proxy detail": "프록시 상세",
"Profiles": "프로필",
"Update All Profiles": "모든 프로필 업데이트",
"Update Channel": "Update Channel",
"Update Channel Stable": "Stable",
"Update Channel Autobuild": "Autobuild",
"View Runtime Config": "런타임 설정 보기",
"Reactivate Profiles": "프로필 재활성화",
"Paste": "붙여넣기",

View File

@@ -51,6 +51,9 @@
"Proxy detail": "Отображать больше сведений о прокси",
"Profiles": "Профили",
"Update All Profiles": "Обновить все профили",
"Update Channel": "Update Channel",
"Update Channel Stable": "Stable",
"Update Channel Autobuild": "Autobuild",
"View Runtime Config": "Просмотреть используемый конфиг",
"Reactivate Profiles": "Перезапустить профиль",
"Paste": "Вставить",

View File

@@ -46,6 +46,9 @@
"Proxy detail": "Vekil detayı",
"Profiles": "Profiller",
"Update All Profiles": "Tüm Profilleri Güncelle",
"Update Channel": "Update Channel",
"Update Channel Stable": "Stable",
"Update Channel Autobuild": "Autobuild",
"View Runtime Config": "Çalışma Zamanı Yapılandırmasını Görüntüle",
"Reactivate Profiles": "Profilleri Yeniden Etkinleştir",
"Paste": "Yapıştır",

View File

@@ -43,6 +43,9 @@
"Proxy detail": "Прокси турында тулы мәгълүмат",
"Profiles": "Профильләр",
"Update All Profiles": "Барлык профильләрне яңарту",
"Update Channel": "Update Channel",
"Update Channel Stable": "Stable",
"Update Channel Autobuild": "Autobuild",
"View Runtime Config": "Кулланылган конфигурацияне карау",
"Reactivate Profiles": "Профильләрне янәдән активлаштыру",
"Paste": "Кую",

View File

@@ -59,6 +59,9 @@
"Proxy detail": "展示节点细节",
"Profiles": "订阅",
"Update All Profiles": "更新所有订阅",
"Update Channel": "更新通道",
"Update Channel Stable": "Stable",
"Update Channel Autobuild": "Autobuild",
"View Runtime Config": "查看运行时订阅",
"Reactivate Profiles": "重新激活订阅",
"Paste": "粘贴",

View File

@@ -59,6 +59,9 @@
"Proxy detail": "展示節點細節",
"Profiles": "訂閱",
"Update All Profiles": "更新所有訂閱",
"Update Channel": "更新通道",
"Update Channel Stable": "Stable",
"Update Channel Autobuild": "Autobuild",
"View Runtime Config": "查看執行時訂閱",
"Reactivate Profiles": "重新啟用訂閱",
"Paste": "貼上",

View File

@@ -280,7 +280,6 @@ interface IProfileOption {
interface IProfilesConfig {
current?: string;
valid?: string[];
items?: IProfileItem[];
}

View File

@@ -5,8 +5,10 @@ import {
compareVersions,
ensureSemver,
extractSemver,
isPrereleaseVersion,
normalizeVersion,
resolveRemoteVersion,
shouldRejectUpdate,
splitVersion,
} from "@/services/update";
import type { VersionParts } from "@/services/update";
@@ -138,3 +140,53 @@ describe("resolveRemoteVersion", () => {
expect(resolveRemoteVersion(update)).toBeNull();
});
});
describe("isPrereleaseVersion", () => {
it("returns true when version has prerelease identifiers", () => {
expect(isPrereleaseVersion("1.2.3-beta.1")).toBe(true);
});
it("returns false for release versions or missing input", () => {
expect(isPrereleaseVersion("1.2.3")).toBe(false);
expect(isPrereleaseVersion(null)).toBe(false);
});
});
describe("shouldRejectUpdate", () => {
const localStable = "2.4.3";
const remoteAutobuild = "2.4.3-autobuild.1122.qwerty.r1a";
it("rejects when comparison cannot proceed in downgrade-safe way on stable channel", () => {
expect(shouldRejectUpdate("stable", -1, "2.4.2", localStable)).toBe(true);
expect(shouldRejectUpdate("stable", 0, "2.4.3", localStable)).toBe(true);
});
it("allows prerelease downgrade on autobuild channel", () => {
expect(
shouldRejectUpdate("autobuild", -1, remoteAutobuild, localStable),
).toBe(false);
});
it("rejects prerelease downgrade when base version is older", () => {
expect(
shouldRejectUpdate("autobuild", -1, "2.3.0-autobuild.1", localStable),
).toBe(true);
});
it("rejects downgrade when both versions are prereleases", () => {
expect(
shouldRejectUpdate(
"autobuild",
-1,
"2.4.3-autobuild.1122.qwerty.r1a",
"2.4.3-autobuild.1127.qwerty.r1a",
),
).toBe(true);
});
it("rejects downgrade when remote release is older even on autobuild channel", () => {
expect(shouldRejectUpdate("autobuild", -1, "2.4.2", localStable)).toBe(
true,
);
});
});

View File

@@ -1,11 +1,22 @@
import {
check,
type CheckOptions,
type Update,
} from "@tauri-apps/plugin-updater";
import { invoke } from "@tauri-apps/api/core";
import { Update, type CheckOptions } from "@tauri-apps/plugin-updater";
import {
DEFAULT_UPDATE_CHANNEL,
getStoredUpdateChannel,
type UpdateChannel,
} from "@/services/updateChannel";
import { version as appVersion } from "@root/package.json";
type NativeUpdateMetadata = {
rid: number;
currentVersion: string;
version: string;
date?: string;
body?: string | null;
rawJson: Record<string, unknown>;
};
export type VersionParts = {
main: number[];
pre: (number | string)[];
@@ -131,16 +142,92 @@ export const resolveRemoteVersion = (update: Update): string | null => {
const localVersionNormalized = normalizeVersion(appVersion);
export const checkUpdateSafe = async (
export const isPrereleaseVersion = (version: string | null): boolean => {
const parts = splitVersion(version);
return Boolean(parts?.pre.length);
};
export const shouldRejectUpdate = (
channel: UpdateChannel,
comparison: number | null,
remoteVersion: string | null,
localVersion: string | null,
): boolean => {
if (comparison === null) return false;
if (comparison === 0) return true;
if (comparison > 0) return false;
if (channel !== "stable") {
const remoteIsPrerelease = isPrereleaseVersion(remoteVersion);
const localIsPrerelease = isPrereleaseVersion(localVersion);
if (remoteIsPrerelease && !localIsPrerelease) {
const remoteParts = splitVersion(remoteVersion);
const localParts = splitVersion(localVersion);
if (!remoteParts || !localParts) return true;
const mainComparison = compareVersionParts(
{ main: remoteParts.main, pre: [] },
{ main: localParts.main, pre: [] },
);
if (mainComparison < 0) return true;
return false;
}
}
return true;
};
const normalizeHeaders = (
headers?: HeadersInit,
): Array<[string, string]> | undefined => {
if (!headers) return undefined;
const pairs = Array.from(new Headers(headers).entries());
return pairs.length > 0 ? pairs : undefined;
};
export const checkUpdateForChannel = async (
channel: UpdateChannel = DEFAULT_UPDATE_CHANNEL,
options?: CheckOptions,
): Promise<Update | null> => {
const result = await check({ ...(options ?? {}), allowDowngrades: false });
const allowDowngrades = channel !== "stable";
const metadata = await invoke<NativeUpdateMetadata | null>(
"check_update_channel",
{
channel,
headers: normalizeHeaders(options?.headers),
timeout: options?.timeout,
proxy: options?.proxy,
target: options?.target,
allowDowngrades,
},
);
if (!metadata) return null;
const result = new Update({
...metadata,
body:
typeof metadata.body === "string"
? metadata.body
: metadata.body === null
? undefined
: metadata.body,
});
if (!result) return null;
const remoteVersion = resolveRemoteVersion(result);
const comparison = compareVersions(remoteVersion, localVersionNormalized);
if (comparison !== null && comparison <= 0) {
if (
shouldRejectUpdate(
channel,
comparison,
remoteVersion,
localVersionNormalized,
)
) {
try {
await result.close();
} catch (err) {
@@ -152,4 +239,13 @@ export const checkUpdateSafe = async (
return result;
};
export const checkUpdateSafe = async (
channel?: UpdateChannel,
options?: CheckOptions,
): Promise<Update | null> => {
const resolvedChannel = channel ?? getStoredUpdateChannel();
return checkUpdateForChannel(resolvedChannel, options);
};
export type { CheckOptions };
export type { UpdateChannel } from "@/services/updateChannel";

View File

@@ -0,0 +1,57 @@
import { useLocalStorage } from "foxact/use-local-storage";
export type UpdateChannel = "stable" | "autobuild";
export const UPDATE_CHANNEL_STORAGE_KEY = "update-channel";
export const DEFAULT_UPDATE_CHANNEL: UpdateChannel = "stable";
export const UPDATE_CHANNEL_OPTIONS: Array<{
value: UpdateChannel;
labelKey: string;
}> = [
{ value: "stable", labelKey: "Update Channel Stable" },
{ value: "autobuild", labelKey: "Update Channel Autobuild" },
];
const isValidChannel = (value: unknown): value is UpdateChannel => {
return value === "stable" || value === "autobuild";
};
export const useUpdateChannel = () =>
useLocalStorage<UpdateChannel>(
UPDATE_CHANNEL_STORAGE_KEY,
DEFAULT_UPDATE_CHANNEL,
{
serializer: JSON.stringify,
deserializer: (value) => {
try {
const parsed = JSON.parse(value);
return isValidChannel(parsed) ? parsed : DEFAULT_UPDATE_CHANNEL;
} catch (ignoreErr) {
return DEFAULT_UPDATE_CHANNEL;
}
},
},
);
export const getStoredUpdateChannel = (): UpdateChannel => {
if (
typeof window === "undefined" ||
typeof window.localStorage === "undefined"
) {
return DEFAULT_UPDATE_CHANNEL;
}
const raw = window.localStorage.getItem(UPDATE_CHANNEL_STORAGE_KEY);
if (raw === null) {
return DEFAULT_UPDATE_CHANNEL;
}
try {
const parsed = JSON.parse(raw);
return isValidChannel(parsed) ? parsed : DEFAULT_UPDATE_CHANNEL;
} catch (ignoreErr) {
return DEFAULT_UPDATE_CHANNEL;
}
};