From bb3ec6d009ed71717fa663a4e3b7d91fbd6a8710 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 10:06:46 +0000 Subject: [PATCH 1/2] fix: reuse SDK cache and block multi-instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host probe strings drifted across launches, so exact stamp matches failed and NuGet was hit again every start. Reuse any major-compatible cached DLL; only re-download on Retry or when the cache is too old. Named mutex stops a second process from sharing the wslc-desktop session. Co-authored-by: 绿岸公式 --- CHANGELOG.md | 7 +++ Cargo.lock | 1 + Cargo.toml | 5 +++ README.md | 2 +- src/main.rs | 14 ++++++ src/single_instance.rs | 98 +++++++++++++++++++++++++++++++++++++++++ src/wslc/sdk_fetch.rs | 47 +++++++++++++++----- src/wslc/sdk_version.rs | 26 +++++++++++ 8 files changed, 187 insertions(+), 13 deletions(-) create mode 100644 src/single_instance.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 36f5536..e5a6978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ ## [Unreleased] +### 修复 + +- 已缓存的 SDK 在 host 版本探测字符串漂移时不再重复下载;仅当缓存包 major 落后于本机 WSL,或用户点「重试」时才会重新拉取。 + - Cached SDK is reused across launches when the host version string drifts; re-download only if the cache is major-behind or Retry is used. +- 禁止多开:同一用户会话只允许一个 WSLc Desktop 实例;再次启动会提示已在运行。 + - Single-instance lock: a second launch shows a notice and exits. + ## [0.1.2] - 2026-10-11 ### 修复 diff --git a/Cargo.lock b/Cargo.lock index 81b92bc..d6b77e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8475,6 +8475,7 @@ dependencies = [ "smol", "sys-locale", "ureq", + "windows-sys 0.61.2", "wslc", "zip", ] diff --git a/Cargo.toml b/Cargo.toml index 05f4c18..2d55283 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,11 @@ sys-locale = "0.3" wslc = { git = "https://github.com/timeroute/wslc-rs", rev = "9fbcd72f6967f79d0dc805648120eaec77364a2e" } 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_Threading", + "Win32_UI_WindowsAndMessaging", +] } [profile.dev.package] gpui-pre = { opt-level = 3 } diff --git a/README.md b/README.md index 08a1021..0f34998 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。 +用独立会话 `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\` 缓存(仅在版本不兼容或手动重试时再下载)。同一用户会话只允许一个实例。 ## 安装(推荐) diff --git a/src/main.rs b/src/main.rs index dfb5700..25cafb8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod app; mod i18n; +mod single_instance; mod wslc; use gpui_kit::component::{Theme, ThemeMode, TitleBar}; @@ -12,6 +13,19 @@ use app::AppView; actions!(wslc_desktop, [Quit]); fn main() { + // Keep the mutex alive for the process lifetime (dropped after `app.run`). + let _instance = match single_instance::try_acquire() { + Ok(lock) => Some(lock), + Err(single_instance::AcquireError::AlreadyRunning) => { + single_instance::notify_already_running(); + return; + } + Err(single_instance::AcquireError::Failed(_)) => { + // Mutex creation failed; continue rather than blocking launch. + None + } + }; + let app = gpui_kit::application().with_assets(gpui_kit::assets::Assets); app.run(move |cx| { diff --git a/src/single_instance.rs b/src/single_instance.rs new file mode 100644 index 0000000..7536024 --- /dev/null +++ b/src/single_instance.rs @@ -0,0 +1,98 @@ +//! Per-user single-instance guard so two app processes cannot share one session. + +pub struct InstanceLock { + #[cfg(windows)] + handle: windows_sys::Win32::Foundation::HANDLE, +} + +#[derive(Debug)] +pub enum AcquireError { + /// Another process already holds the instance mutex. + #[cfg_attr(not(windows), allow(dead_code))] + AlreadyRunning, + /// `CreateMutexW` failed; caller may continue without a lock. + #[cfg_attr(not(windows), allow(dead_code))] + Failed(u32), +} + +/// Try to become the only WSLc Desktop instance for this Windows user session. +/// +/// Non-Windows builds always succeed (the app cannot load the SDK there anyway). +pub fn try_acquire() -> Result { + #[cfg(windows)] + { + acquire_windows() + } + #[cfg(not(windows))] + { + Ok(InstanceLock {}) + } +} + +/// Tell the user another instance is already open (Windows MessageBox). +pub fn notify_already_running() { + #[cfg(windows)] + { + notify_windows(); + } +} + +#[cfg(windows)] +fn acquire_windows() -> Result { + use windows_sys::Win32::Foundation::{ + CloseHandle, GetLastError, SetLastError, ERROR_ALREADY_EXISTS, + }; + use windows_sys::Win32::System::Threading::CreateMutexW; + + // `Local\` scopes the mutex to the current Windows session / user. + let name = wide("Local\\WSLcDesktop_SingleInstance"); + unsafe { + SetLastError(0); + let handle = CreateMutexW(std::ptr::null(), 0, name.as_ptr()); + if handle.is_null() { + return Err(AcquireError::Failed(GetLastError())); + } + if GetLastError() == ERROR_ALREADY_EXISTS { + let _ = CloseHandle(handle); + return Err(AcquireError::AlreadyRunning); + } + Ok(InstanceLock { handle }) + } +} + +#[cfg(windows)] +fn notify_windows() { + use windows_sys::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONINFORMATION, MB_OK}; + + let (title, body) = if crate::i18n::Locale::from_system() == crate::i18n::Locale::Zh { + ( + "WSLc Desktop", + "WSLc Desktop 已在运行,不能同时打开多个实例。", + ) + } else { + ( + "WSLc Desktop", + "WSLc Desktop is already running. Only one instance is allowed.", + ) + }; + let title = wide(title); + let body = wide(body); + unsafe { + MessageBoxW(std::ptr::null_mut(), body.as_ptr(), title.as_ptr(), MB_OK | MB_ICONINFORMATION); + } +} + +#[cfg(windows)] +fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +#[cfg(windows)] +impl Drop for InstanceLock { + fn drop(&mut self) { + use windows_sys::Win32::Foundation::CloseHandle; + unsafe { + let _ = CloseHandle(self.handle); + } + } +} diff --git a/src/wslc/sdk_fetch.rs b/src/wslc/sdk_fetch.rs index 5c50567..f309b86 100644 --- a/src/wslc/sdk_fetch.rs +++ b/src/wslc/sdk_fetch.rs @@ -15,7 +15,10 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{anyhow, Context as _, Result}; use serde::{Deserialize, Serialize}; -use super::sdk_version::{decode_command_output, extract_version, pick_nuget_version}; +use super::sdk_version::{ + cache_compatible, decode_command_output, extract_version, judge_wsl_version, pick_nuget_version, + WslVersionVerdict, +}; const NUGET_INDEX: &str = "https://api.nuget.org/v3-flatcontainer/microsoft.wsl.containers/index.json"; @@ -75,9 +78,17 @@ pub fn ensure_sdk_dir( let stamp_path = sdk_root().join("active.json"); - // Prefer a matching on-disk DLL before touching NuGet (offline / stale index). + // 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) = try_active_sdk_dir(&stamp_path, Some(&host), arch) { + 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())); emit(FetchEvent::Log(format!( "Cache hit (active stamp): {}", @@ -94,7 +105,7 @@ pub fn ensure_sdk_dir( 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) { + if let Some((dir, _)) = try_active_sdk_dir(&stamp_path, None, arch) { emit(FetchEvent::Log(format!( "NuGet unavailable ({err:#}); reusing active SDK cache" ))); @@ -220,11 +231,23 @@ pub(crate) fn probe_wsl_version_output() -> Option { } 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(), - ["wsl", "--version"].as_slice(), - ["wsl.exe", "--version"].as_slice(), ] { if let Ok(out) = run_hidden(args) { if let Some(v) = extract_version(&out) { @@ -266,21 +289,21 @@ fn try_active_sdk_dir( stamp_path: &Path, host: Option<&str>, arch: &str, -) -> Option { +) -> 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 { - if stamp.host_version != host { - 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) + nuget_dir_ready(&dir).then_some((dir, stamp)) } fn nuget_versions(emit: &mut dyn FnMut(FetchEvent)) -> Result> { diff --git a/src/wslc/sdk_version.rs b/src/wslc/sdk_version.rs index abd51c3..1dda791 100644 --- a/src/wslc/sdk_version.rs +++ b/src/wslc/sdk_version.rs @@ -163,6 +163,22 @@ pub fn pick_nuget_version(host: &str, available: &[String]) -> Option { parsed.last().map(|(_, v)| (*v).clone()) } +/// 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. +pub fn cache_compatible(host: &str, nuget_version: &str) -> bool { + let Some(host_ver) = SemVer::parse(host) else { + return true; + }; + let Some(nuget_ver) = SemVer::parse(nuget_version) else { + return false; + }; + 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) { @@ -372,6 +388,16 @@ Windows 版本: 10.0.26100.3476 ); } + #[test] + fn cache_compatible_ignores_host_string_drift() { + 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")); + assert!(cache_compatible("3.0.1", "3.0.1")); + 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); From b1da330c2a5df9cb62365c2a6f7b1af74bfffa0c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 10:13:14 +0000 Subject: [PATCH 2/2] chore(release): fold fixes into 0.1.2, allow MSI overwrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 绿岸公式 --- .github/workflows/windows-msi.yml | 2 ++ CHANGELOG.md | 11 ++++------- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/windows-msi.yml b/.github/workflows/windows-msi.yml index df3b0b0..2f41f9c 100644 --- a/.github/workflows/windows-msi.yml +++ b/.github/workflows/windows-msi.yml @@ -94,5 +94,7 @@ jobs: with: files: target/wix/*.msi generate_release_notes: true + # Allow re-tagging the same version (e.g. rebuild v0.1.2) to replace MSI assets. + overwrite_files: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index e5a6978..45e2f09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,19 +4,16 @@ ## [Unreleased] -### 修复 - -- 已缓存的 SDK 在 host 版本探测字符串漂移时不再重复下载;仅当缓存包 major 落后于本机 WSL,或用户点「重试」时才会重新拉取。 - - Cached SDK is reused across launches when the host version string drifts; re-download only if the cache is major-behind or Retry is used. -- 禁止多开:同一用户会话只允许一个 WSLc Desktop 实例;再次启动会提示已在运行。 - - Single-instance lock: a second launch shows a notice and exits. - ## [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. +- 已缓存的 SDK 在 host 版本探测字符串漂移时不再重复下载;仅当缓存包 major 落后于本机 WSL,或用户点「重试」时才会重新拉取。 + - Cached SDK is reused across launches when the host version string drifts; re-download only if the cache is major-behind or Retry is used. +- 禁止多开:同一用户会话只允许一个 WSLc Desktop 实例;再次启动会提示已在运行。 + - Single-instance lock: a second launch shows a notice and exits. ## [0.1.1] - 2026-10-11