diff --git a/.github/workflows/autobuild.yml b/.github/workflows/autobuild.yml index d9b308bd..b8cbc2dc 100644 --- a/.github/workflows/autobuild.yml +++ b/.github/workflows/autobuild.yml @@ -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 diff --git a/scripts/release-version.mjs b/scripts/release-version.mjs index bc177a29..9b522972 100644 --- a/scripts/release-version.mjs +++ b/scripts/release-version.mjs @@ -5,12 +5,12 @@ * pnpm release-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()}`; } diff --git a/scripts/updater.mjs b/scripts/updater.mjs index 9291d6ab..e61dc4b8 100644 --- a/scripts/updater.mjs +++ b/scripts/updater.mjs @@ -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, ); } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d433be36..dd7fb1ed 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1158,6 +1158,7 @@ dependencies = [ "tauri-plugin-window-state", "tokio", "tokio-stream", + "url", "users", "warp", "winapi", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index db31304e..d9445f85 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -87,6 +87,7 @@ 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" diff --git a/src-tauri/src/cmd/mod.rs b/src-tauri/src/cmd/mod.rs index 6c748687..1f8efd09 100644 --- a/src-tauri/src/cmd/mod.rs +++ b/src-tauri/src/cmd/mod.rs @@ -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::*; diff --git a/src-tauri/src/cmd/updater.rs b/src-tauri/src/cmd/updater.rs new file mode 100644 index 00000000..07d5071d --- /dev/null +++ b/src-tauri/src/cmd/updater.rs @@ -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, + body: Option, + 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 { + 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> { + 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( + webview: Webview, + channel: String, + headers: Option>, + timeout: Option, + proxy: Option, + target: Option, + allow_downgrades: Option, +) -> CmdResult> { + 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)) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index da6c5710..fc4c10c0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/components/home/system-info-card.tsx b/src/components/home/system-info-card.tsx index e9f483b7..0aeecea5 100644 --- a/src/components/home/system-info-card.tsx +++ b/src/components/home/system-info-card.tsx @@ -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 { diff --git a/src/components/layout/update-button.tsx b/src/components/layout/update-button.tsx index d5c8b4ff..874d9188 100644 --- a/src/components/layout/update-button.tsx +++ b/src/components/layout/update-button.tsx @@ -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(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, diff --git a/src/components/setting/mods/update-viewer.tsx b/src/components/setting/mods/update-viewer.tsx index 8efaa3fe..f5583ebf 100644 --- a/src/components/setting/mods/update-viewer.tsx +++ b/src/components/setting/mods/update-viewer.tsx @@ -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 }) { const { t } = useTranslation(); @@ -26,12 +27,17 @@ export function UpdateViewer({ ref }: { ref?: Ref }) { 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); diff --git a/src/components/setting/setting-verge-advanced.tsx b/src/components/setting/setting-verge-advanced.tsx index c88348ab..bf8ac0df 100644 --- a/src/components/setting/setting-verge-advanced.tsx +++ b/src/components/setting/setting-verge-advanced.tsx @@ -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(null); const backupRef = useRef(null); const liteModeRef = useRef(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 { diff --git a/src/components/setting/setting-verge-basic.tsx b/src/components/setting/setting-verge-basic.tsx index 15b48132..7bce91e3 100644 --- a/src/components/setting/setting-verge-basic.tsx +++ b/src/components/setting/setting-verge-basic.tsx @@ -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(null); const updateRef = useRef(null); const backupRef = useRef(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) => { + const nextChannel = event.target.value as UpdateChannel; + setUpdateChannel(nextChannel); + }, + [setUpdateChannel], + ); + return ( @@ -89,6 +109,21 @@ const SettingVergeBasic = ({ onError }: Props) => { + + + + { 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, + ); + }); +}); diff --git a/src/services/update.ts b/src/services/update.ts index a9b0fe85..c56af1e6 100644 --- a/src/services/update.ts +++ b/src/services/update.ts @@ -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; +}; + 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 => { - const result = await check({ ...(options ?? {}), allowDowngrades: false }); + const allowDowngrades = channel !== "stable"; + + const metadata = await invoke( + "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 => { + const resolvedChannel = channel ?? getStoredUpdateChannel(); + return checkUpdateForChannel(resolvedChannel, options); +}; + export type { CheckOptions }; +export type { UpdateChannel } from "@/services/updateChannel"; diff --git a/src/services/updateChannel.ts b/src/services/updateChannel.ts new file mode 100644 index 00000000..34c15dc6 --- /dev/null +++ b/src/services/updateChannel.ts @@ -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( + 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; + } +};