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**。 diff --git a/src/wslc/sdk_fetch.rs b/src/wslc/sdk_fetch.rs index 6de4b78..5c50567 100644 --- a/src/wslc/sdk_fetch.rs +++ b/src/wslc/sdk_fetch.rs @@ -15,7 +15,7 @@ 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::{decode_command_output, extract_version, pick_nuget_version}; const NUGET_INDEX: &str = "https://api.nuget.org/v3-flatcontainer/microsoft.wsl.containers/index.json"; @@ -251,9 +251,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(); + // `wsl.exe` writes UTF-16LE when stdout is a pipe, not a console. + let mut text = decode_command_output(&output.stdout); if text.trim().is_empty() { - text = String::from_utf8_lossy(&output.stderr).into_owned(); + text = decode_command_output(&output.stderr); } if !output.status.success() && text.trim().is_empty() { return Err(anyhow!("`{program}` exited {}", output.status)); diff --git a/src/wslc/sdk_version.rs b/src/wslc/sdk_version.rs index 1f5f4d7..abd51c3 100644 --- a/src/wslc/sdk_version.rs +++ b/src/wslc/sdk_version.rs @@ -1,4 +1,9 @@ //! 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 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 scan. #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] pub struct SemVer { @@ -42,17 +47,39 @@ pub enum WslVersionVerdict { TooOld { found: String }, } +/// 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() +} + /// Classify `wsl --version` output. /// /// Prefers a line labeled `WSL` (English or `WSL 版本`) and ignores `WSLg`. /// A single bare version token is accepted. Multiple unlabeled versions are /// treated as unreadable so a kernel version cannot pass the check by accident. +/// NUL-separated and spaced UTF-16 misreads are repaired before that scan. +/// `3.0.1.0` satisfies `>= 2.9.3` (the fourth component is ignored). #[cfg_attr(not(windows), allow(dead_code))] pub fn judge_wsl_version(output: Option<&str>) -> WslVersionVerdict { let Some(text) = output.map(str::trim).filter(|s| !s.is_empty()) else { return WslVersionVerdict::Missing; }; - let version = labeled_wsl_version(text).or_else(|| sole_version(text)); + let normalized = normalize_command_text(text); + let version = labeled_wsl_version(&normalized).or_else(|| sole_version(&normalized)); match version { Some(v) => match SemVer::parse(&v) { Some(sv) if sv >= MIN_WSL_VERSION => WslVersionVerdict::Ok(v), @@ -95,7 +122,7 @@ fn sole_version(text: &str) -> Option { found } -/// Pull the first `x.y.z` / `x.y.z.w` from command output. +/// Pull the first `x.y.z` / `x.y.z.w` from command output (fourth component dropped). pub fn extract_version(text: &str) -> Option { for token in text.split(|c: char| !(c.is_ascii_digit() || c == '.')) { if token.is_empty() { @@ -136,10 +163,177 @@ pub fn pick_nuget_version(host: &str, available: &[String]) -> Option { parsed.last().map(|(_, v)| (*v).clone()) } +fn normalize_command_text(text: &str) -> String { + let stripped: String = text.chars().filter(|c| *c != '\0').collect(); + if looks_like_spaced_utf16(&stripped) { + let collapsed = collapse_ascii_gaps(&stripped); + if collapsed != stripped { + return collapsed; + } + } + stripped +} + +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 +"; + #[test] fn extracts_version_tokens() { assert_eq!( @@ -164,6 +358,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") @@ -218,4 +416,61 @@ WSLg version: 1.0.65 ); assert_eq!(format!("{}", MIN_WSL_VERSION), "2.9.3"); } + + #[test] + fn utf16_le_chinese_3_0_1_0_meets_minimum() { + let bytes = encode_utf16_le(CHINESE_VERSION); + let text = decode_command_output(&bytes); + assert_eq!(text.lines().next(), Some("WSL 版本: 3.0.1.0")); + assert_eq!( + judge_wsl_version(Some(&text)), + WslVersionVerdict::Ok("3.0.1".into()) + ); + assert!(SemVer::parse("3.0.1.0").unwrap() >= MIN_WSL_VERSION); + + let mut bom = vec![0xFF, 0xFE]; + bom.extend(encode_utf16_le( + "WSL version: 3.0.1.0\r\nKernel version: 6.18.40.1-1\r\n", + )); + let from_stderr = decode_command_output(&bom); + assert_eq!( + judge_wsl_version(Some(&from_stderr)), + WslVersionVerdict::Ok("3.0.1".into()) + ); + } + + #[test] + fn nul_and_spaced_misread_still_accepts_3_0_1_0() { + let bytes = encode_utf16_le(CHINESE_VERSION); + let lossy = String::from_utf8_lossy(&bytes); + assert!(lossy.contains('\0')); + assert_eq!( + judge_wsl_version(Some(&lossy)), + WslVersionVerdict::Ok("3.0.1".into()) + ); + + 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!( + judge_wsl_version(Some(&spaced)), + WslVersionVerdict::Ok("3.0.1".into()) + ); + } + + #[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!( + judge_wsl_version(Some(&text)), + WslVersionVerdict::Ok("3.0.1".into()) + ); + } }