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
2 changes: 2 additions & 0 deletions .github/workflows/windows-msi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

- 首次启动读取 `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

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

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

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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\` 缓存(仅在版本不兼容或手动重试时再下载)。同一用户会话只允许一个实例。

## 安装(推荐)

Expand Down
14 changes: 14 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

mod app;
mod i18n;
mod single_instance;
mod wslc;

use gpui_kit::component::{Theme, ThemeMode, TitleBar};
Expand All @@ -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| {
Expand Down
98 changes: 98 additions & 0 deletions src/single_instance.rs
Original file line number Diff line number Diff line change
@@ -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<InstanceLock, AcquireError> {
#[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<InstanceLock, AcquireError> {
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<u16> {
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);
}
}
}
47 changes: 35 additions & 12 deletions src/wslc/sdk_fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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): {}",
Expand All @@ -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"
)));
Expand Down Expand Up @@ -220,11 +231,23 @@ pub(crate) fn probe_wsl_version_output() -> Option<String> {
}

fn probe_host_version() -> Result<String> {
// 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) {
Expand Down Expand Up @@ -266,21 +289,21 @@ fn try_active_sdk_dir(
stamp_path: &Path,
host: Option<&str>,
arch: &str,
) -> Option<PathBuf> {
) -> 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<Vec<String>> {
Expand Down
26 changes: 26 additions & 0 deletions src/wslc/sdk_version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,22 @@ pub fn pick_nuget_version(host: &str, available: &[String]) -> Option<String> {
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) {
Expand Down Expand Up @@ -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);
Expand Down
Loading