From 4eb2830fa9266233ce117f4587cc9e973ddf186e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 09:39:56 +0000 Subject: [PATCH 1/2] fix(wsl): decode UTF-16 version output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wsl --version` writes UTF-16LE when stdout is a pipe. Reading those bytes as UTF-8 splits 3.0.1.0 with NUL gaps, so first launch reports WSL as missing. Accept 3.0.1.0 as >= 2.9.3. Co-authored-by: 绿岸公式 --- src/wslc/sdk_fetch.rs | 65 ++++-- src/wslc/sdk_version.rs | 474 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 510 insertions(+), 29 deletions(-) diff --git a/src/wslc/sdk_fetch.rs b/src/wslc/sdk_fetch.rs index d512d7d..cd10e3a 100644 --- a/src/wslc/sdk_fetch.rs +++ b/src/wslc/sdk_fetch.rs @@ -15,7 +15,9 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{anyhow, Context as _, Result}; use serde::{Deserialize, Serialize}; -use super::sdk_version::{extract_version, pick_nuget_version}; +use super::sdk_version::{ + MIN_WSL_VERSION, meets_min_wsl_version, pick_nuget_version, wsl_version_from_output, +}; const NUGET_INDEX: &str = "https://api.nuget.org/v3-flatcontainer/microsoft.wsl.containers/index.json"; @@ -57,9 +59,16 @@ pub fn ensure_sdk_dir( emit(FetchEvent::Log(format!("Host version: {v}"))); v } - Err(err) => { + Err(ProbeError::TooOld(version)) => { + let message = format!( + "WSL {version} is older than {MIN_WSL_VERSION}. Run `wsl --update` and retry." + ); + emit(FetchEvent::Log(message.clone())); + return Err(anyhow!(message)); + } + Err(ProbeError::Missing(detail)) => { emit(FetchEvent::Log(format!( - "Could not read wslc/wsl version: {err:#}" + "Could not read wslc/wsl version: {detail}" ))); emit(FetchEvent::Log( "Install or update WSL (`wsl --install` / `wsl --update`), ensure it is on PATH, then retry." @@ -204,25 +213,51 @@ fn native_arch_folder() -> &'static str { } } -fn probe_host_version() -> Result { +enum ProbeError { + TooOld(String), + Missing(String), +} + +fn probe_host_version() -> std::result::Result { + let mut too_old = None; + let mut saw_output = false; for args in [ ["wslc", "version"].as_slice(), ["wslc.exe", "version"].as_slice(), ["wsl", "--version"].as_slice(), ["wsl.exe", "--version"].as_slice(), ] { - if let Ok(out) = run_hidden(args) { - if let Some(v) = extract_version(&out) { - return Ok(v); - } + let Ok((stdout, stderr)) = run_hidden(args) else { + continue; + }; + if stdout.is_empty() && stderr.is_empty() { + continue; } + saw_output = true; + let Some(version) = wsl_version_from_output(&stdout, &stderr) else { + continue; + }; + if meets_min_wsl_version(&version) { + return Ok(version); + } + too_old = Some(version); + } + if let Some(version) = too_old { + return Err(ProbeError::TooOld(version)); } - Err(anyhow!( - "could not read wslc/wsl version (is WSL installed and on PATH?)" + if saw_output { + return Err(ProbeError::Missing( + "could not parse a WSL version from `wsl --version` / `wslc version` output".into(), + )); + } + Err(ProbeError::Missing( + "could not read wslc/wsl version (is WSL installed and on PATH?)".into(), )) } -fn run_hidden(args: &[&str]) -> Result { +/// Capture stdout/stderr as bytes. `wsl.exe` writes UTF-16LE when the pipe is +/// not a console; decoding happens in `sdk_version::decode_command_output`. +fn run_hidden(args: &[&str]) -> Result<(Vec, Vec)> { let (program, rest) = args .split_first() .ok_or_else(|| anyhow!("empty command"))?; @@ -236,14 +271,10 @@ fn run_hidden(args: &[&str]) -> Result { let output = cmd .output() .with_context(|| format!("spawn `{program}`"))?; - let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); - if text.trim().is_empty() { - text = String::from_utf8_lossy(&output.stderr).into_owned(); - } - if !output.status.success() && text.trim().is_empty() { + if !output.status.success() && output.stdout.is_empty() && output.stderr.is_empty() { return Err(anyhow!("`{program}` exited {}", output.status)); } - Ok(text) + Ok((output.stdout, output.stderr)) } fn try_active_sdk_dir( diff --git a/src/wslc/sdk_version.rs b/src/wslc/sdk_version.rs index 55b046a..c6fce87 100644 --- a/src/wslc/sdk_version.rs +++ b/src/wslc/sdk_version.rs @@ -1,10 +1,20 @@ //! Host / NuGet version matching (no Windows-only deps). +//! +//! `wsl.exe --version` writes UTF-16LE when stdout is a pipe (no console). +//! Reading those bytes as UTF-8/Latin-1 leaves NUL gaps (`W\0S\0L`) or, once +//! NULs are shown as spaces, `W S L Hr,g: 3 . 0 . 1 . 0` — `版本` is the +//! UTF-16LE code units `48 72 2C 67`. Either form hides `3.0.1.0` from a +//! naive `x.y.z` scan, so a new enough WSL looks missing. + +/// Oldest WSL release that ships WSL Containers. +pub const MIN_WSL_VERSION: &str = "2.9.3"; #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] pub struct SemVer { pub major: u32, pub minor: u32, pub patch: u32, + pub revision: u32, } impl SemVer { @@ -13,27 +23,83 @@ impl SemVer { let major = parts.next()?.parse().ok()?; let minor = parts.next()?.parse().ok()?; let patch = parts.next().unwrap_or("0").parse().ok()?; + let revision = match parts.next() { + Some(part) => part.parse().ok()?, + None => 0, + }; Some(Self { major, minor, patch, + revision, }) } } -/// Pull the first `x.y.z` / `x.y.z.w` from command output. +/// `true` when `version` is `2.9.3` or newer. `3.0.1.0` qualifies; the fourth +/// component is the Windows revision and missing components count as zero. +pub fn meets_min_wsl_version(version: &str) -> bool { + match (SemVer::parse(version), SemVer::parse(MIN_WSL_VERSION)) { + (Some(version), Some(min)) => version >= min, + _ => false, + } +} + +/// Decode a captured stdout/stderr buffer. +/// +/// Order: UTF-16 BOM, then a UTF-16LE NUL pattern, then strip NULs that sit +/// between ASCII bytes, then lossy UTF-8. +pub fn decode_command_output(bytes: &[u8]) -> String { + if bytes.is_empty() { + return String::new(); + } + if let Some(text) = decode_utf16(bytes) + && plausible_text(&text) + { + return text; + } + if let Some(text) = strip_nuls_between_ascii(bytes) { + return text; + } + String::from_utf8_lossy(bytes).into_owned() +} + +/// Read a WSL / `wslc` version from raw process pipes. +pub fn wsl_version_from_output(stdout: &[u8], stderr: &[u8]) -> Option { + let out = decode_command_output(stdout); + let err = decode_command_output(stderr); + let combined = match (out.trim().is_empty(), err.trim().is_empty()) { + (true, _) => err, + (_, true) => out, + _ => format!("{out}\n{err}"), + }; + extract_wsl_version(&combined) +} + +/// Pull the first `x.y.z` (dropping a fourth component) from command output. pub fn extract_version(text: &str) -> Option { - for token in text.split(|c: char| !(c.is_ascii_digit() || c == '.')) { - if token.is_empty() { - continue; - } - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() >= 3 - && parts - .iter() - .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())) - { - return Some(format!("{}.{}.{}", parts[0], parts[1], parts[2])); + let full = extract_wsl_version(text)?; + let semver = SemVer::parse(&full)?; + Some(format!( + "{}.{}.{}", + semver.major, semver.minor, semver.patch + )) +} + +/// WSL product version from `wsl --version` / `wslc version` text. +/// +/// Prefers a `WSL version` / `WSL 版本` line over kernel, WSLg, and Windows +/// lines. Also accepts NUL-separated UTF-16 misreads and the spaced form +/// those NULs become when displayed. +pub fn extract_wsl_version(text: &str) -> Option { + let stripped: String = text.chars().filter(|c| *c != '\0').collect(); + if let Some(version) = extract_wsl_version_raw(&stripped) { + return Some(version); + } + if looks_like_spaced_utf16(&stripped) { + let collapsed = collapse_ascii_gaps(&stripped); + if collapsed != stripped { + return extract_wsl_version_raw(&collapsed); } } None @@ -62,10 +128,300 @@ pub fn pick_nuget_version(host: &str, available: &[String]) -> Option { parsed.last().map(|(_, v)| (*v).clone()) } +fn extract_wsl_version_raw(text: &str) -> Option { + let lines = non_empty_lines(text); + + for line in &lines { + if is_explicit_wsl_version_line(line) + && let Some(version) = first_version_token(line) + { + return Some(version); + } + } + + // UTF-16 read as Latin-1 turns `版本` into `Hr,g`, so the label is gone + // but the line still starts with WSL. Skip kernel / WSLg / Windows lines. + for line in &lines { + if mentions_wsl_product(line) + && !is_non_wsl_component_line(line) + && let Some(version) = first_version_token(line) + { + return Some(version); + } + } + + bare_tool_version(&lines) +} + +fn non_empty_lines(text: &str) -> Vec<&str> { + text.lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect() +} + +fn is_explicit_wsl_version_line(line: &str) -> bool { + if !mentions_wsl_product(line) || is_non_wsl_component_line(line) { + return false; + } + let lower = line.to_ascii_lowercase(); + lower.contains("version") || line.contains("版本") +} + +fn mentions_wsl_product(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + if lower.contains("wslg") { + return false; + } + lower.contains("wsl") +} + +fn is_non_wsl_component_line(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.contains("wslg") + || lower.contains("kernel") + || lower.contains("msrdc") + || lower.contains("direct3d") + || lower.contains("dxcore") + || lower.contains("windows") + || line.contains("内核") +} + +/// `wslc version` often prints a bare `x.y.z` or `wslc x.y.z` with no banner. +fn bare_tool_version(lines: &[&str]) -> Option { + if lines.len() != 1 { + return None; + } + let line = lines[0]; + if is_non_wsl_component_line(line) { + return None; + } + let token = first_version_token(line)?; + let lower = line.to_ascii_lowercase(); + if line.trim() == token || lower.contains("wslc") || mentions_wsl_product(line) { + Some(token) + } else { + None + } +} + +fn first_version_token(text: &str) -> Option { + let bytes = text.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if !bytes[i].is_ascii_digit() { + i += 1; + continue; + } + let start = i; + let mut dots = 0; + let mut prev_digit = true; + i += 1; + while i < bytes.len() { + if bytes[i].is_ascii_digit() { + prev_digit = true; + i += 1; + } else if bytes[i] == b'.' && prev_digit { + dots += 1; + prev_digit = false; + i += 1; + } else { + break; + } + } + if !prev_digit && i > start { + i -= 1; + } + if dots >= 2 && prev_digit { + return Some(truncate_components(&text[start..i], 4)); + } + } + None +} + +fn truncate_components(token: &str, max_components: usize) -> String { + let mut dots = 0; + for (idx, ch) in token.char_indices() { + if ch == '.' { + dots += 1; + if dots == max_components { + return token[..idx].to_string(); + } + } + } + token.to_string() +} + +fn decode_utf16(bytes: &[u8]) -> Option { + if bytes.starts_with(&[0xFF, 0xFE]) { + return Some(utf16_units_to_string(&bytes[2..], true)); + } + if bytes.starts_with(&[0xFE, 0xFF]) { + return Some(utf16_units_to_string(&bytes[2..], false)); + } + if looks_like_utf16_le(bytes) { + return Some(utf16_units_to_string(bytes, true)); + } + None +} + +fn utf16_units_to_string(bytes: &[u8], little_endian: bool) -> String { + let units: Vec = bytes + .chunks(2) + .map(|chunk| { + if chunk.len() == 2 { + if little_endian { + u16::from_le_bytes([chunk[0], chunk[1]]) + } else { + u16::from_be_bytes([chunk[0], chunk[1]]) + } + } else if little_endian { + chunk[0] as u16 + } else { + (chunk[0] as u16) << 8 + } + }) + .collect(); + String::from_utf16_lossy(&units) + .trim_start_matches('\u{feff}') + .to_string() +} + +/// ASCII-heavy UTF-16LE: the high byte of most units is NUL. +fn looks_like_utf16_le(bytes: &[u8]) -> bool { + if bytes.len() < 8 || !bytes.len().is_multiple_of(2) { + return false; + } + let mut odd_zeros = 0usize; + let mut even_zeros = 0usize; + for pair in bytes.as_chunks::<2>().0 { + if pair[0] == 0 { + even_zeros += 1; + } + if pair[1] == 0 { + odd_zeros += 1; + } + } + let pairs = bytes.len() / 2; + odd_zeros * 2 > pairs && odd_zeros > even_zeros.saturating_mul(4) +} + +fn plausible_text(text: &str) -> bool { + let total = text.chars().count(); + if total == 0 { + return false; + } + let printable = text + .chars() + .filter(|c| !c.is_control() || matches!(*c, '\n' | '\r' | '\t')) + .count(); + printable * 4 >= total * 3 +} + +/// Fallback when the buffer is not even-length UTF-16 but still has +/// `ASCII, NUL` pairs (a truncated or mixed capture). +fn strip_nuls_between_ascii(bytes: &[u8]) -> Option { + let mut pairs = 0usize; + let mut i = 0usize; + while i + 1 < bytes.len() { + if bytes[i] != 0 && bytes[i].is_ascii() && bytes[i + 1] == 0 { + pairs += 1; + i += 2; + } else { + i += 1; + } + } + if pairs < 3 { + return None; + } + let stripped: Vec = bytes.iter().copied().filter(|b| *b != 0).collect(); + let text = String::from_utf8_lossy(&stripped).into_owned(); + if text.trim().is_empty() { + None + } else { + Some(text) + } +} + +fn looks_like_spaced_utf16(text: &str) -> bool { + let chars: Vec = text.chars().collect(); + let mut hits = 0usize; + for window in chars.windows(3) { + if window[1] == ' ' && is_gap_glue(window[0]) && is_gap_glue(window[2]) { + hits += 1; + } + } + hits >= 3 +} + +fn is_gap_glue(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '.' +} + +/// Join ASCII that was split by the NUL bytes of UTF-16LE (`3 . 0 . 1` → `3.0.1`). +/// Newlines stay put so a trailing digit cannot merge into the next line. +fn collapse_ascii_gaps(text: &str) -> String { + let mut out = String::new(); + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\n' || c == '\r' { + out.push(c); + continue; + } + if c.is_whitespace() { + let prev = match out.chars().last() { + Some('\n' | '\r') | None => None, + other => other, + }; + let next = chars.peek().copied(); + if let (Some(prev), Some(next)) = (prev, next) + && next != '\n' + && next != '\r' + && is_gap_glue(prev) + && is_gap_glue(next) + { + continue; + } + if !matches!(out.chars().last(), Some(' ' | '\n' | '\r') | None) { + out.push(' '); + } + continue; + } + out.push(c); + } + out +} + #[cfg(test)] mod tests { use super::*; + fn encode_utf16_le(text: &str) -> Vec { + text.encode_utf16() + .flat_map(|unit| unit.to_le_bytes()) + .collect() + } + + const CHINESE_VERSION: &str = "\ +WSL 版本: 3.0.1.0 +内核版本: 6.18.40.1-1 +WSLg 版本: 1.0.66 +MSRDC 版本: 1.2.6074 +Direct3D 版本: 1.611.1-81528511 +DXCore 版本: 10.0.26100.1-240331-1435.ge-release +Windows 版本: 10.0.26100.3476 +"; + + const ENGLISH_VERSION: &str = "\ +WSL version: 2.9.3.0 +Kernel version: 6.6.87.1-1 +WSLg version: 1.0.66 +MSRDC version: 1.2.6074 +Direct3D version: 1.611.1-81528511 +DXCore version: 10.0.26100.1-240331-1435.ge-release +Windows version: 10.0.22621.2715 +"; + #[test] fn extracts_version_tokens() { assert_eq!( @@ -90,6 +446,10 @@ mod tests { pick_nuget_version("3.0.0", &available).as_deref(), Some("3.0.1") ); + assert_eq!( + pick_nuget_version("3.0.1.0", &available).as_deref(), + Some("3.0.1") + ); assert_eq!( pick_nuget_version("4.0.0", &available).as_deref(), Some("3.0.1") @@ -99,4 +459,94 @@ mod tests { Some("3.0.1") ); } + + #[test] + fn utf16_le_chinese_wsl_version_meets_minimum() { + let bytes = encode_utf16_le(CHINESE_VERSION); + let version = wsl_version_from_output(&bytes, b"").expect("version"); + assert_eq!(version, "3.0.1.0"); + assert!(meets_min_wsl_version(&version)); + assert_eq!( + decode_command_output(&bytes).lines().next(), + Some("WSL 版本: 3.0.1.0") + ); + } + + #[test] + fn utf16_le_bom_and_stderr_only() { + let mut bytes = vec![0xFF, 0xFE]; + bytes.extend(encode_utf16_le( + "WSL version: 3.0.1.0\r\nKernel version: 6.18.40.1-1\r\n", + )); + assert_eq!( + wsl_version_from_output(b"", &bytes).as_deref(), + Some("3.0.1.0") + ); + } + + #[test] + fn english_utf8_boundary_and_kernel_is_not_wsl() { + assert_eq!( + extract_wsl_version(ENGLISH_VERSION).as_deref(), + Some("2.9.3.0") + ); + assert!(meets_min_wsl_version("2.9.3.0")); + assert!(meets_min_wsl_version("2.9.3")); + assert!(!meets_min_wsl_version("2.9.2.9")); + assert!(!meets_min_wsl_version("1.2.5")); + assert_eq!( + extract_wsl_version("Kernel version: 6.18.40.1-1\nWSL version: 3.0.1.0\n").as_deref(), + Some("3.0.1.0") + ); + assert_eq!(extract_wsl_version("内核版本: 6.18.40.1-1\n"), None); + } + + #[test] + fn nul_separated_utf16_misread_and_spaced_regression() { + let bytes = encode_utf16_le(CHINESE_VERSION); + let lossy = String::from_utf8_lossy(&bytes); + assert!( + lossy.contains('\0'), + "UTF-16LE read as UTF-8 must keep NUL gaps" + ); + assert_eq!(extract_wsl_version(&lossy).as_deref(), Some("3.0.1.0")); + + // Log viewers (and a Latin-1 decode) show those NULs as spaces: + // `W S L Hr,g: 3 . 0 . 1 . 0` + let spaced = lossy.replace('\0', " "); + assert!( + spaced.contains("Hr,g"), + "版本 misread as Latin-1 should surface as Hr,g, got {spaced:?}" + ); + assert!(spaced.contains("3 . 0 . 1 . 0"), "got {spaced:?}"); + assert_eq!(extract_wsl_version(&spaced).as_deref(), Some("3.0.1.0")); + assert!(meets_min_wsl_version( + extract_wsl_version(&spaced).as_deref().unwrap() + )); + } + + #[test] + fn nul_strip_fallback_when_utf16_length_is_odd() { + let mut bytes = encode_utf16_le("WSL version: 3.0.1.0\n"); + bytes.push(b'A'); + assert_eq!(bytes.len() % 2, 1); + assert!(super::decode_utf16(&bytes).is_none()); + let text = decode_command_output(&bytes); + assert_eq!(extract_wsl_version(&text).as_deref(), Some("3.0.1.0")); + } + + #[test] + fn rejects_wslg_line_and_accepts_wslc_banner() { + let text = "\ +WSLg version: 1.0.66 +Kernel version: 6.18.40.1-1 +WSL version: 3.0.1.0 +"; + assert_eq!(extract_wsl_version(text).as_deref(), Some("3.0.1.0")); + assert_eq!( + extract_wsl_version("wslc 3.0.1\n").as_deref(), + Some("3.0.1") + ); + assert_eq!(extract_version("3.0.1.0\n").as_deref(), Some("3.0.1")); + } } From 831562f29b169ad52d934c098a3787f45727baa4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 09:44:48 +0000 Subject: [PATCH 2/2] chore: release 0.1.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the UTF-16 WSL version fix under 0.1.2. Co-authored-by: 绿岸公式 --- CHANGELOG.md | 10 +++++++++- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 8 ++++---- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 579ec55..36f5536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ ## [Unreleased] +## [0.1.2] - 2026-10-11 + +### 修复 + +- 首次启动读取 `wsl --version` 时按 UTF-16LE 解码。以前按 UTF-8 读,版本号被空字节拆开(日志里像 `W S L Hr,g: 3 . 0 . 1 . 0`),已安装的 WSL 3.0.1.0 会被当成未检测到。3.0.1.0 满足 2.9.3+。 + - First-launch `wsl --version` is decoded as UTF-16LE, so WSL 3.0.1.0 counts as 2.9.3 or newer. + ## [0.1.1] - 2026-10-11 ### 新增 @@ -38,6 +45,7 @@ - 容器日志走 [`timeroute/wslc-rs`](https://github.com/timeroute/wslc-rs) 的 `LogsOptions`(`wslc --session wslc-desktop container logs`)。 - Container logs use the `wslc-rs` fork rather than a separate CLI path. -[Unreleased]: https://github.com/timeroute/wslc-desktop/compare/v0.1.1...HEAD +[Unreleased]: https://github.com/timeroute/wslc-desktop/compare/v0.1.2...HEAD +[0.1.2]: https://github.com/timeroute/wslc-desktop/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/timeroute/wslc-desktop/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/timeroute/wslc-desktop/releases/tag/v0.1.0 diff --git a/Cargo.lock b/Cargo.lock index 5db3ba7..81b92bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8466,7 +8466,7 @@ dependencies = [ [[package]] name = "wslc-desktop" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "gpui-kit", diff --git a/Cargo.toml b/Cargo.toml index 9b5516c..05f4c18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wslc-desktop" -version = "0.1.1" +version = "0.1.2" edition = "2024" description = "Lightweight desktop manager for Windows WSL Containers (WSLc)" license = "Apache-2.0" diff --git a/README.md b/README.md index 099eba1..08a1021 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ cargo test ```powershell cargo wix # 产物:target\wix\WSLc.Desktop--x86_64.msi -# 当前 0.1.1:WSLc.Desktop-0.1.1-x86_64.msi +# 当前 0.1.2:WSLc.Desktop-0.1.2-x86_64.msi ``` 可选:`cargo wix --no-build`(已有 release 产物时)、`cargo wix --sign`(需 `signtool`)。 @@ -64,9 +64,9 @@ cargo wix 打 `v*` tag 会触发 [Windows MSI](.github/workflows/windows-msi.yml) 工作流:构建 MSI 并创建 Release 挂载安装包。 ```powershell -# 版本与 Cargo.toml 对齐,例如 0.1.1 → v0.1.1 -git tag v0.1.1 -git push origin v0.1.1 +# 版本与 Cargo.toml 对齐,例如 0.1.2 → v0.1.2 +git tag v0.1.2 +git push origin v0.1.2 ``` 也可:push / PR 到 `main` 仅上传 artifact;或 Actions → **Windows MSI** → **Run workflow**。