Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

### 新增
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ cargo test
```powershell
cargo wix
# 产物:target\wix\WSLc.Desktop-<version>-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`)。
Expand All @@ -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**。
Expand Down
7 changes: 4 additions & 3 deletions src/wslc/sdk_fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -251,9 +251,10 @@ fn run_hidden(args: &[&str]) -> Result<String> {
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));
Expand Down
259 changes: 257 additions & 2 deletions src/wslc/sdk_version.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -95,7 +122,7 @@ fn sole_version(text: &str) -> Option<String> {
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<String> {
for token in text.split(|c: char| !(c.is_ascii_digit() || c == '.')) {
if token.is_empty() {
Expand Down Expand Up @@ -136,10 +163,177 @@ pub fn pick_nuget_version(host: &str, available: &[String]) -> Option<String> {
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<String> {
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<u16> = 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<String> {
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<u8> = 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<char> = 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<u8> {
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!(
Expand All @@ -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")
Expand Down Expand Up @@ -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())
);
}
}
Loading