diff --git a/CHANGELOG.md b/CHANGELOG.md index 45e2f09..dd7ae53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ ## [Unreleased] +### 变更 + +- 启动门禁只读注册表 `HKLM\...\Lxss\Msi\Version`(与 WSL 自身一致),不再调用 `wsl --version`。缺键即视为未安装。SDK 加载后的版本仍用 `wslc::Service::version()`。 + - First-launch WSL check reads only the MSI registry version; no CLI. Live SDK version still comes from `Service::version()`. +- 拆分 SDK 安装逻辑:`wsl_probe`(版本探测)、`sdk_cache`(本地 stamp/DLL)、`sdk_fetch`(NuGet 下载解压),主路径只保留「缓存命中 / 列包 / 下载」。 + - Split SDK install into probe / cache / fetch modules so the download path stays short. +- 删除面向 `wsl --version` 的 UTF-16 / 多行文本解析;`sdk_version` 只保留 SemVer 与 NuGet 匹配。 + - Drop CLI text/UTF-16 parsing; `sdk_version` keeps SemVer + NuGet matching only. +- 移除未使用的 `session_stats` / `SessionStats` / `Cmd::Stats` 路径。 + - Remove unused session-stats plumbing. + ## [0.1.2] - 2026-10-11 ### 修复 diff --git a/Cargo.toml b/Cargo.toml index 2d55283..6f9a2a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ ureq = { version = "2.12", default-features = true, features = ["json"] } zip = { version = "2", default-features = false, features = ["deflate"] } windows-sys = { version = "0.61", features = [ "Win32_Foundation", + "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", ] } diff --git a/README.md b/README.md index 0f34998..58cc3ec 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ |------|----------|----------| | ![总览](docs/screenshots/overview.png) | ![容器日志](docs/screenshots/containers-logs.png) | ![拉取镜像](docs/screenshots/images-pull.png) | -用独立会话 `wslc-desktop`(`%LOCALAPPDATA%\WSLcDesktop\`)管理容器、镜像与 VHD 卷,与默认 `wslc` CLI 会话隔离。Rust 绑定使用自维护 fork [`timeroute/wslc-rs`](https://github.com/timeroute/wslc-rs)(OpenContainer / Logs / named volumes)。MSI **不捆绑** `wslcsdk.dll`:首次启动以阻塞弹窗安装/更新 SDK,之后复用 `%LOCALAPPDATA%\WSLcDesktop\sdk\` 缓存(仅在版本不兼容或手动重试时再下载)。同一用户会话只允许一个实例。 +用独立会话 `wslc-desktop`(`%LOCALAPPDATA%\WSLcDesktop\`)管理容器、镜像与 VHD 卷,与默认 `wslc` CLI 会话隔离。Rust 绑定使用自维护 fork [`timeroute/wslc-rs`](https://github.com/timeroute/wslc-rs)(OpenContainer / Logs / named volumes)。MSI **不捆绑** `wslcsdk.dll`:首次启动以阻塞弹窗安装/更新 SDK,之后复用 `%LOCALAPPDATA%\WSLcDesktop\sdk\` 缓存(仅在版本不兼容或手动重试时再下载)。启动门禁只读注册表 `Lxss\Msi\Version`(与 WSL 一致),SDK 连上后版本来自 `Service::version()`。同一用户会话只允许一个实例。 ## 安装(推荐) diff --git a/src/i18n.rs b/src/i18n.rs index 9076b29..d9399d4 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -357,7 +357,7 @@ pub static EN: Strings = Strings { sdk_setup_step_checking: "Checking", sdk_setup_step_passed: "Passed", sdk_setup_step_failed: "Failed", - sdk_wsl_missing: "WSL is missing, or its version could not be read. Install or update WSL with `wsl --update`, then retry.", + sdk_wsl_missing: "WSL is missing, or its package version was not found in the registry. Install or update WSL with `wsl --update`, then retry.", sdk_wsl_too_old: "WSL {found} is older than {required}. Run `wsl --update`, then retry.", sdk_wsl_ok: "WSL {version} (requires {required}+)", sdk_install_ok: "SDK {version}", @@ -568,7 +568,7 @@ pub static ZH: Strings = Strings { sdk_setup_step_checking: "检查中", sdk_setup_step_passed: "通过", sdk_setup_step_failed: "失败", - sdk_wsl_missing: "未检测到 WSL,或无法读取版本。请运行 `wsl --update` 安装或更新后再重试。", + sdk_wsl_missing: "未检测到 WSL,或注册表中没有包版本。请运行 `wsl --update` 安装或更新后再重试。", sdk_wsl_too_old: "WSL {found} 低于要求的 {required}。请运行 `wsl --update` 后再重试。", sdk_wsl_ok: "WSL {version}(要求 {required}+)", sdk_install_ok: "SDK {version}", diff --git a/src/wslc/live.rs b/src/wslc/live.rs index 77bd5fb..e7f9065 100644 --- a/src/wslc/live.rs +++ b/src/wslc/live.rs @@ -12,7 +12,7 @@ use super::catalog::{Catalog, PersistedContainer}; use super::prefs::SessionPrefs; use super::{ split_ref, BackendKind, ContainerInfo, CreateContainerRequest, EngineStatus, ImageInfo, - PortMapping, PullEvent, SessionStats, VolumeInfo, VolumeMapping, + PortMapping, PullEvent, VolumeInfo, VolumeMapping, }; const SESSION_NAME: &str = "wslc-desktop"; @@ -21,7 +21,6 @@ const STOP_TIMEOUT: Duration = Duration::from_secs(10); pub enum Cmd { Status(Sender), - Stats(Sender), ListContainers { all: bool, reply: Sender>>, @@ -409,28 +408,6 @@ impl LiveState { } } - fn stats(&self) -> SessionStats { - let running = self - .containers - .iter() - .filter(|c| { - matches!( - c.handle.state(), - Ok(wslc::ContainerState::Running) - ) - }) - .count(); - let images = self.session.list_images().map(|v| v.len()).unwrap_or(0); - SessionStats { - cpu_count: self.prefs.cpu_count, - memory_mb: self.prefs.memory_mb, - containers: self.containers.len(), - running, - images, - volumes: self.volumes.len(), - } - } - fn find_container_mut(&mut self, key: &str) -> Result<&mut TrackedContainer> { self.containers .iter_mut() @@ -923,9 +900,6 @@ fn live_worker(mut state: LiveState, rx: mpsc::Receiver) { Cmd::Status(reply) => { let _ = reply.send(state.status()); } - Cmd::Stats(reply) => { - let _ = reply.send(state.stats()); - } Cmd::ListContainers { all, reply } => { let _ = reply.send(state.list_containers(all)); } diff --git a/src/wslc/mod.rs b/src/wslc/mod.rs index 92123f3..71c4572 100644 --- a/src/wslc/mod.rs +++ b/src/wslc/mod.rs @@ -15,6 +15,8 @@ mod live; mod paths; mod prefs; #[cfg(windows)] +mod sdk_cache; +#[cfg(windows)] mod sdk_fetch; mod sdk_version; pub mod setup_log; @@ -22,6 +24,8 @@ pub mod setup_log; mod syspath; pub mod templates; mod types; +#[cfg(windows)] +mod wsl_probe; pub use prefs::SessionPrefs; pub use templates::{ContainerTemplate, TemplateEditError, TemplateStore, UpsertKind}; @@ -32,6 +36,7 @@ pub fn min_wsl_version_text() -> String { sdk_version::MIN_WSL_VERSION.to_string() } +#[cfg_attr(not(windows), allow(dead_code))] // production use is in `live` (Windows) pub(super) fn split_ref(reference: &str) -> (String, String) { if let Some((repo, tag)) = reference.rsplit_once(':') { if !tag.contains('/') { @@ -223,21 +228,19 @@ impl WslcClient { "Checking WSL version (require {required} or newer)…" ))); - let output = sdk_fetch::probe_wsl_version_output(); - if let Some(out) = &output { - for line in out.lines() { - let line = line.trim(); - if !line.is_empty() { - let _ = tx.send(SdkSetupEvent::Log(format!("wsl: {line}"))); - } - } + // Registry only (same key WSL writes). No CLI — missing key means not installed. + let found = wsl_probe::package_version(); + if let Some(version) = &found { + let _ = tx.send(SdkSetupEvent::Log(format!( + "WSL package version from registry (Lxss\\Msi): {version}" + ))); } else { let _ = tx.send(SdkSetupEvent::Log( - "wsl --version produced no output (is WSL installed and on PATH?)".into(), + "Registry Lxss\\Msi\\Version missing (WSL MSI not installed?)".into(), )); } - match sdk_version::judge_wsl_version(output.as_deref()) { + match sdk_version::judge_package_version(found.as_deref()) { sdk_version::WslVersionVerdict::Ok(version) => { let _ = tx.send(SdkSetupEvent::Log(format!( "WSL {version} meets the {required}+ requirement" @@ -255,7 +258,7 @@ impl WslcClient { state: SetupStepState::Failed(SetupFault::WslMissing), }); let _ = tx.send(SdkSetupEvent::Finished(Err( - "WSL is missing or its version could not be read".into(), + "WSL is missing or its version could not be read from the registry".into(), ))); false } @@ -426,25 +429,6 @@ impl WslcClient { } } - #[cfg_attr(not(windows), allow(dead_code))] - #[allow(dead_code)] // reserved for richer Overview refresh - pub fn session_stats(&self) -> SessionStats { - match &self.inner.backend { - #[cfg(windows)] - Backend::Live { tx, .. } => { - let (reply_tx, reply_rx) = std::sync::mpsc::channel(); - if tx.send(live::Cmd::Stats(reply_tx)).is_ok() { - if let Ok(stats) = reply_rx.recv() { - return stats; - } - } - SessionStats::default() - } - #[cfg(not(windows))] - Backend::Unsupported => SessionStats::default(), - } - } - pub fn list_volumes(&self) -> Result> { match &self.inner.backend { #[cfg(windows)] diff --git a/src/wslc/sdk_cache.rs b/src/wslc/sdk_cache.rs new file mode 100644 index 0000000..9fc75c2 --- /dev/null +++ b/src/wslc/sdk_cache.rs @@ -0,0 +1,79 @@ +//! On-disk cache for extracted `wslcsdk.dll` under `%LOCALAPPDATA%\WSLcDesktop\sdk`. + +#![cfg(windows)] + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +use super::paths; +use super::sdk_version::cache_compatible; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActiveStamp { + pub host_version: String, + pub nuget_version: String, + pub arch: String, +} + +pub fn root() -> PathBuf { + paths::app_data_dir().join("sdk") +} + +pub fn native_arch() -> &'static str { + match std::env::consts::ARCH { + "x86_64" => "win-x64", + "aarch64" => "win-arm64", + _ => "win-x64", + } +} + +pub fn stamp_path() -> PathBuf { + root().join("active.json") +} + +pub fn package_dir(nuget: &str, arch: &str) -> PathBuf { + root().join(format!("nuget-{nuget}")).join(arch) +} + +pub fn dll_ready(dir: &Path) -> bool { + dir.join("wslcsdk.dll").is_file() +} + +/// Look up the active stamp's DLL when present and (optionally) host-compatible. +pub fn lookup(host: Option<&str>, arch: &str) -> Option<(PathBuf, ActiveStamp)> { + let raw = fs::read_to_string(stamp_path()).ok()?; + let stamp: ActiveStamp = serde_json::from_str(&raw).ok()?; + if stamp.arch != arch { + return None; + } + if let Some(host) = host + && !cache_compatible(host, &stamp.nuget_version) + { + return None; + } + let dir = package_dir(&stamp.nuget_version, arch); + dll_ready(&dir).then_some((dir, stamp)) +} + +pub fn write_stamp(host: &str, nuget: &str, arch: &str) -> Result<()> { + let path = stamp_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let stamp = ActiveStamp { + host_version: host.to_string(), + nuget_version: nuget.to_string(), + arch: arch.to_string(), + }; + fs::write(path, serde_json::to_string_pretty(&stamp)?)?; + Ok(()) +} + +pub fn clear_package(nuget: &str, arch: &str) { + let dll = package_dir(nuget, arch).join("wslcsdk.dll"); + let _ = fs::remove_file(dll); + let _ = fs::remove_file(stamp_path()); +} diff --git a/src/wslc/sdk_fetch.rs b/src/wslc/sdk_fetch.rs index f309b86..b91d8c8 100644 --- a/src/wslc/sdk_fetch.rs +++ b/src/wslc/sdk_fetch.rs @@ -1,28 +1,25 @@ -//! Download and cache `wslcsdk.dll` matched to the host `wslc` / WSL version. +//! Download `wslcsdk.dll` from NuGet when the local cache cannot serve the host. //! -//! WSL ships `wslc.exe` but not the C SDK DLL. On first launch (and whenever the -//! host version changes) we resolve the best [Microsoft.WSL.Containers] NuGet -//! package and extract `runtimes/win-*/native/wslcsdk.dll` under LocalAppData. +//! Cache lookup / stamps live in [`super::sdk_cache`]; host probing in +//! [`super::wsl_probe`]. This module only lists packages, downloads, and extracts. #![cfg(windows)] use std::fs::{self, File}; use std::io::{copy, Read, Write}; use std::path::{Path, PathBuf}; -use std::process::Command; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{anyhow, Context as _, Result}; use serde::{Deserialize, Serialize}; -use super::sdk_version::{ - cache_compatible, decode_command_output, extract_version, judge_wsl_version, pick_nuget_version, - WslVersionVerdict, -}; +use super::sdk_cache; +use super::sdk_version::pick_nuget_version; +use super::wsl_probe; const NUGET_INDEX: &str = "https://api.nuget.org/v3-flatcontainer/microsoft.wsl.containers/index.json"; -const INDEX_MAX_AGE: Duration = Duration::from_secs(60 * 60); // 1 hour +const INDEX_MAX_AGE: Duration = Duration::from_secs(60 * 60); #[derive(Debug, Clone)] pub enum FetchEvent { @@ -37,13 +34,6 @@ struct IndexCache { versions: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] -struct ActiveStamp { - host_version: String, - nuget_version: String, - arch: String, -} - /// Ensure a host-matched `wslcsdk.dll` is on disk; return its directory for PATH. /// /// Progress values are 0–100 for this download/cache stage. @@ -53,66 +43,54 @@ pub fn ensure_sdk_dir( ) -> Result { let mut emit = |ev: FetchEvent| on_event(ev); - emit(FetchEvent::Status("Detecting WSL / wslc version…".into())); + emit(FetchEvent::Status("Detecting WSL version…".into())); emit(FetchEvent::Progress(5.0)); - let host = match probe_host_version() { - Ok(v) => { + let host = match wsl_probe::package_version() { + Some(v) => { emit(FetchEvent::Log(format!("Host version: {v}"))); v } - Err(err) => { - emit(FetchEvent::Log(format!( - "Could not read wslc/wsl version: {err:#}" - ))); + None => { + emit(FetchEvent::Log( + "WSL package version not found in registry (Lxss\\Msi\\Version)".into(), + )); emit(FetchEvent::Log( - "Install or update WSL (`wsl --install` / `wsl --update`), ensure it is on PATH, then retry." - .into(), + "Install or update WSL (`wsl --install` / `wsl --update`), then retry.".into(), )); "unknown".into() } }; - let arch = native_arch_folder(); + let arch = sdk_cache::native_arch(); emit(FetchEvent::Log(format!("Native arch folder: {arch}"))); emit(FetchEvent::Progress(12.0)); - let stamp_path = sdk_root().join("active.json"); - - // Prefer a compatible on-disk DLL before touching NuGet. Host probe strings - // may drift across launches; only major-behind caches are rejected. - if !force_redownload { - if let Some((dir, stamp)) = try_active_sdk_dir(&stamp_path, Some(&host), arch) { - if stamp.host_version != host { - emit(FetchEvent::Log(format!( - "Host probe drifted ({} → {}); keeping cached SDK {}", - stamp.host_version, host, stamp.nuget_version - ))); - let _ = write_stamp(&stamp_path, &host, &stamp.nuget_version, arch); - } - emit(FetchEvent::Status("Using cached SDK".into())); + if !force_redownload + && let Some((dir, stamp)) = sdk_cache::lookup(Some(&host), arch) + { + if stamp.host_version != host { emit(FetchEvent::Log(format!( - "Cache hit (active stamp): {}", - dir.join("wslcsdk.dll").display() + "Host probe drifted ({} → {}); keeping cached SDK {}", + stamp.host_version, host, stamp.nuget_version ))); - emit(FetchEvent::Progress(100.0)); - return Ok(dir); + let _ = sdk_cache::write_stamp(&host, &stamp.nuget_version, arch); } + return cache_hit(&mut emit, &dir); } - emit(FetchEvent::Status("Listing Microsoft.WSL.Containers on NuGet…".into())); - let versions = match nuget_versions(&mut emit) { + emit(FetchEvent::Status( + "Listing Microsoft.WSL.Containers on NuGet…".into(), + )); + let versions = match list_nuget_versions(&mut emit) { Ok(versions) => versions, Err(err) => { - // Last resort: any previously activated DLL, even if host string drifted. - if !force_redownload { - if let Some((dir, _)) = try_active_sdk_dir(&stamp_path, None, arch) { - emit(FetchEvent::Log(format!( - "NuGet unavailable ({err:#}); reusing active SDK cache" - ))); - emit(FetchEvent::Status("Using cached SDK".into())); - emit(FetchEvent::Progress(100.0)); - return Ok(dir); - } + if !force_redownload + && let Some((dir, _)) = sdk_cache::lookup(None, arch) + { + emit(FetchEvent::Log(format!( + "NuGet unavailable ({err:#}); reusing active SDK cache" + ))); + return cache_hit(&mut emit, &dir); } return Err(err).context("list Microsoft.WSL.Containers versions"); } @@ -129,46 +107,23 @@ pub fn ensure_sdk_dir( "Selected NuGet package version: {nuget} (host {host})" ))); - let dir = sdk_root().join(format!("nuget-{nuget}")).join(arch); - let dll = dir.join("wslcsdk.dll"); - - if force_redownload && dll.is_file() { + let dir = sdk_cache::package_dir(&nuget, arch); + if force_redownload && sdk_cache::dll_ready(&dir) { emit(FetchEvent::Log(format!( - "Retry requested — removing cached DLL at {}", - dll.display() + "Retry requested — clearing cached DLL for {nuget}" ))); - let _ = fs::remove_file(&dll); - let _ = fs::remove_file(&stamp_path); + sdk_cache::clear_package(&nuget, arch); } - if dll.is_file() { - if let Ok(raw) = fs::read_to_string(&stamp_path) { - if let Ok(stamp) = serde_json::from_str::(&raw) { - if stamp.host_version == host - && stamp.nuget_version == nuget - && stamp.arch == arch - { - emit(FetchEvent::Status("Using cached SDK".into())); - emit(FetchEvent::Log(format!( - "Cache hit: {}", - dll.display() - ))); - emit(FetchEvent::Progress(100.0)); - return Ok(dir); - } - } - } - // Package already extracted; host string may have changed. - if nuget_dir_ready(&dir) { - emit(FetchEvent::Status("Refreshing SDK stamp…".into())); - emit(FetchEvent::Log(format!( - "Reusing extracted DLL at {}", - dll.display() - ))); - write_stamp(&stamp_path, &host, &nuget, arch)?; - emit(FetchEvent::Progress(100.0)); - return Ok(dir); - } + if sdk_cache::dll_ready(&dir) { + emit(FetchEvent::Status("Refreshing SDK stamp…".into())); + emit(FetchEvent::Log(format!( + "Reusing extracted DLL at {}", + dir.join("wslcsdk.dll").display() + ))); + sdk_cache::write_stamp(&host, &nuget, arch)?; + emit(FetchEvent::Progress(100.0)); + return Ok(dir); } emit(FetchEvent::Status(format!( @@ -177,137 +132,24 @@ pub fn ensure_sdk_dir( emit(FetchEvent::Progress(28.0)); download_nuget_dll(&nuget, arch, &dir, &mut emit) .with_context(|| format!("download Microsoft.WSL.Containers {nuget} ({arch})"))?; - write_stamp(&stamp_path, &host, &nuget, arch)?; + sdk_cache::write_stamp(&host, &nuget, arch)?; emit(FetchEvent::Status("SDK installed".into())); emit(FetchEvent::Progress(100.0)); Ok(dir) } -fn write_stamp(path: &Path, host: &str, nuget: &str, arch: &str) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - let stamp = ActiveStamp { - host_version: host.to_string(), - nuget_version: nuget.to_string(), - arch: arch.to_string(), - }; - fs::write(path, serde_json::to_string_pretty(&stamp)?)?; - Ok(()) -} - -fn nuget_dir_ready(dir: &Path) -> bool { - dir.join("wslcsdk.dll").is_file() -} - -fn sdk_root() -> PathBuf { - let base = std::env::var_os("LOCALAPPDATA") - .map(PathBuf::from) - .unwrap_or_else(|| std::env::temp_dir().join("WSLcDesktop")); - base.join("WSLcDesktop").join("sdk") -} - -fn native_arch_folder() -> &'static str { - match std::env::consts::ARCH { - "x86_64" => "win-x64", - "aarch64" => "win-arm64", - _ => "win-x64", - } -} - -/// Raw `wsl --version` text, if the command ran and printed anything. -pub(crate) fn probe_wsl_version_output() -> Option { - for args in [ - ["wsl", "--version"].as_slice(), - ["wsl.exe", "--version"].as_slice(), - ] { - if let Ok(out) = run_hidden(args) { - if !out.trim().is_empty() { - return Some(out); - } - } - } - None -} - -fn probe_host_version() -> Result { - // Prefer the same WSL version the setup gate uses so the active stamp does - // not flip between `wslc version` and `wsl --version` across launches. - if let Some(out) = probe_wsl_version_output() { - match judge_wsl_version(Some(&out)) { - WslVersionVerdict::Ok(v) | WslVersionVerdict::TooOld { found: v } => { - return Ok(v); - } - WslVersionVerdict::Missing => { - if let Some(v) = extract_version(&out) { - return Ok(v); - } - } - } - } - for args in [ - ["wslc", "version"].as_slice(), - ["wslc.exe", "version"].as_slice(), - ] { - if let Ok(out) = run_hidden(args) { - if let Some(v) = extract_version(&out) { - return Ok(v); - } - } - } - Err(anyhow!( - "could not read wslc/wsl version (is WSL installed and on PATH?)" - )) -} - -fn run_hidden(args: &[&str]) -> Result { - let (program, rest) = args - .split_first() - .ok_or_else(|| anyhow!("empty command"))?; - let mut cmd = Command::new(program); - cmd.args(rest); - { - use std::os::windows::process::CommandExt as _; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - cmd.creation_flags(CREATE_NO_WINDOW); - } - let output = cmd - .output() - .with_context(|| format!("spawn `{program}`"))?; - // `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 = decode_command_output(&output.stderr); - } - if !output.status.success() && text.trim().is_empty() { - return Err(anyhow!("`{program}` exited {}", output.status)); - } - Ok(text) -} - -fn try_active_sdk_dir( - stamp_path: &Path, - host: Option<&str>, - arch: &str, -) -> Option<(PathBuf, ActiveStamp)> { - let raw = fs::read_to_string(stamp_path).ok()?; - let stamp: ActiveStamp = serde_json::from_str(&raw).ok()?; - if stamp.arch != arch { - return None; - } - if let Some(host) = host - && !cache_compatible(host, &stamp.nuget_version) - { - return None; - } - let dir = sdk_root() - .join(format!("nuget-{}", stamp.nuget_version)) - .join(arch); - nuget_dir_ready(&dir).then_some((dir, stamp)) +fn cache_hit(emit: &mut dyn FnMut(FetchEvent), dir: &Path) -> Result { + emit(FetchEvent::Status("Using cached SDK".into())); + emit(FetchEvent::Log(format!( + "Cache hit: {}", + dir.join("wslcsdk.dll").display() + ))); + emit(FetchEvent::Progress(100.0)); + Ok(dir.to_path_buf()) } -fn nuget_versions(emit: &mut dyn FnMut(FetchEvent)) -> Result> { - let cache_path = sdk_root().join("nuget-index.json"); +fn list_nuget_versions(emit: &mut dyn FnMut(FetchEvent)) -> Result> { + let cache_path = sdk_cache::root().join("nuget-index.json"); let cached = fs::read_to_string(&cache_path) .ok() .and_then(|raw| serde_json::from_str::(&raw).ok()) @@ -347,7 +189,7 @@ fn nuget_versions(emit: &mut dyn FnMut(FetchEvent)) -> Result> { match fetched { Ok(versions) => { - fs::create_dir_all(sdk_root())?; + fs::create_dir_all(sdk_cache::root())?; let cache = IndexCache { fetched_unix: now_unix(), versions: versions.clone(), @@ -408,7 +250,6 @@ fn download_nuget_dll( written += n as u64; if total > 0 { let frac = written as f32 / total as f32; - // Map download into 28–75%. emit(FetchEvent::Progress(28.0 + frac * 47.0)); } else if written % (512 * 1024) < 64 * 1024 { emit(FetchEvent::Log(format!( @@ -436,7 +277,6 @@ fn download_nuget_dll( let mut out = File::create(&dll_path)?; copy(&mut entry, &mut out)?; out.flush()?; - let _ = fs::remove_file(&tmp_nupkg); if !dll_path.is_file() { diff --git a/src/wslc/sdk_version.rs b/src/wslc/sdk_version.rs index 1dda791..646b10e 100644 --- a/src/wslc/sdk_version.rs +++ b/src/wslc/sdk_version.rs @@ -1,9 +1,4 @@ -//! 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. +//! SemVer helpers for the WSL package gate and NuGet SDK matching. #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] pub struct SemVer { @@ -13,8 +8,9 @@ pub struct SemVer { } impl SemVer { + /// Parse `x.y`, `x.y.z`, or `x.y.z.w` (components after patch ignored). pub fn parse(s: &str) -> Option { - let mut parts = s.split('.'); + let mut parts = s.trim().split('.'); let major = parts.next()?.parse().ok()?; let minor = parts.next()?.parse().ok()?; let patch = parts.next().unwrap_or("0").parse().ok()?; @@ -47,101 +43,28 @@ 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). +/// Classify a registry package version string (`3.0.1` / `3.0.1.0`). #[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 { +pub fn judge_package_version(version: Option<&str>) -> WslVersionVerdict { + let Some(raw) = version.map(str::trim).filter(|s| !s.is_empty()) else { return WslVersionVerdict::Missing; }; - 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), - Some(_) => WslVersionVerdict::TooOld { found: v }, - None => WslVersionVerdict::Missing, - }, - None => WslVersionVerdict::Missing, - } -} - -#[cfg_attr(not(windows), allow(dead_code))] -fn labeled_wsl_version(text: &str) -> Option { - let line = text.lines().find(|line| { - let head = line - .split(':') - .next() - .unwrap_or("") - .trim() - .to_ascii_lowercase(); - head.starts_with("wsl") && !head.starts_with("wslg") - })?; - extract_version(line) -} - -#[cfg_attr(not(windows), allow(dead_code))] -fn sole_version(text: &str) -> Option { - let mut found = None; - for token in text.split(|c: char| !(c.is_ascii_digit() || c == '.')) { - if token.is_empty() { - continue; - } - let Some(version) = extract_version(token) else { - continue; - }; - if found.is_some() { - return None; - } - found = Some(version); - } - found -} - -/// 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() { - 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 Some(sv) = SemVer::parse(raw) else { + return WslVersionVerdict::Missing; + }; + let normalized = sv.to_string(); + if sv >= MIN_WSL_VERSION { + WslVersionVerdict::Ok(normalized) + } else { + WslVersionVerdict::TooOld { + found: normalized, } } - None } /// Prefer exact host match, else newest package with the same major as the -/// host, else the newest published package (SDK must not be older than WSL). +/// host, else the newest published package. +#[cfg_attr(not(windows), allow(dead_code))] // used from `sdk_fetch` on Windows pub fn pick_nuget_version(host: &str, available: &[String]) -> Option { let mut parsed: Vec<(SemVer, &String)> = available .iter() @@ -165,10 +88,9 @@ pub fn pick_nuget_version(host: &str, available: &[String]) -> Option { /// Whether a cached NuGet SDK can serve this host without re-downloading. /// -/// Host probe strings can drift (`wslc` vs `wsl --version`, or `unknown` on a -/// failed probe). Do not require an exact stamp match — only re-download when -/// the cached package is clearly too old (major behind the host), or when the -/// user forces Retry. +/// Re-download only when the cached package major is behind the host, or when +/// the user forces Retry. +#[cfg_attr(not(windows), allow(dead_code))] // used from `sdk_cache` on Windows pub fn cache_compatible(host: &str, nuget_version: &str) -> bool { let Some(host_ver) = SemVer::parse(host) else { return true; @@ -179,184 +101,39 @@ pub fn cache_compatible(host: &str, nuget_version: &str) -> bool { nuget_ver.major >= host_ver.major } -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() + #[test] + fn parses_registry_style_versions() { + assert_eq!(SemVer::parse("2.9.3"), Some(SemVer { major: 2, minor: 9, patch: 3 })); + assert_eq!(SemVer::parse("3.0.1.0"), Some(SemVer { major: 3, minor: 0, patch: 1 })); + assert_eq!(SemVer::parse(" 3.0.1 "), Some(SemVer { major: 3, minor: 0, patch: 1 })); + assert!(SemVer::parse("not-a-version").is_none()); + assert!(SemVer::parse("").is_none()); } - 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() { + fn judges_package_version() { + assert_eq!(judge_package_version(None), WslVersionVerdict::Missing); + assert_eq!(judge_package_version(Some(" ")), WslVersionVerdict::Missing); assert_eq!( - extract_version("WSL version: 2.9.3.0\n"), - Some("2.9.3".into()) + judge_package_version(Some("2.9.3")), + WslVersionVerdict::Ok("2.9.3".into()) ); - assert_eq!(extract_version("wslc 3.0.1\n"), Some("3.0.1".into())); + assert_eq!( + judge_package_version(Some("3.0.1.0")), + WslVersionVerdict::Ok("3.0.1".into()) + ); + assert_eq!( + judge_package_version(Some("2.9.2")), + WslVersionVerdict::TooOld { + found: "2.9.2".into() + } + ); + assert!(SemVer::parse("3.0.1.0").unwrap() >= MIN_WSL_VERSION); + assert_eq!(format!("{MIN_WSL_VERSION}"), "2.9.3"); } #[test] @@ -389,7 +166,7 @@ Windows 版本: 10.0.26100.3476 } #[test] - fn cache_compatible_ignores_host_string_drift() { + fn cache_compatible_by_major() { assert!(cache_compatible("unknown", "3.0.1")); assert!(cache_compatible("2.9.3", "2.9.9")); assert!(cache_compatible("2.9.5", "3.0.1")); @@ -397,106 +174,4 @@ Windows 版本: 10.0.26100.3476 assert!(!cache_compatible("3.0.1", "2.9.9")); assert!(!cache_compatible("3.0.0", "not-a-version")); } - - #[test] - fn judges_wsl_version_output() { - assert_eq!(judge_wsl_version(None), WslVersionVerdict::Missing); - assert_eq!(judge_wsl_version(Some(" ")), WslVersionVerdict::Missing); - assert_eq!( - judge_wsl_version(Some("2.9.3")), - WslVersionVerdict::Ok("2.9.3".into()) - ); - assert_eq!( - judge_wsl_version(Some("2.10.0")), - WslVersionVerdict::Ok("2.10.0".into()) - ); - assert_eq!( - judge_wsl_version(Some("2.9.2")), - WslVersionVerdict::TooOld { - found: "2.9.2".into() - } - ); - - let english = "\ -WSL version: 2.4.13.0 -Kernel version: 5.15.167.4-1 -WSLg version: 1.0.65 -"; - assert_eq!( - judge_wsl_version(Some(english)), - WslVersionVerdict::TooOld { - found: "2.4.13".into() - } - ); - - let chinese = "WSL 版本: 2.9.3.0\n内核版本: 5.15.167.4-1\n"; - assert_eq!( - judge_wsl_version(Some(chinese)), - WslVersionVerdict::Ok("2.9.3".into()) - ); - - let unlabeled = "Kernel version: 5.15.167.4-1\nWSLg version: 1.0.65\n"; - assert_eq!( - judge_wsl_version(Some(unlabeled)), - WslVersionVerdict::Missing - ); - 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()) - ); - } } diff --git a/src/wslc/types.rs b/src/wslc/types.rs index 961ed5c..83071a5 100644 --- a/src/wslc/types.rs +++ b/src/wslc/types.rs @@ -127,9 +127,9 @@ pub enum SetupStepId { #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(not(windows), allow(dead_code))] pub enum SetupFault { - /// `wsl --version` missing, failed, or unreadable. + /// Registry `Lxss\Msi\Version` missing or unreadable. WslMissing, - /// Parsed version is below 2.9.3. + /// Package version is below 2.9.3. WslTooOld { found: String }, /// This build is not running on Windows. NotWindows, @@ -164,14 +164,3 @@ pub enum SdkSetupEvent { Finished(Result), } -/// Snapshot used by Overview "simple stats". -#[derive(Debug, Clone, PartialEq, Eq, Default)] -#[allow(dead_code)] -pub struct SessionStats { - pub cpu_count: u32, - pub memory_mb: u32, - pub containers: usize, - pub running: usize, - pub images: usize, - pub volumes: usize, -} diff --git a/src/wslc/wsl_probe.rs b/src/wslc/wsl_probe.rs new file mode 100644 index 0000000..a7454a7 --- /dev/null +++ b/src/wslc/wsl_probe.rs @@ -0,0 +1,84 @@ +//! Read the installed WSL package version from the machine registry. +//! +//! Same source WSL itself uses: `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Lxss\Msi\Version`. +//! No CLI. After `wslcsdk.dll` loads, use `wslc::Service::version()` instead. + +#![cfg(windows)] + +use super::sdk_version::SemVer; + +/// Installed WSL package version (`x.y.z`), if the MSI registry value is present. +pub fn package_version() -> Option { + for subkey in [ + r"SOFTWARE\Microsoft\Windows\CurrentVersion\Lxss\Msi", + r"SOFTWARE\Microsoft\Windows\CurrentVersion\Lxss\MSI", + ] { + if let Some(raw) = read_hk_lm_string(subkey, "Version") + && let Some(v) = SemVer::parse(&raw) + { + return Some(v.to_string()); + } + } + None +} + +fn read_hk_lm_string(subkey: &str, value_name: &str) -> Option { + use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, ERROR_SUCCESS}; + use windows_sys::Win32::System::Registry::{ + RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY_LOCAL_MACHINE, KEY_READ, REG_SZ, + }; + + let subkey_w = wide(subkey); + let value_w = wide(value_name); + unsafe { + let mut hkey = std::ptr::null_mut(); + if RegOpenKeyExW( + HKEY_LOCAL_MACHINE, + subkey_w.as_ptr(), + 0, + KEY_READ, + &mut hkey, + ) != ERROR_SUCCESS + { + return None; + } + + let mut kind = 0u32; + let mut byte_len = 0u32; + let query = RegQueryValueExW( + hkey, + value_w.as_ptr(), + std::ptr::null(), + &mut kind, + std::ptr::null_mut(), + &mut byte_len, + ); + if (query != ERROR_SUCCESS && query != ERROR_MORE_DATA) || kind != REG_SZ || byte_len < 2 { + let _ = RegCloseKey(hkey); + return None; + } + + let mut buf = vec![0u16; (byte_len as usize).div_ceil(2)]; + let mut buf_bytes = (buf.len() * 2) as u32; + let status = RegQueryValueExW( + hkey, + value_w.as_ptr(), + std::ptr::null(), + &mut kind, + buf.as_mut_ptr().cast::(), + &mut buf_bytes, + ); + let _ = RegCloseKey(hkey); + if status != ERROR_SUCCESS || kind != REG_SZ { + return None; + } + let units = buf_bytes as usize / 2; + let text = String::from_utf16_lossy(&buf[..units]); + let trimmed = text.trim_end_matches('\0').trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } +} + +fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} diff --git a/wix/sdk/README.md b/wix/sdk/README.md index ac0fc8e..00b024f 100644 --- a/wix/sdk/README.md +++ b/wix/sdk/README.md @@ -2,7 +2,7 @@ The MSI does **not** embed `wslcsdk.dll`. At runtime the app: -1. Reads `wslc version` / `wsl --version` +1. Reads the WSL package version from `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Lxss\Msi\Version` 2. Picks a matching [Microsoft.WSL.Containers](https://www.nuget.org/packages/Microsoft.WSL.Containers) release 3. Caches `wslcsdk.dll` under `%LOCALAPPDATA%\WSLcDesktop\sdk\`