diff --git a/.gitignore b/.gitignore index 7ffbdbe..7a21b52 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,7 @@ Thumbs.db /agent-tools/ /tmp/ *.log + +# Optional local SDK staging (scripts/fetch-wslc-sdk.ps1) +/.wslc-sdk/ +/wix/sdk/win-x64/ diff --git a/Cargo.lock b/Cargo.lock index fd9559d..7428e77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -192,6 +192,9 @@ name = "arbitrary" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] [[package]] name = "arc-swap" @@ -1325,6 +1328,17 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -5745,6 +5759,7 @@ version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -7131,6 +7146,24 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -7541,6 +7574,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -8405,6 +8456,13 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "wslc" +version = "0.1.9" +dependencies = [ + "wslc-sys", +] + [[package]] name = "wslc-desktop" version = "0.1.0" @@ -8415,8 +8473,15 @@ dependencies = [ "serde_json", "smol", "sys-locale", + "ureq", + "wslc", + "zip", ] +[[package]] +name = "wslc-sys" +version = "0.1.2" + [[package]] name = "x11" version = "2.21.0" @@ -8837,6 +8902,23 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.21", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -8849,6 +8931,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 42bee0e..c91d303 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,15 @@ serde_json = "1" smol = "2" sys-locale = "0.3" +# Official WSL Containers SDK bindings (`wslcsdk.dll`). Windows host only. +# ureq/zip: download matching Microsoft.WSL.Containers native DLL at runtime. +[target.'cfg(windows)'.dependencies] +# Vendored from timeroute/wslc-rs (+ named_volume). Switch to crates.io when upstream publishes +# OpenContainer / Logs / named volumes. +wslc = { path = "vendor/wslc-rs/crates/wslc" } +ureq = { version = "2.12", default-features = true, features = ["json"] } +zip = { version = "2", default-features = false, features = ["deflate"] } + [profile.dev.package] gpui-pre = { opt-level = 3 } gpui-component = { opt-level = 3 } diff --git a/README.md b/README.md index 76000f4..2337890 100644 --- a/README.md +++ b/README.md @@ -2,30 +2,31 @@ 面向 Windows **WSL Containers(WSLc)** 的轻量级桌面管理器,基于 [GPUI Kit](https://gpui-kit.com)(Zed GPUI + 组件库)构建。 -在本机调用 `wslc` / `container` CLI,浏览与管理容器、镜像,并一键启动分离式容器。未检测到 WSLc 时自动进入 **Demo** 模式(内存样例数据),便于在尚未安装 WSL Containers 的 Windows 上预览 UI。 +通过官方 **WSL Containers SDK**(Rust crate [`wslc`](https://crates.io/crates/wslc))浏览与管理容器、镜像与 VHD 卷。应用使用**独立会话** `wslc-desktop`(数据在 `%LOCALAPPDATA%\WSLcDesktop\session\`),与 `wslc` CLI 会话隔离——桌面端镜像/容器需在本应用内拉取与创建。MSI **不捆绑** DLL:首次启动以**阻塞弹窗**安装/更新 SDK 到 `%LOCALAPPDATA%\WSLcDesktop\sdk\`;Live 失败则保持锁定直至重试成功或自行修好 WSL。 ## 功能 -- **Containers**:列出全部/运行中容器,Start / Stop / Remove -- **Images**:输入镜像名直接 Pull(状态 / 进度条 / 日志),查看本地镜像,跳转 Run 或删除 -- **Volumes**:列出 / 创建 / 删除命名卷 -- **Run**:按镜像引用启动 detached 容器(可选名称) -- **引擎状态**:LIVE(真实 CLI)或 DEMO(内存样例数据) -- **设置**:TitleBar 齿轮按钮打开;界面语言(中文 / English,默认跟随系统)与主题(深色 / 浅色) +- **总览(默认)**:引擎 / SDK / 应用版本、会话信息、容器·镜像·卷摘要 +- **Containers**:工具栏创建;行内 Start / Stop / Restart / Logs / Inspect / Exec / Remove(确认);catalog + `OpenContainer` 跨重启恢复 +- **Images**:工具栏拉取;行内创建容器 / 删除(确认) +- **Volumes**:工具栏弹窗创建(1/2/5/10 GiB);行内删除(确认);创建容器时可挂命名卷 +- **引擎状态**:LIVE(独立 `wslc-desktop` 会话);未就绪时全屏 SDK 安装弹窗(OFF) +- **设置**:可滚动;语言 / 主题 / 会话 CPU·内存(写入 prefs,下次新建会话生效) ## 环境要求 1. Windows 10/11(x64) -2. WSL **2.9.3+**(含 `wslc.exe`;真实管理容器时需要) +2. WSL **2.9.3+**(`wsl --update`;LIVE 模式需要本机 WSL Containers 运行时) 3. Rust **1.92+**,MSVC 工具链 4. Visual Studio 2022 C++ 工作负载 + Windows SDK + CMake ```powershell wsl --update -wslc version rustup default stable-x86_64-pc-windows-msvc ``` +LIVE 需要:已安装/更新的 WSL(提供 `wslc.exe`),以及首次启动时能访问 nuget.org 以下载 `wslcsdk.dll`。也可预先手动放置 DLL(应用仍会优先用自动缓存)。 + ## 运行 在 **x86_64-pc-windows-msvc** 工具链下: @@ -40,7 +41,7 @@ cargo run cargo build --release ``` -单元测试(CLI 表格解析与 Demo 状态机): +单元测试(版本匹配与格式化辅助): ```powershell cargo test @@ -70,7 +71,7 @@ cargo install cargo-wix ### 2. 打 MSI(含桌面图标) -仓库已提交 `wix/main.wxs` 与 `wix/Product.ico`:安装后会创建 **开始菜单** 与 **桌面快捷方式**,**不会** 写入 PATH(无需命令行启动)。 +仓库已提交 `wix/main.wxs` 与 `wix/Product.ico`:安装后会创建 **开始菜单** 与 **桌面快捷方式**(**不** 捆绑 `wslcsdk.dll`,**不** 写入 PATH)。 ```powershell cargo wix @@ -105,7 +106,7 @@ cargo wix --sign | **EXE 安装向导** | [Inno Setup](https://jrsoftware.org/isinfo.php) / NSIS | 先 `cargo build --release`,再把 `target\release\wslc-desktop.exe` 写进脚本 | | **便携版** | 直接分发 exe | 拷贝 `target\release\wslc-desktop.exe`(目标机通常还需 [VC++ Redistributable](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist)) | -GPUI 应用一般是单二进制;若运行时报缺 DLL,把对应 VC++ 运行库一并装上,或在 WiX/Inno 脚本里捆绑 redistributable。 +若运行时报缺 VC++ 运行库,把对应 [VC++ Redistributable](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist) 一并装上。LIVE 依赖本机 WSL + 可发现的 `wslcsdk.dll`;安装弹窗与状态栏会显示具体失败原因(找不到 DLL / 组件缺失 / SDK 过旧等)。 ### 5. GitHub Actions 自动打 MSI @@ -139,20 +140,22 @@ git push origin v0.1.0 wix/ # MSI:桌面/开始菜单快捷方式 + 产品图标 src/ main.rs # 窗口启动与快捷键 - app.rs # 侧栏导航 + TitleBar 设置按钮 + app/ # GPUI 主界面(按功能拆分:shell、containers、images、…) i18n.rs # 中英文字符串与系统语言检测 - wslc.rs # wslc CLI 封装与 Demo 回退 + wslc/ # SDK 封装(types + 独立会话 Live;安装/拉取/创建进度事件) ``` ## 架构说明 ```text -UI (GPUI Kit) ──► WslcClient ──► wslc / container CLI - │ - └─► DemoState(无 CLI 时) +UI (GPUI Kit) ──► SDK 安装弹窗 ──► NuGet 缓存 wslcsdk.dll + ──► Session "wslc-desktop"(与 CLI 隔离) + ──► 拉取 / 创建容器 / 日志 均为阻塞弹窗 ``` -解析策略:优先尝试 `--format json`,失败则按 Docker 风格表格拆列,尽量兼容 `wslc` 的输出演进。 +- **Images**:本会话 `list_images` / `pull_image` / `delete_image` +- **Containers**:本进程跟踪的 SDK 句柄 + `%LOCALAPPDATA%\WSLcDesktop\catalog.json`;启动时 `OpenContainer` 重连(退出不 `terminate` 会话) +- **Volumes**:`create_vhd_volume` / `delete_vhd_volume`,catalog 持久化 ## 许可 diff --git a/scripts/fetch-wslc-sdk.ps1 b/scripts/fetch-wslc-sdk.ps1 new file mode 100644 index 0000000..02726b3 --- /dev/null +++ b/scripts/fetch-wslc-sdk.ps1 @@ -0,0 +1,63 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Optional: download Microsoft.WSL.Containers for local `cargo run` / PATH setup. + +.DESCRIPTION + The MSI does **not** bundle wslcsdk.dll. LIVE mode loads the host copy + (WSL install dir or PATH). Use this script only when developing without a + system SDK on PATH. + +.PARAMETER Version + NuGet package version. Defaults to $env:WSLC_SDK_VERSION or 3.0.1. + +.PARAMETER OutDir + Directory that receives wslcsdk.dll (default: .wslc-sdk\native). +#> +param( + [string]$Version = $(if ($env:WSLC_SDK_VERSION) { $env:WSLC_SDK_VERSION } else { "3.0.1" }), + [string]$OutDir = "" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +if (-not $OutDir) { + $OutDir = Join-Path $repoRoot ".wslc-sdk\native" +} + +$cacheRoot = Join-Path $repoRoot ".wslc-sdk" +$nupkgName = "microsoft.wsl.containers.$Version.nupkg" +$nupkgPath = Join-Path $cacheRoot $nupkgName +$extractDir = Join-Path $cacheRoot "Microsoft.WSL.Containers.$Version" +$downloadUrl = "https://api.nuget.org/v3-flatcontainer/microsoft.wsl.containers/$Version/$nupkgName" + +New-Item -ItemType Directory -Force -Path $cacheRoot | Out-Null +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null + +if (-not (Test-Path $nupkgPath)) { + Write-Host "Downloading $downloadUrl" + Invoke-WebRequest -Uri $downloadUrl -OutFile $nupkgPath -UseBasicParsing +} else { + Write-Host "Using cached $nupkgPath" +} + +if (Test-Path $extractDir) { + Remove-Item -Recurse -Force $extractDir +} +New-Item -ItemType Directory -Force -Path $extractDir | Out-Null + +$zipPath = Join-Path $cacheRoot "microsoft.wsl.containers.$Version.zip" +Copy-Item -Force $nupkgPath $zipPath +Write-Host "Extracting $zipPath" +Expand-Archive -Path $zipPath -DestinationPath $extractDir -Force + +$nativeDll = Join-Path $extractDir "runtimes\win-x64\native\wslcsdk.dll" +if (-not (Test-Path $nativeDll)) { + throw "wslcsdk.dll not found at $nativeDll" +} + +Copy-Item -Force $nativeDll (Join-Path $OutDir "wslcsdk.dll") +Write-Host "Staged $(Join-Path $OutDir 'wslcsdk.dll')" +Write-Host "Add to PATH for this session:" +Write-Host " `$env:PATH = '$OutDir;' + `$env:PATH" diff --git a/src/app.rs b/src/app.rs deleted file mode 100644 index df00674..0000000 --- a/src/app.rs +++ /dev/null @@ -1,1533 +0,0 @@ -use std::sync::mpsc::{Receiver, TryRecvError}; -use std::time::Duration; - -use gpui_kit::component::{ - ActiveTheme, Disableable as _, Icon, IconName, Selectable as _, Sizable as _, StyledExt as _, - Theme, ThemeMode, TitleBar, - button::{Button, ButtonVariants as _}, - h_flex, - input::{Input, InputState}, - label::Label, - progress::Progress, - sidebar::{Sidebar, SidebarGroup, SidebarHeader, SidebarMenu, SidebarMenuItem}, - v_flex, -}; -use gpui_kit::prelude::FluentBuilder as _; -use gpui_kit::*; -use smol::Timer; - -use crate::i18n::{self, Locale, Strings}; -use crate::wslc::{ - BackendKind, ContainerInfo, EngineStatus, ImageInfo, PullEvent, VolumeInfo, WslcClient, -}; - -const MAX_PULL_LOG_LINES: usize = 400; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum Section { - #[default] - Containers = 0, - Images = 1, - Volumes = 2, - Run = 3, - Settings = 4, -} - -impl Section { - fn label(self, t: &Strings) -> &'static str { - match self { - Self::Containers => t.containers, - Self::Images => t.images, - Self::Volumes => t.volumes, - Self::Run => t.run, - Self::Settings => t.settings, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -enum PullPhase { - #[default] - Idle, - Pulling, - Success, - Failed, -} - -impl PullPhase { - fn label(self, t: &Strings) -> &'static str { - match self { - Self::Idle => t.pull_idle, - Self::Pulling => t.pull_phase_pulling, - Self::Success => t.pull_ok, - Self::Failed => t.pull_failed, - } - } -} - -pub struct AppView { - client: WslcClient, - status: EngineStatus, - section: Section, - locale: Locale, - theme_mode: ThemeMode, - containers: Vec, - images: Vec, - volumes: Vec, - selected_container: Option, - selected_image: Option, - selected_volume: Option, - show_all_containers: bool, - busy: bool, - status_line: SharedString, - image_input: Entity, - name_input: Entity, - pull_input: Entity, - volume_input: Entity, - pull_phase: PullPhase, - pull_status: SharedString, - pull_progress: f32, - pull_logs: Vec, - pull_rx: Option>, - pull_poll_generation: u64, -} - -impl AppView { - pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let locale = Locale::from_system(); - let t = locale.strings(); - let theme_mode = ThemeMode::Dark; - Theme::change(theme_mode, Some(window), cx); - - let image_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("nginx:alpine") - .default_value("nginx:alpine") - }); - let name_input = - cx.new(|cx| InputState::new(window, cx).placeholder(t.name_placeholder)); - let pull_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("library/alpine:latest") - .default_value("alpine:latest") - }); - let volume_input = - cx.new(|cx| InputState::new(window, cx).placeholder(t.volume_placeholder)); - - let client = WslcClient::detect(); - let status = client.status(); - let mut view = Self { - client, - status, - section: Section::Containers, - locale, - theme_mode, - containers: Vec::new(), - images: Vec::new(), - volumes: Vec::new(), - selected_container: None, - selected_image: None, - selected_volume: None, - show_all_containers: true, - busy: false, - status_line: t.ready.into(), - image_input, - name_input, - pull_input, - volume_input, - pull_phase: PullPhase::Idle, - pull_status: t.pull_hint.into(), - pull_progress: 0.0, - pull_logs: Vec::new(), - pull_rx: None, - pull_poll_generation: 0, - }; - view.refresh(cx); - view - } - - fn t(&self) -> &'static Strings { - self.locale.strings() - } - - fn set_status(&mut self, msg: impl Into, cx: &mut Context) { - self.status_line = msg.into(); - cx.notify(); - } - - fn apply_locale(&mut self, locale: Locale, window: &mut Window, cx: &mut Context) { - if self.locale == locale { - return; - } - self.locale = locale; - let t = self.t(); - self.name_input.update(cx, |state, cx| { - state.set_placeholder(t.name_placeholder, window, cx); - }); - self.volume_input.update(cx, |state, cx| { - state.set_placeholder(t.volume_placeholder, window, cx); - }); - if self.pull_phase == PullPhase::Idle { - self.pull_status = t.pull_hint.into(); - } - self.refresh(cx); - } - - fn apply_theme(&mut self, mode: ThemeMode, window: &mut Window, cx: &mut Context) { - if self.theme_mode == mode { - return; - } - self.theme_mode = mode; - Theme::change(mode, Some(window), cx); - cx.notify(); - } - - fn localize_engine_message(&self, status: &EngineStatus) -> String { - let t = self.t(); - match status.backend { - BackendKind::Live => i18n::fmt1(t.engine_connected, "bin", &status.binary), - BackendKind::Demo if status.binary == "demo" => t.engine_missing.to_string(), - BackendKind::Demo => { - // Keep the CLI error detail from the client when present. - let detail = status - .message - .strip_prefix("CLI present but unusable (") - .and_then(|rest| rest.strip_suffix("); using demo data")) - .unwrap_or(status.message.as_str()); - i18n::fmt1(t.engine_unusable, "err", detail) - } - } - } - - fn refresh(&mut self, cx: &mut Context) { - let t = self.t(); - self.status = self.client.status(); - self.status.message = self.localize_engine_message(&self.status); - let mut ok = true; - match self.client.list_containers(self.show_all_containers) { - Ok(rows) => { - self.containers = rows; - if self - .selected_container - .is_some_and(|i| i >= self.containers.len()) - { - self.selected_container = None; - } - } - Err(err) => { - ok = false; - self.status_line = i18n::fmt1(t.list_containers_failed, "err", err.to_string()).into(); - } - } - match self.client.list_images() { - Ok(rows) => { - self.images = rows; - if self.selected_image.is_some_and(|i| i >= self.images.len()) { - self.selected_image = None; - } - } - Err(err) => { - ok = false; - self.status_line = i18n::fmt1(t.list_images_failed, "err", err.to_string()).into(); - } - } - match self.client.list_volumes() { - Ok(rows) => { - self.volumes = rows; - if self.selected_volume.is_some_and(|i| i >= self.volumes.len()) { - self.selected_volume = None; - } - } - Err(err) => { - ok = false; - self.status_line = i18n::fmt1(t.list_volumes_failed, "err", err.to_string()).into(); - } - } - if ok { - let mode = match self.status.backend { - BackendKind::Live => t.mode_live, - BackendKind::Demo => t.mode_demo, - }; - self.status_line = i18n::fmt4( - t.refreshed_summary, - "containers", - self.containers.len().to_string(), - "images", - self.images.len().to_string(), - "volumes", - self.volumes.len().to_string(), - "mode", - mode, - ) - .into(); - } - cx.notify(); - } - - fn with_busy( - &mut self, - cx: &mut Context, - op: impl FnOnce(&WslcClient) -> anyhow::Result, - ) { - if self.busy || self.pull_phase == PullPhase::Pulling { - return; - } - self.busy = true; - cx.notify(); - match op(&self.client) { - Ok(msg) => { - self.busy = false; - self.set_status(msg, cx); - self.refresh(cx); - } - Err(err) => { - self.busy = false; - let msg = i18n::fmt1(self.t().error_prefix, "err", err.to_string()); - self.set_status(msg, cx); - } - } - } - - fn append_pull_log(&mut self, line: impl Into) { - self.pull_logs.push(line.into()); - if self.pull_logs.len() > MAX_PULL_LOG_LINES { - let drop = self.pull_logs.len() - MAX_PULL_LOG_LINES; - self.pull_logs.drain(0..drop); - } - } - - fn begin_pull(&mut self, reference: String, cx: &mut Context) { - if self.pull_phase == PullPhase::Pulling { - return; - } - let reference = reference.trim().to_string(); - if reference.is_empty() { - self.set_status(self.t().image_ref_empty, cx); - return; - } - - let (tx, rx) = std::sync::mpsc::channel(); - self.client.start_pull(&reference, tx); - self.pull_rx = Some(rx); - self.pull_phase = PullPhase::Pulling; - self.pull_progress = 0.0; - let pulling = i18n::fmt1(self.t().pulling_image, "name", &reference); - self.pull_status = pulling.clone().into(); - self.pull_logs.clear(); - self.append_pull_log(format!("$ wslc pull {reference}")); - self.status_line = pulling.into(); - self.pull_poll_generation = self.pull_poll_generation.wrapping_add(1); - let generation = self.pull_poll_generation; - cx.notify(); - - cx.spawn(async move |this, cx| { - loop { - Timer::after(Duration::from_millis(80)).await; - let cont = this - .update(cx, |this, cx| { - if this.pull_poll_generation != generation { - return false; - } - this.poll_pull(cx) - }) - .unwrap_or(false); - if !cont { - break; - } - } - }) - .detach(); - } - - /// Returns `true` while the pull is still active. - fn poll_pull(&mut self, cx: &mut Context) -> bool { - let Some(rx) = self.pull_rx.take() else { - return false; - }; - - let mut events = Vec::new(); - let mut disconnected = false; - loop { - match rx.try_recv() { - Ok(event) => events.push(event), - Err(TryRecvError::Empty) => break, - Err(TryRecvError::Disconnected) => { - disconnected = true; - break; - } - } - } - - let mut finished = disconnected; - for event in events { - match event { - PullEvent::Status(status) => { - self.pull_status = status.into(); - } - PullEvent::Progress(value) => { - self.pull_progress = value.clamp(0.0, 100.0); - } - PullEvent::Log(line) => { - self.append_pull_log(line); - } - PullEvent::Finished(result) => { - finished = true; - match result { - Ok(()) => { - self.pull_phase = PullPhase::Success; - self.pull_progress = 100.0; - self.pull_status = self.t().pull_complete.into(); - self.append_pull_log(self.t().pull_finished_ok); - self.status_line = self.t().pulled_image.into(); - self.refresh(cx); - } - Err(err) => { - self.pull_phase = PullPhase::Failed; - let msg = i18n::fmt1(self.t().pull_failed_msg, "err", &err); - self.pull_status = msg.clone().into(); - self.append_pull_log(format!("✗ {err}")); - self.status_line = msg.into(); - } - } - } - } - } - - if disconnected && self.pull_phase == PullPhase::Pulling { - self.pull_phase = PullPhase::Failed; - self.pull_status = self.t().pull_channel_disconnected.into(); - self.append_pull_log(self.t().pull_worker_disconnected); - finished = true; - } - - if finished { - self.pull_rx = None; - } else { - self.pull_rx = Some(rx); - } - cx.notify(); - self.pull_phase == PullPhase::Pulling - } - - fn selected_container(&self) -> Option<&ContainerInfo> { - self.selected_container - .and_then(|i| self.containers.get(i)) - } - - fn selected_image(&self) -> Option<&ImageInfo> { - self.selected_image.and_then(|i| self.images.get(i)) - } - - fn selected_volume(&self) -> Option<&VolumeInfo> { - self.selected_volume.and_then(|i| self.volumes.get(i)) - } - - fn render_engine_badge(&self, cx: &Context) -> impl IntoElement { - let (label, color) = match self.status.backend { - BackendKind::Live => ("LIVE", cx.theme().green), - BackendKind::Demo => ("DEMO", cx.theme().yellow), - }; - - h_flex() - .items_center() - .gap_2() - .child( - div() - .px_2() - .py_0p5() - .rounded(cx.theme().radius) - .bg(color.opacity(0.18)) - .text_color(color) - .text_xs() - .font_semibold() - .child(label), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(self.status.version.clone()), - ) - } - - fn render_pull_badge(&self, cx: &Context) -> impl IntoElement { - let color = match self.pull_phase { - PullPhase::Idle => cx.theme().muted_foreground, - PullPhase::Pulling => cx.theme().blue, - PullPhase::Success => cx.theme().green, - PullPhase::Failed => cx.theme().red, - }; - div() - .px_2() - .py_0p5() - .rounded(cx.theme().radius) - .bg(color.opacity(0.18)) - .text_color(color) - .text_xs() - .font_semibold() - .child(self.pull_phase.label(self.t())) - } - - fn render_sidebar(&self, cx: &mut Context) -> impl IntoElement { - let t = self.t(); - Sidebar::new("nav") - .w(px(220.)) - .header( - SidebarHeader::new().child( - h_flex() - .items_center() - .gap_3() - .child( - div() - .flex() - .items_center() - .justify_center() - .size_8() - .rounded(cx.theme().radius) - .bg(cx.theme().sidebar_primary) - .text_color(cx.theme().sidebar_primary_foreground) - .child(Icon::new(IconName::SquareTerminal)), - ) - .child( - v_flex() - .gap_0p5() - .child( - div() - .font_semibold() - .text_sm() - .child("WSLc Desktop"), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(t.app_subtitle), - ), - ), - ), - ) - .child( - SidebarGroup::new(t.browse).child( - SidebarMenu::new().children([ - SidebarMenuItem::new(t.containers) - .icon(IconName::HardDrive) - .active(self.section == Section::Containers) - .on_click(cx.listener(|this, _, _, cx| { - this.section = Section::Containers; - cx.notify(); - })), - SidebarMenuItem::new(t.images) - .icon(IconName::Folder) - .active(self.section == Section::Images) - .on_click(cx.listener(|this, _, _, cx| { - this.section = Section::Images; - cx.notify(); - })), - SidebarMenuItem::new(t.volumes) - .icon(IconName::Inbox) - .active(self.section == Section::Volumes) - .on_click(cx.listener(|this, _, _, cx| { - this.section = Section::Volumes; - cx.notify(); - })), - SidebarMenuItem::new(t.run) - .icon(IconName::Play) - .active(self.section == Section::Run) - .on_click(cx.listener(|this, _, _, cx| { - this.section = Section::Run; - cx.notify(); - })), - ]), - ), - ) - } - - fn render_title_bar(&self, cx: &mut Context) -> impl IntoElement { - let t = self.t(); - TitleBar::new().child( - h_flex() - .id("title-bar-content") - .w_full() - .h_full() - .items_center() - .justify_end() - .pr_2() - .child( - Button::new("titlebar-settings") - .ghost() - .icon(IconName::Settings) - .tooltip(t.settings) - .selected(self.section == Section::Settings) - .on_click(cx.listener(|this, _, _, cx| { - this.section = Section::Settings; - cx.notify(); - })), - ), - ) - } - - fn render_toolbar(&self, cx: &mut Context) -> impl IntoElement { - let t = self.t(); - h_flex() - .items_center() - .justify_between() - .gap_3() - .child( - v_flex() - .gap_1() - .child( - div() - .text_lg() - .font_semibold() - .child(self.section.label(t)), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(self.status.message.clone()), - ), - ) - .child( - h_flex() - .items_center() - .gap_2() - .child(self.render_engine_badge(cx)) - .when(self.section != Section::Settings, |this| { - this.child( - Button::new("refresh") - .label(if self.busy { t.working } else { t.refresh }) - .icon(IconName::RefreshCw) - .disabled(self.busy || self.pull_phase == PullPhase::Pulling) - .on_click(cx.listener(|this, _, _, cx| { - this.set_status(this.t().refreshing, cx); - this.refresh(cx); - })), - ) - }), - ) - } - - fn render_container_row( - &self, - index: usize, - item: &ContainerInfo, - cx: &mut Context, - ) -> impl IntoElement { - let selected = self.selected_container == Some(index); - let running = item.is_running(); - let name = item.name.clone(); - let image = item.image.clone(); - let status = item.status.clone(); - let ports = if item.ports.is_empty() { - "—".to_string() - } else { - item.ports.clone() - }; - let id_short = item.id.chars().take(12).collect::(); - - div() - .id(("container-row", index)) - .w_full() - .px_3() - .py_2() - .rounded(cx.theme().radius) - .border_1() - .border_color(if selected { - cx.theme().border - } else { - cx.theme().transparent - }) - .bg(if selected { - cx.theme().accent.opacity(0.12) - } else { - cx.theme().transparent - }) - .hover(|s| s.bg(cx.theme().accent.opacity(0.08))) - .cursor_pointer() - .on_click(cx.listener(move |this, _, _, cx| { - this.selected_container = Some(index); - cx.notify(); - })) - .child( - h_flex() - .w_full() - .items_center() - .gap_3() - .child( - div() - .size_2() - .rounded_full() - .bg(if running { - cx.theme().green - } else { - cx.theme().muted_foreground.opacity(0.5) - }), - ) - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_0p5() - .child( - h_flex() - .gap_2() - .items_baseline() - .child(div().font_medium().text_sm().child(name)) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(id_short), - ), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .truncate() - .child(format!("{image} · {status} · {ports}")), - ), - ), - ) - } - - fn render_containers(&self, cx: &mut Context) -> impl IntoElement { - let t = self.t(); - let running_count = self.containers.iter().filter(|c| c.is_running()).count(); - - v_flex() - .size_full() - .gap_3() - .child( - h_flex() - .items_center() - .justify_between() - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(i18n::fmt2( - t.containers_count, - "total", - self.containers.len().to_string(), - "running", - running_count.to_string(), - )), - ) - .child( - h_flex() - .gap_2() - .child( - Button::new("toggle-all") - .small() - .label(if self.show_all_containers { - t.all - } else { - t.running - }) - .on_click(cx.listener(|this, _, _, cx| { - this.show_all_containers = !this.show_all_containers; - this.refresh(cx); - })), - ) - .child( - Button::new("start-selected") - .small() - .primary() - .label(t.start) - .disabled( - self.busy - || self.pull_phase == PullPhase::Pulling - || self - .selected_container() - .is_none_or(|c| c.is_running()), - ) - .on_click(cx.listener(|this, _, _, cx| { - let Some(c) = this.selected_container().cloned() else { - return; - }; - let key = if c.name.is_empty() { - c.id.clone() - } else { - c.name.clone() - }; - let template = this.t().started; - this.with_busy(cx, move |client| { - client.start_container(&key)?; - Ok(i18n::fmt1(template, "name", &key)) - }); - })), - ) - .child( - Button::new("stop-selected") - .small() - .label(t.stop) - .disabled( - self.busy - || self.pull_phase == PullPhase::Pulling - || self - .selected_container() - .is_none_or(|c| !c.is_running()), - ) - .on_click(cx.listener(|this, _, _, cx| { - let Some(c) = this.selected_container().cloned() else { - return; - }; - let key = if c.name.is_empty() { - c.id.clone() - } else { - c.name.clone() - }; - let template = this.t().stopped; - this.with_busy(cx, move |client| { - client.stop_container(&key)?; - Ok(i18n::fmt1(template, "name", &key)) - }); - })), - ) - .child( - Button::new("remove-selected") - .small() - .danger() - .label(t.remove) - .disabled( - self.busy - || self.pull_phase == PullPhase::Pulling - || self.selected_container().is_none(), - ) - .on_click(cx.listener(|this, _, _, cx| { - let Some(c) = this.selected_container().cloned() else { - return; - }; - let key = if c.name.is_empty() { - c.id.clone() - } else { - c.name.clone() - }; - let template = this.t().removed; - this.with_busy(cx, move |client| { - client.remove_container(&key)?; - Ok(i18n::fmt1(template, "name", &key)) - }); - })), - ), - ), - ) - .child( - div() - .id("container-list") - .flex_1() - .w_full() - .overflow_y_scroll() - .child({ - if self.containers.is_empty() { - v_flex().gap_1().w_full().child(self.empty_state( - t.no_containers_title, - t.no_containers_detail, - cx, - )) - } else { - let rows: Vec<_> = self - .containers - .iter() - .enumerate() - .map(|(index, item)| { - self.render_container_row(index, item, cx).into_any_element() - }) - .collect(); - v_flex().gap_1().w_full().children(rows) - } - }), - ) - } - - fn render_image_row( - &self, - index: usize, - item: &ImageInfo, - cx: &mut Context, - ) -> impl IntoElement { - let selected = self.selected_image == Some(index); - let reference = item.reference(); - let id_short = item.id.chars().take(16).collect::(); - let meta = format!("{} · {}", item.size, item.created); - - div() - .id(("image-row", index)) - .w_full() - .px_3() - .py_2() - .rounded(cx.theme().radius) - .border_1() - .border_color(if selected { - cx.theme().border - } else { - cx.theme().transparent - }) - .bg(if selected { - cx.theme().accent.opacity(0.12) - } else { - cx.theme().transparent - }) - .hover(|s| s.bg(cx.theme().accent.opacity(0.08))) - .cursor_pointer() - .on_click(cx.listener(move |this, _, _, cx| { - this.selected_image = Some(index); - cx.notify(); - })) - .child( - h_flex() - .w_full() - .items_center() - .gap_3() - .child(Icon::new(IconName::Folder).text_color(cx.theme().muted_foreground)) - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_0p5() - .child(div().font_medium().text_sm().child(reference)) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .truncate() - .child(format!("{id_short} · {meta}")), - ), - ), - ) - } - - fn render_pull_panel(&self, cx: &mut Context) -> impl IntoElement { - let t = self.t(); - let pulling = self.pull_phase == PullPhase::Pulling; - let log_lines: Vec<_> = self - .pull_logs - .iter() - .enumerate() - .map(|(i, line)| { - div() - .id(("pull-log", i)) - .text_xs() - .font_family("monospace") - .text_color(cx.theme().muted_foreground) - .child(line.clone()) - .into_any_element() - }) - .collect(); - - v_flex() - .gap_3() - .p_3() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().secondary.opacity(0.28)) - .child( - h_flex() - .items_center() - .justify_between() - .child(div().font_medium().text_sm().child(t.pull_image)) - .child(self.render_pull_badge(cx)), - ) - .child( - h_flex() - .gap_2() - .items_center() - .child(div().flex_1().min_w_0().child(Input::new(&self.pull_input))) - .child( - Button::new("pull-image") - .primary() - .label(if pulling { t.pulling } else { t.pull }) - .icon(IconName::ArrowDown) - .disabled(pulling || self.busy) - .on_click(cx.listener(|this, _, _, cx| { - let reference = this.pull_input.read(cx).value().to_string(); - this.begin_pull(reference, cx); - })), - ), - ) - .child( - v_flex() - .gap_1() - .child( - h_flex() - .items_center() - .justify_between() - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .truncate() - .child(self.pull_status.clone()), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(format!("{:.0}%", self.pull_progress)), - ), - ) - .child( - Progress::new("pull-progress") - .w_full() - .h_2() - .value(self.pull_progress), - ), - ) - .child( - div() - .id("pull-log") - .h(px(120.)) - .w_full() - .overflow_y_scroll() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().background.opacity(0.65)) - .p_2() - .child( - v_flex().gap_0p5().w_full().map(|list| { - if log_lines.is_empty() { - list.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(t.pull_logs_placeholder), - ) - } else { - list.children(log_lines) - } - }), - ), - ) - } - - fn render_images(&self, cx: &mut Context) -> impl IntoElement { - let t = self.t(); - v_flex() - .size_full() - .gap_3() - .child(self.render_pull_panel(cx)) - .child( - h_flex() - .items_center() - .justify_between() - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(i18n::fmt1( - t.images_count, - "count", - self.images.len().to_string(), - )), - ) - .child( - h_flex() - .gap_2() - .child( - Button::new("run-image") - .small() - .primary() - .label(t.run) - .disabled( - self.busy - || self.pull_phase == PullPhase::Pulling - || self.selected_image().is_none(), - ) - .on_click(cx.listener(|this, _, window, cx| { - let Some(img) = this.selected_image().cloned() else { - return; - }; - let reference = img.reference(); - this.image_input.update(cx, |state, cx| { - state.set_value(&reference, window, cx); - }); - this.section = Section::Run; - cx.notify(); - })), - ) - .child( - Button::new("remove-image") - .small() - .danger() - .label(t.remove) - .disabled( - self.busy - || self.pull_phase == PullPhase::Pulling - || self.selected_image().is_none(), - ) - .on_click(cx.listener(|this, _, _, cx| { - let Some(img) = this.selected_image().cloned() else { - return; - }; - let reference = img.reference(); - let template = this.t().removed; - this.with_busy(cx, move |client| { - client.remove_image(&reference)?; - Ok(i18n::fmt1(template, "name", &reference)) - }); - })), - ), - ), - ) - .child( - div() - .id("image-list") - .flex_1() - .w_full() - .overflow_y_scroll() - .child({ - if self.images.is_empty() { - v_flex().gap_1().w_full().child(self.empty_state( - t.no_images_title, - t.no_images_detail, - cx, - )) - } else { - let rows: Vec<_> = self - .images - .iter() - .enumerate() - .map(|(index, item)| { - self.render_image_row(index, item, cx).into_any_element() - }) - .collect(); - v_flex().gap_1().w_full().children(rows) - } - }), - ) - } - - fn render_volume_row( - &self, - index: usize, - item: &VolumeInfo, - cx: &mut Context, - ) -> impl IntoElement { - let selected = self.selected_volume == Some(index); - let name = item.name.clone(); - let driver = item.driver.clone(); - let mount = if item.mountpoint.is_empty() { - "—".to_string() - } else { - item.mountpoint.clone() - }; - let meta = format!( - "{} · {} · {}", - driver, - if item.size.is_empty() { - "—" - } else { - item.size.as_str() - }, - if item.created.is_empty() { - "—" - } else { - item.created.as_str() - } - ); - - div() - .id(("volume-row", index)) - .w_full() - .px_3() - .py_2() - .rounded(cx.theme().radius) - .border_1() - .border_color(if selected { - cx.theme().border - } else { - cx.theme().transparent - }) - .bg(if selected { - cx.theme().accent.opacity(0.12) - } else { - cx.theme().transparent - }) - .hover(|s| s.bg(cx.theme().accent.opacity(0.08))) - .cursor_pointer() - .on_click(cx.listener(move |this, _, _, cx| { - this.selected_volume = Some(index); - cx.notify(); - })) - .child( - h_flex() - .w_full() - .items_center() - .gap_3() - .child( - Icon::new(IconName::HardDrive).text_color(cx.theme().muted_foreground), - ) - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_0p5() - .child(div().font_medium().text_sm().child(name)) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .truncate() - .child(format!("{meta} · {mount}")), - ), - ), - ) - } - - fn render_volumes(&self, cx: &mut Context) -> impl IntoElement { - let t = self.t(); - v_flex() - .size_full() - .gap_3() - .child( - h_flex() - .gap_2() - .items_center() - .child(div().flex_1().min_w_0().child(Input::new(&self.volume_input))) - .child( - Button::new("create-volume") - .primary() - .label(t.create) - .icon(IconName::Plus) - .disabled(self.busy || self.pull_phase == PullPhase::Pulling) - .on_click(cx.listener(|this, _, _, cx| { - let name = this.volume_input.read(cx).value().to_string(); - let template = this.t().created_volume; - this.with_busy(cx, move |client| { - client.create_volume(&name)?; - Ok(i18n::fmt1(template, "name", &name)) - }); - })), - ) - .child( - Button::new("remove-volume") - .danger() - .label(t.remove) - .disabled( - self.busy - || self.pull_phase == PullPhase::Pulling - || self.selected_volume().is_none(), - ) - .on_click(cx.listener(|this, _, _, cx| { - let Some(vol) = this.selected_volume().cloned() else { - return; - }; - let name = vol.name; - let template = this.t().removed; - this.with_busy(cx, move |client| { - client.remove_volume(&name)?; - Ok(i18n::fmt1(template, "name", &name)) - }); - })), - ), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(i18n::fmt1( - t.volumes_count, - "count", - self.volumes.len().to_string(), - )), - ) - .child( - div() - .id("volume-list") - .flex_1() - .w_full() - .overflow_y_scroll() - .child({ - if self.volumes.is_empty() { - v_flex().gap_1().w_full().child(self.empty_state( - t.no_volumes_title, - t.no_volumes_detail, - cx, - )) - } else { - let rows: Vec<_> = self - .volumes - .iter() - .enumerate() - .map(|(index, item)| { - self.render_volume_row(index, item, cx).into_any_element() - }) - .collect(); - v_flex().gap_1().w_full().children(rows) - } - }), - ) - } - - fn render_run(&self, cx: &mut Context) -> impl IntoElement { - let t = self.t(); - v_flex() - .size_full() - .gap_4() - .max_w(px(520.)) - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(t.run_intro), - ) - .child( - v_flex() - .gap_2() - .child(Label::new(t.image_label)) - .child(Input::new(&self.image_input)), - ) - .child( - v_flex() - .gap_2() - .child(Label::new(t.name_optional)) - .child(Input::new(&self.name_input)), - ) - .child( - h_flex().gap_2().child( - Button::new("run-detached") - .primary() - .label(t.run_detached) - .icon(IconName::Play) - .disabled(self.busy || self.pull_phase == PullPhase::Pulling) - .on_click(cx.listener(|this, _, _, cx| { - let image = this.image_input.read(cx).value().to_string(); - let name = this.name_input.read(cx).value().to_string(); - let name_opt = if name.trim().is_empty() { - None - } else { - Some(name) - }; - let template = this.t().launched; - this.with_busy(cx, move |client| { - let launched = - client.run_detached(&image, name_opt.as_deref())?; - Ok(i18n::fmt1(template, "name", &launched)) - }); - this.section = Section::Containers; - })), - ), - ) - .child( - div() - .mt_4() - .p_3() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().secondary.opacity(0.35)) - .child( - v_flex() - .gap_2() - .child(div().font_medium().text_sm().child(t.cli_equivalents)) - .child( - div() - .text_xs() - .font_family("monospace") - .text_color(cx.theme().muted_foreground) - .child("wslc pull "), - ) - .child( - div() - .text_xs() - .font_family("monospace") - .text_color(cx.theme().muted_foreground) - .child("wslc run -d --name "), - ) - .child( - div() - .text_xs() - .font_family("monospace") - .text_color(cx.theme().muted_foreground) - .child("wslc volume list"), - ), - ), - ) - } - - fn render_settings(&self, cx: &mut Context) -> impl IntoElement { - let t = self.t(); - let locale = self.locale; - let theme_mode = self.theme_mode; - - v_flex() - .size_full() - .gap_6() - .max_w(px(560.)) - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(t.settings_intro), - ) - .child( - v_flex() - .gap_3() - .p_4() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().secondary.opacity(0.28)) - .child(div().font_medium().text_sm().child(t.language)) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(t.language_hint), - ) - .child( - h_flex() - .gap_2() - .child( - Button::new("lang-zh") - .label(Locale::Zh.label()) - .when(locale == Locale::Zh, |b| b.primary()) - .on_click(cx.listener(|this, _, window, cx| { - this.apply_locale(Locale::Zh, window, cx); - })), - ) - .child( - Button::new("lang-en") - .label(Locale::En.label()) - .when(locale == Locale::En, |b| b.primary()) - .on_click(cx.listener(|this, _, window, cx| { - this.apply_locale(Locale::En, window, cx); - })), - ), - ), - ) - .child( - v_flex() - .gap_3() - .p_4() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().secondary.opacity(0.28)) - .child(div().font_medium().text_sm().child(t.theme)) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(t.theme_hint), - ) - .child( - h_flex() - .gap_2() - .child( - Button::new("theme-dark") - .label(t.theme_dark) - .when(theme_mode == ThemeMode::Dark, |b| b.primary()) - .on_click(cx.listener(|this, _, window, cx| { - this.apply_theme(ThemeMode::Dark, window, cx); - })), - ) - .child( - Button::new("theme-light") - .label(t.theme_light) - .when(theme_mode == ThemeMode::Light, |b| b.primary()) - .on_click(cx.listener(|this, _, window, cx| { - this.apply_theme(ThemeMode::Light, window, cx); - })), - ), - ), - ) - } - - fn empty_state( - &self, - title: &'static str, - detail: &'static str, - cx: &Context, - ) -> impl IntoElement { - v_flex() - .items_center() - .justify_center() - .gap_2() - .py_16() - .child( - div() - .text_sm() - .font_medium() - .text_color(cx.theme().foreground) - .child(title), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .max_w(px(360.)) - .text_center() - .child(detail), - ) - } - - fn render_status_bar(&self, cx: &Context) -> impl IntoElement { - h_flex() - .h_8() - .px_3() - .items_center() - .justify_between() - .border_t_1() - .border_color(cx.theme().border) - .bg(cx.theme().tab_bar) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .truncate() - .child(self.status_line.clone()), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(i18n::fmt1( - self.t().binary_label, - "name", - &self.status.binary, - )), - ) - } -} - -impl Render for AppView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - h_flex() - .size_full() - .bg(cx.theme().background) - .child(self.render_sidebar(cx)) - .child( - v_flex() - .size_full() - .min_w_0() - .child(self.render_title_bar(cx)) - .child( - v_flex() - .flex_1() - .min_h_0() - .p_4() - .gap_4() - .child(self.render_toolbar(cx)) - .child( - div().id("section").flex_1().min_h_0().map(|panel| { - match self.section { - Section::Containers => { - panel.child(self.render_containers(cx)) - } - Section::Images => panel.child(self.render_images(cx)), - Section::Volumes => panel.child(self.render_volumes(cx)), - Section::Run => panel.child(self.render_run(cx)), - Section::Settings => panel.child(self.render_settings(cx)), - } - }), - ), - ) - .child(self.render_status_bar(cx)), - ) - } -} diff --git a/src/app/confirm.rs b/src/app/confirm.rs new file mode 100644 index 0000000..3a73901 --- /dev/null +++ b/src/app/confirm.rs @@ -0,0 +1,117 @@ + +use gpui_kit::component::{ + ActiveTheme, Sizable as _, StyledExt as _, + button::{Button, ButtonVariants as _}, + h_flex, +}; +use gpui_kit::*; + +use crate::i18n::{self}; + +use super::state::{ConfirmAction, PullPhase}; +use super::widgets::{modal_panel, modal_scrim}; +use super::AppView; + +impl AppView { + pub(crate) fn request_confirm(&mut self, action: ConfirmAction, cx: &mut Context) { + if self.sdk_locked() + || self.busy + || self.pull_phase == PullPhase::Pulling + || self.confirm.is_some() + { + return; + } + self.confirm = Some(action); + cx.notify(); + } + + pub(crate) fn dismiss_confirm(&mut self, cx: &mut Context) { + self.confirm = None; + cx.notify(); + } + + pub(crate) fn submit_confirm(&mut self, cx: &mut Context) { + let Some(action) = self.confirm.take() else { + return; + }; + match action { + ConfirmAction::RemoveContainer { key, .. } => { + let template = self.t().removed; + self.with_busy(cx, move |client| { + client.remove_container(&key)?; + Ok(i18n::fmt1(template, "name", &key)) + }); + } + ConfirmAction::RemoveImage { reference } => { + let template = self.t().removed; + self.with_busy(cx, move |client| { + client.remove_image(&reference)?; + Ok(i18n::fmt1(template, "name", &reference)) + }); + } + ConfirmAction::RemoveVolume { name } => { + let template = self.t().removed; + self.with_busy(cx, move |client| { + client.remove_volume(&name)?; + Ok(i18n::fmt1(template, "name", &name)) + }); + } + } + } + + pub(crate) fn render_confirm_modal(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); + let action = self + .confirm + .as_ref() + .expect("confirm modal rendered only when open"); + let title = t.confirm_remove_title; + let body = match action { + ConfirmAction::RemoveContainer { label, .. } => { + i18n::fmt1(t.confirm_remove_container, "name", label) + } + ConfirmAction::RemoveImage { reference } => { + i18n::fmt1(t.confirm_remove_image, "name", reference) + } + ConfirmAction::RemoveVolume { name } => { + i18n::fmt1(t.confirm_remove_volume, "name", name) + } + }; + + modal_scrim( + "confirm-remove-modal", + cx, + modal_panel(cx) + .w(px(420.)) + .child(div().font_semibold().text_base().child(title)) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(body), + ) + .child( + h_flex() + .justify_end() + .gap_2() + .child( + Button::new("confirm-cancel") + .small() + .label(t.cancel) + .on_click(cx.listener(|this, _, _, cx| { + this.dismiss_confirm(cx); + })), + ) + .child( + Button::new("confirm-ok") + .small() + .danger() + .label(t.remove) + .on_click(cx.listener(|this, _, _, cx| { + this.submit_confirm(cx); + })), + ), + ), + ) + } +} diff --git a/src/app/containers.rs b/src/app/containers.rs new file mode 100644 index 0000000..e03ee05 --- /dev/null +++ b/src/app/containers.rs @@ -0,0 +1,749 @@ + +use gpui_kit::component::{ + ActiveTheme, Disableable as _, IconName, Sizable as _, StyledExt as _, + button::{Button, ButtonVariants as _}, + h_flex, + input::Input, + label::Label, + v_flex, +}; +use gpui_kit::prelude::{FluentBuilder as _, InteractiveElement as _}; +use gpui_kit::*; + +use crate::i18n::{self}; +use crate::wslc::{ + ContainerInfo, CreateContainerRequest, +}; + +use super::state::{ConfirmAction, PullPhase}; +use super::AppView; +use super::parsing::{parse_bind_mounts, parse_named_volumes, parse_port_mappings}; +use super::widgets::{ + list_header, modal_panel, modal_scrim, resource_list, resource_row, row_actions, +}; + +impl AppView { + pub(crate) fn reset_create_form(&mut self, window: &mut Window, cx: &mut Context) { + let t = self.t(); + self.image_input.update(cx, |state, cx| { + state.set_value("nginx:alpine", window, cx); + }); + self.name_input.update(cx, |state, cx| { + state.set_value("", window, cx); + state.set_placeholder(t.name_placeholder, window, cx); + }); + self.ports_input.update(cx, |state, cx| { + state.set_value("", window, cx); + state.set_placeholder(t.ports_placeholder, window, cx); + }); + self.bind_mounts_input.update(cx, |state, cx| { + state.set_value("", window, cx); + state.set_placeholder(t.bind_mounts_placeholder, window, cx); + }); + self.named_volumes_input.update(cx, |state, cx| { + state.set_value("", window, cx); + state.set_placeholder(t.named_volumes_placeholder, window, cx); + }); + self.command_input.update(cx, |state, cx| { + state.set_value("", window, cx); + state.set_placeholder(t.command_placeholder, window, cx); + }); + self.create_auto_remove = false; + } + + pub(crate) fn open_create_modal( + &mut self, + prefill_image: Option, + window: &mut Window, + cx: &mut Context, + ) { + if self.sdk_locked() || self.ops_disabled() { + return; + } + self.reset_create_form(window, cx); + if let Some(image) = prefill_image { + self.image_input.update(cx, |state, cx| { + state.set_value(&image, window, cx); + }); + } + self.create_modal_open = true; + cx.notify(); + } + + pub(crate) fn dismiss_create_modal( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + if self.busy { + return; + } + self.create_modal_open = false; + self.reset_create_form(window, cx); + cx.notify(); + } + + pub(crate) fn dismiss_logs_modal(&mut self, cx: &mut Context) { + self.logs_modal_open = false; + self.logs_text = SharedString::default(); + self.logs_title = SharedString::default(); + cx.notify(); + } + + pub(crate) fn submit_create_container( + &mut self, + _window: &mut Window, + cx: &mut Context, + ) { + if self.sdk_locked() || self.busy || self.pull_phase == PullPhase::Pulling { + return; + } + let t = self.t(); + let image = self.image_input.read(cx).value().trim().to_string(); + if image.is_empty() { + self.set_status(t.image_empty, cx); + return; + } + let name_raw = self.name_input.read(cx).value().to_string(); + let name = name_raw.trim(); + let name_opt = if name.is_empty() { + None + } else { + Some(name.to_string()) + }; + let ports_raw = self.ports_input.read(cx).value().to_string(); + let mounts_raw = self.bind_mounts_input.read(cx).value().to_string(); + let named_raw = self.named_volumes_input.read(cx).value().to_string(); + let command_raw = self.command_input.read(cx).value().to_string(); + let command_trim = command_raw.trim(); + let command_opt = if command_trim.is_empty() { + None + } else { + Some(command_trim.to_string()) + }; + + let ports = match parse_port_mappings(&ports_raw) { + Ok(p) => p, + Err(err) => { + self.set_status(i18n::fmt1(t.parse_ports_failed, "err", err), cx); + return; + } + }; + let mut volumes = match parse_bind_mounts(&mounts_raw) { + Ok(v) => v, + Err(err) => { + self.set_status(i18n::fmt1(t.parse_bind_mounts_failed, "err", err), cx); + return; + } + }; + match parse_named_volumes(&named_raw) { + Ok(mut named) => volumes.append(&mut named), + Err(err) => { + self.set_status(i18n::fmt1(t.parse_named_volumes_failed, "err", err), cx); + return; + } + } + + let request = CreateContainerRequest { + image, + name: name_opt, + ports, + volumes, + auto_remove: self.create_auto_remove, + command: command_opt, + }; + + let template = t.created_container; + self.with_busy(cx, move |client| { + let created = client.create_container(request)?; + Ok(i18n::fmt1(template, "name", &created)) + }); + } + + pub(crate) fn open_container_logs(&mut self, index: usize, cx: &mut Context) { + if self.ops_disabled() { + return; + } + let Some(item) = self.containers.get(index) else { + return; + }; + let key = Self::container_key(item); + let title = key.clone(); + let failed = self.t().container_logs_failed; + self.with_busy(cx, move |client| { + let text = client + .container_logs(&key) + .map_err(|e| anyhow::anyhow!(i18n::fmt1(failed, "err", e.to_string())))?; + Ok(format!("__LOGS__\0{title}\0{text}")) + }); + } + + pub(crate) fn open_container_inspect(&mut self, index: usize, cx: &mut Context) { + if self.ops_disabled() { + return; + } + let Some(item) = self.containers.get(index) else { + return; + }; + let key = Self::container_key(item); + let title = format!("{} · inspect", key); + let failed = self.t().container_inspect_failed; + self.with_busy(cx, move |client| { + let text = client + .container_inspect(&key) + .map_err(|e| anyhow::anyhow!(i18n::fmt1(failed, "err", e.to_string())))?; + Ok(format!("__LOGS__\0{title}\0{text}")) + }); + } + + pub(crate) fn open_exec_modal(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + if self.ops_disabled() { + return; + } + let Some(item) = self.containers.get(index) else { + return; + }; + let key = Self::container_key(item); + let t = self.t(); + self.exec_target_key = key.into(); + self.exec_input.update(cx, |state, cx| { + state.set_value("uname -a", window, cx); + state.set_placeholder(t.container_exec_placeholder, window, cx); + }); + self.exec_modal_open = true; + cx.notify(); + } + + pub(crate) fn dismiss_exec_modal(&mut self, cx: &mut Context) { + if self.busy { + return; + } + self.exec_modal_open = false; + self.exec_target_key = SharedString::default(); + cx.notify(); + } + + pub(crate) fn submit_exec(&mut self, cx: &mut Context) { + if self.sdk_locked() || self.busy { + return; + } + let key = self.exec_target_key.to_string(); + if key.is_empty() { + return; + } + let command = self.exec_input.read(cx).value().to_string(); + let title = format!("{key} · exec"); + let failed = self.t().container_exec_failed; + self.exec_modal_open = false; + self.with_busy(cx, move |client| { + let text = client + .container_exec(&key, &command) + .map_err(|e| anyhow::anyhow!(i18n::fmt1(failed, "err", e.to_string())))?; + Ok(format!("__LOGS__\0{title}\0{text}")) + }); + } + pub(crate) fn render_container_row( + &self, + index: usize, + item: &ContainerInfo, + cx: &mut Context, + ) -> impl IntoElement { + let t = self.t(); + let selected = self.selected_container == Some(index); + let running = item.is_running(); + let display_name = if item.name.is_empty() { + item.id.chars().take(12).collect::() + } else { + item.name.clone() + }; + let id_short = item.id.chars().take(12).collect::(); + let image = item.image.clone(); + let status = item.status.clone(); + let ports = if item.ports.is_empty() { + "—".to_string() + } else { + item.ports.clone() + }; + let volumes = if item.volumes.is_empty() { + "—".to_string() + } else { + item.volumes.clone() + }; + let ports_label = t.ports_label; + let mounts_label = t.bind_mounts_label; + let ops_off = self.ops_disabled(); + + resource_row(("container-row", index), selected, cx) + .on_click(cx.listener(move |this, _, _, cx| { + this.selected_container = Some(index); + cx.notify(); + })) + .child( + h_flex() + .w_full() + .items_start() + .gap_3() + .child( + div() + .mt_1() + .size_2() + .rounded_full() + .bg(if running { + cx.theme().green + } else { + cx.theme().muted_foreground.opacity(0.5) + }), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child( + h_flex() + .gap_2() + .items_center() + .flex_wrap() + .child(div().font_medium().text_sm().child(display_name)) + .when(!item.name.is_empty(), |row| { + row.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(id_short), + ) + }) + .when(item.auto_remove, |row| { + row.child( + div() + .px_1p5() + .py_0p5() + .rounded(cx.theme().radius) + .bg(cx.theme().yellow.opacity(0.2)) + .text_color(cx.theme().yellow) + .text_xs() + .font_semibold() + .child(t.auto_remove_badge), + ) + }), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .truncate() + .child(image), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(status), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .truncate() + .child(format!("{ports_label}: {ports}")), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .truncate() + .child(format!("{mounts_label}: {volumes}")), + ), + ) + .child( + row_actions() + .child( + Button::new(("container-start", index)) + .small() + .label(t.start) + .disabled(ops_off || running) + .on_click(cx.listener(move |this, _, _, cx| { + this.selected_container = Some(index); + let key = this + .containers + .get(index) + .map(Self::container_key) + .unwrap_or_default(); + let template = this.t().started; + this.with_busy(cx, move |client| { + client.start_container(&key)?; + Ok(i18n::fmt1(template, "name", &key)) + }); + })), + ) + .child( + Button::new(("container-stop", index)) + .small() + .label(t.stop) + .disabled(ops_off || !running) + .on_click(cx.listener(move |this, _, _, cx| { + this.selected_container = Some(index); + let key = this + .containers + .get(index) + .map(Self::container_key) + .unwrap_or_default(); + let template = this.t().stopped; + this.with_busy(cx, move |client| { + client.stop_container(&key)?; + Ok(i18n::fmt1(template, "name", &key)) + }); + })), + ) + .child( + Button::new(("container-restart", index)) + .small() + .label(t.restart) + .disabled(ops_off) + .on_click(cx.listener(move |this, _, _, cx| { + this.selected_container = Some(index); + let key = this + .containers + .get(index) + .map(Self::container_key) + .unwrap_or_default(); + let template = this.t().restarted; + this.with_busy(cx, move |client| { + client.restart_container(&key)?; + Ok(i18n::fmt1(template, "name", &key)) + }); + })), + ) + .child( + Button::new(("container-logs", index)) + .small() + .label(t.logs) + .disabled(ops_off) + .on_click(cx.listener(move |this, _, _, cx| { + this.open_container_logs(index, cx); + })), + ) + .child( + Button::new(("container-inspect", index)) + .small() + .label(t.inspect) + .disabled(ops_off) + .on_click(cx.listener(move |this, _, _, cx| { + this.open_container_inspect(index, cx); + })), + ) + .child( + Button::new(("container-exec", index)) + .small() + .label(t.exec) + .disabled(ops_off || !running) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_exec_modal(index, window, cx); + })), + ) + .child( + Button::new(("container-remove", index)) + .small() + .danger() + .label(t.remove) + .disabled(ops_off) + .on_click(cx.listener(move |this, _, _, cx| { + this.selected_container = Some(index); + let Some(item) = this.containers.get(index) else { + return; + }; + let key = Self::container_key(item); + let label = if item.name.is_empty() { + item.id.chars().take(12).collect::() + } else { + item.name.clone() + }; + this.request_confirm( + ConfirmAction::RemoveContainer { key, label }, + cx, + ); + })), + ), + ), + ) + } + + pub(crate) fn render_containers(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); + let running_count = self.containers.iter().filter(|c| c.is_running()).count(); + + v_flex() + .size_full() + .gap_3() + .child(list_header( + self.muted_count( + i18n::fmt2( + t.containers_count, + "total", + self.containers.len().to_string(), + "running", + running_count.to_string(), + ), + cx, + ), + h_flex() + .gap_2() + .child( + Button::new("create-container") + .small() + .primary() + .label(t.create) + .icon(IconName::Plus) + .disabled(self.ops_disabled()) + .on_click(cx.listener(|this, _, window, cx| { + this.open_create_modal(None, window, cx); + })), + ) + .child( + Button::new("toggle-all") + .small() + .label(if self.show_all_containers { + t.all + } else { + t.running + }) + .disabled(self.ops_disabled()) + .on_click(cx.listener(|this, _, _, cx| { + this.show_all_containers = !this.show_all_containers; + this.refresh(cx); + })), + ), + )) + .child( + resource_list("container-list").child({ + if self.containers.is_empty() { + v_flex().gap_1().w_full().child(self.empty_state( + t.no_containers_title, + t.no_containers_detail, + cx, + )) + } else { + let rows: Vec<_> = self + .containers + .iter() + .enumerate() + .map(|(index, item)| { + self.render_container_row(index, item, cx).into_any_element() + }) + .collect(); + v_flex().gap_1().w_full().children(rows) + } + }), + ) + } + pub(crate) fn render_create_modal(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); + let creating = self.busy && self.create_modal_open; + let auto_remove = self.create_auto_remove; + + modal_scrim( + "create-container-modal", + cx, + modal_panel(cx) + .w(px(560.)) + .max_h(px(640.)) + .child(div().font_semibold().text_base().child(t.create_container_title)) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t.create_container_intro), + ) + .child( + v_flex() + .gap_2() + .child(Label::new(t.image_label)) + .child(Input::new(&self.image_input)), + ) + .child( + v_flex() + .gap_2() + .child(Label::new(t.name_optional)) + .child(Input::new(&self.name_input)), + ) + .child( + v_flex() + .gap_2() + .child(Label::new(t.ports_label)) + .child(Input::new(&self.ports_input)), + ) + .child( + v_flex() + .gap_2() + .child(Label::new(t.bind_mounts_label)) + .child(Input::new(&self.bind_mounts_input)), + ) + .child( + v_flex() + .gap_2() + .child(Label::new(t.named_volumes_label)) + .child(Input::new(&self.named_volumes_input)), + ) + .child( + h_flex() + .items_center() + .justify_between() + .child(Label::new(t.auto_remove_label)) + .child( + Button::new("create-auto-remove") + .small() + .label(if auto_remove { + t.auto_remove_on + } else { + t.auto_remove_off + }) + .when(auto_remove, |b| b.primary()) + .disabled(creating) + .on_click(cx.listener(|this, _, _, cx| { + this.create_auto_remove = !this.create_auto_remove; + cx.notify(); + })), + ), + ) + .child( + v_flex() + .gap_2() + .child(Label::new(t.command_label)) + .child(Input::new(&self.command_input)), + ) + .child( + h_flex() + .gap_2() + .justify_end() + .child( + Button::new("create-cancel") + .label(t.close) + .disabled(creating) + .on_click(cx.listener(|this, _, window, cx| { + this.dismiss_create_modal(window, cx); + })), + ) + .child( + Button::new("create-submit") + .primary() + .label(if creating { t.working } else { t.create }) + .icon(IconName::Plus) + .disabled(self.sdk_locked() || creating || self.pull_phase == PullPhase::Pulling) + .on_click(cx.listener(|this, _, window, cx| { + this.submit_create_container(window, cx); + })), + ), + ), + ) + } + + pub(crate) fn render_exec_modal(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); + let running = self.busy && self.exec_modal_open; + modal_scrim( + "exec-modal", + cx, + modal_panel(cx) + .w(px(520.)) + .child(div().font_semibold().text_base().child(t.container_exec_title)) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("{} · {}", t.container_exec_intro, self.exec_target_key)), + ) + .child(Input::new(&self.exec_input)) + .child( + h_flex() + .justify_end() + .gap_2() + .child( + Button::new("exec-cancel") + .small() + .label(t.cancel) + .disabled(running) + .on_click(cx.listener(|this, _, _, cx| { + this.dismiss_exec_modal(cx); + })), + ) + .child( + Button::new("exec-run") + .small() + .primary() + .label(if running { t.working } else { t.container_exec_run }) + .disabled(self.sdk_locked() || running) + .on_click(cx.listener(|this, _, _, cx| { + this.submit_exec(cx); + })), + ), + ), + ) + } + + pub(crate) fn render_logs_modal(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); + let body: SharedString = if self.logs_text.is_empty() { + t.logs_empty.into() + } else { + self.logs_text.clone() + }; + let heading = if self.logs_title.to_ascii_lowercase().contains("inspect") { + t.container_inspect_title + } else if self.logs_title.to_ascii_lowercase().contains("exec") { + t.container_exec_title + } else { + t.container_logs_title + }; + + modal_scrim( + "logs-modal", + cx, + modal_panel(cx) + .w(px(640.)) + .max_h(px(520.)) + .child( + h_flex() + .items_center() + .justify_between() + .child(div().font_semibold().text_base().child(heading)) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .truncate() + .child(self.logs_title.clone()), + ), + ) + .child( + div() + .id("container-logs-body") + .h(px(320.)) + .w_full() + .overflow_y_scroll() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().background.opacity(0.65)) + .p_2() + .child( + div() + .text_xs() + .font_family("monospace") + .text_color(cx.theme().foreground) + .child(body), + ), + ) + .child( + h_flex() + .justify_end() + .child( + Button::new("logs-modal-close") + .label(t.close) + .on_click(cx.listener(|this, _, _, cx| { + this.dismiss_logs_modal(cx); + })), + ), + ), + ) + } +} diff --git a/src/app/images.rs b/src/app/images.rs new file mode 100644 index 0000000..61602f7 --- /dev/null +++ b/src/app/images.rs @@ -0,0 +1,426 @@ +use std::time::Duration; + +use gpui_kit::component::{ + ActiveTheme, Disableable as _, Icon, IconName, Sizable as _, StyledExt as _, + button::{Button, ButtonVariants as _}, + h_flex, + input::Input, + progress::Progress, + v_flex, +}; +use gpui_kit::prelude::{FluentBuilder as _, InteractiveElement as _}; +use gpui_kit::*; +use smol::Timer; + +use crate::i18n::{self}; +use crate::wslc::{ + ImageInfo, + PullEvent, +}; + +use super::state::{ConfirmAction, PullPhase, MAX_PULL_LOG_LINES}; +use super::AppView; +use super::poll::recv_all; +use super::widgets::{ + list_header, modal_panel, modal_scrim, resource_list, resource_row, row_actions, +}; + +impl AppView { + pub(crate) fn open_pull_modal(&mut self, window: &mut Window, cx: &mut Context) { + if self.sdk_locked() { + return; + } + self.pull_input.update(cx, |state, cx| { + state.set_value("alpine:latest", window, cx); + }); + self.pull_phase = PullPhase::Idle; + self.pull_status = self.t().pull_hint.into(); + self.pull_progress = 0.0; + self.pull_logs.clear(); + self.pull_modal_open = true; + cx.notify(); + } + + pub(crate) fn dismiss_pull_modal(&mut self, window: &mut Window, cx: &mut Context) { + if self.pull_phase == PullPhase::Pulling { + return; + } + self.pull_modal_open = false; + self.pull_phase = PullPhase::Idle; + self.pull_status = self.t().pull_hint.into(); + self.pull_progress = 0.0; + self.pull_logs.clear(); + self.pull_input.update(cx, |state, cx| { + state.set_value("alpine:latest", window, cx); + }); + cx.notify(); + } + pub(crate) fn append_pull_log(&mut self, line: impl Into) { + self.pull_logs.push(line.into()); + if self.pull_logs.len() > MAX_PULL_LOG_LINES { + let drop = self.pull_logs.len() - MAX_PULL_LOG_LINES; + self.pull_logs.drain(0..drop); + } + } + + pub(crate) fn begin_pull(&mut self, reference: String, cx: &mut Context) { + if self.sdk_locked() || self.pull_phase == PullPhase::Pulling { + return; + } + let reference = reference.trim().to_string(); + if reference.is_empty() { + self.set_status(self.t().image_ref_empty, cx); + return; + } + let Some(client) = self.client.clone() else { + return; + }; + + let (tx, rx) = std::sync::mpsc::channel(); + client.start_pull(&reference, tx); + self.pull_rx = Some(rx); + self.pull_phase = PullPhase::Pulling; + self.pull_progress = 0.0; + let pulling = i18n::fmt1(self.t().pulling_image, "name", &reference); + self.pull_status = pulling.clone().into(); + self.pull_logs.clear(); + self.append_pull_log(format!("sdk pull {reference}")); + self.status_line = pulling.into(); + self.pull_poll_generation = self.pull_poll_generation.wrapping_add(1); + let generation = self.pull_poll_generation; + cx.notify(); + + cx.spawn(async move |this, cx| { + loop { + Timer::after(Duration::from_millis(80)).await; + let cont = this + .update(cx, |this, cx| { + if this.pull_poll_generation != generation { + return false; + } + this.poll_pull(cx) + }) + .unwrap_or(false); + if !cont { + break; + } + } + }) + .detach(); + } + + /// Returns `true` while the pull is still active. + pub(crate) fn poll_pull(&mut self, cx: &mut Context) -> bool { + let Some(rx) = self.pull_rx.take() else { + return false; + }; + + let (events, disconnected) = recv_all(&rx); + + let mut finished = disconnected; + for event in events { + match event { + PullEvent::Status(status) => { + self.pull_status = status.into(); + } + PullEvent::Progress(value) => { + self.pull_progress = value.clamp(0.0, 100.0); + } + PullEvent::Log(line) => { + self.append_pull_log(line); + } + PullEvent::Finished(result) => { + finished = true; + match result { + Ok(()) => { + self.pull_phase = PullPhase::Success; + self.pull_progress = 100.0; + self.pull_status = self.t().pull_complete.into(); + self.append_pull_log(self.t().pull_finished_ok); + self.status_line = self.t().pulled_image.into(); + self.refresh(cx); + } + Err(err) => { + self.pull_phase = PullPhase::Failed; + let msg = i18n::fmt1(self.t().pull_failed_msg, "err", &err); + self.pull_status = msg.clone().into(); + self.append_pull_log(format!("✗ {err}")); + self.status_line = msg.into(); + } + } + } + } + } + + if disconnected && self.pull_phase == PullPhase::Pulling { + self.pull_phase = PullPhase::Failed; + self.pull_status = self.t().pull_channel_disconnected.into(); + self.append_pull_log(self.t().pull_worker_disconnected); + finished = true; + } + + if finished { + self.pull_rx = None; + } else { + self.pull_rx = Some(rx); + } + cx.notify(); + self.pull_phase == PullPhase::Pulling + } + pub(crate) fn render_pull_badge(&self, cx: &Context) -> impl IntoElement { + let color = match self.pull_phase { + PullPhase::Idle => cx.theme().muted_foreground, + PullPhase::Pulling => cx.theme().blue, + PullPhase::Success => cx.theme().green, + PullPhase::Failed => cx.theme().red, + }; + div() + .px_2() + .py_0p5() + .rounded(cx.theme().radius) + .bg(color.opacity(0.18)) + .text_color(color) + .text_xs() + .font_semibold() + .child(self.pull_phase.label(self.t())) + } + pub(crate) fn render_image_row( + &self, + index: usize, + item: &ImageInfo, + cx: &mut Context, + ) -> impl IntoElement { + let t = self.t(); + let selected = self.selected_image == Some(index); + let reference = item.reference(); + let id_short = item.id.chars().take(16).collect::(); + let meta = format!("{} · {}", item.size, item.created); + let ops_off = self.ops_disabled(); + + resource_row(("image-row", index), selected, cx) + .on_click(cx.listener(move |this, _, _, cx| { + this.selected_image = Some(index); + cx.notify(); + })) + .child( + h_flex() + .w_full() + .items_center() + .gap_3() + .child(Icon::new(IconName::Folder).text_color(cx.theme().muted_foreground)) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() + .child(div().font_medium().text_sm().child(reference.clone())) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .truncate() + .child(format!("{id_short} · {meta}")), + ), + ) + .child( + row_actions() + .child( + Button::new(("image-create", index)) + .small() + .label(t.create) + .disabled(ops_off) + .on_click(cx.listener(move |this, _, window, cx| { + this.selected_image = Some(index); + let Some(img) = this.images.get(index).cloned() else { + return; + }; + this.open_create_modal( + Some(img.reference()), + window, + cx, + ); + })), + ) + .child( + Button::new(("image-remove", index)) + .small() + .danger() + .label(t.remove) + .disabled(ops_off) + .on_click(cx.listener(move |this, _, _, cx| { + this.selected_image = Some(index); + let Some(img) = this.images.get(index).cloned() else { + return; + }; + this.request_confirm( + ConfirmAction::RemoveImage { + reference: img.reference(), + }, + cx, + ); + })), + ), + ), + ) + } + pub(crate) fn render_pull_modal(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); + let pulling = self.pull_phase == PullPhase::Pulling; + let can_close = !pulling; + let log_lines: Vec<_> = self + .pull_logs + .iter() + .enumerate() + .map(|(i, line)| { + div() + .id(("pull-log", i)) + .text_xs() + .font_family("monospace") + .text_color(cx.theme().muted_foreground) + .child(line.clone()) + .into_any_element() + }) + .collect(); + + modal_scrim( + "pull-modal", + cx, + modal_panel(cx) + .w(px(560.)) + .max_h(px(520.)) + .child( + h_flex() + .items_center() + .justify_between() + .child(div().font_semibold().text_base().child(t.pull_image)) + .child(self.render_pull_badge(cx)), + ) + .child( + h_flex() + .gap_2() + .items_center() + .child(div().flex_1().min_w_0().child(Input::new(&self.pull_input))) + .child( + Button::new("pull-image") + .primary() + .label(if pulling { t.pulling } else { t.pull }) + .icon(IconName::ArrowDown) + .disabled(self.sdk_locked() || pulling) + .on_click(cx.listener(|this, _, _, cx| { + let reference = this.pull_input.read(cx).value().to_string(); + this.begin_pull(reference, cx); + })), + ), + ) + .child( + v_flex() + .gap_1() + .child( + h_flex() + .items_center() + .justify_between() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .truncate() + .child(self.pull_status.clone()), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("{:.0}%", self.pull_progress)), + ), + ) + .child( + Progress::new("pull-progress") + .w_full() + .h_2() + .value(self.pull_progress), + ), + ) + .child( + div() + .id("pull-log") + .h(px(220.)) + .w_full() + .overflow_y_scroll() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().background.opacity(0.65)) + .p_2() + .child( + v_flex().gap_0p5().w_full().map(|list| { + if log_lines.is_empty() { + list.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t.pull_logs_placeholder), + ) + } else { + list.children(log_lines) + } + }), + ), + ) + .when(can_close, |el| { + el.child( + h_flex() + .justify_end() + .child( + Button::new("pull-modal-close") + .label(t.close) + .on_click(cx.listener(|this, _, window, cx| { + this.dismiss_pull_modal(window, cx); + })), + ), + ) + }), + ) + } + pub(crate) fn render_images(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); + v_flex() + .size_full() + .gap_3() + .child(list_header( + self.muted_count( + i18n::fmt1(t.images_count, "count", self.images.len().to_string()), + cx, + ), + Button::new("open-pull-modal") + .small() + .primary() + .label(t.pull) + .icon(IconName::ArrowDown) + .disabled(self.ops_disabled()) + .on_click(cx.listener(|this, _, window, cx| { + this.open_pull_modal(window, cx); + })), + )) + .child( + resource_list("image-list").child({ + if self.images.is_empty() { + v_flex().gap_1().w_full().child(self.empty_state( + t.no_images_title, + t.no_images_detail, + cx, + )) + } else { + let rows: Vec<_> = self + .images + .iter() + .enumerate() + .map(|(index, item)| { + self.render_image_row(index, item, cx).into_any_element() + }) + .collect(); + v_flex().gap_1().w_full().children(rows) + } + }), + ) + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..e11d452 --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,259 @@ +use std::sync::mpsc::Receiver; + +use gpui_kit::component::{ + ActiveTheme, ThemeMode, + h_flex, + input::InputState, + v_flex, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::*; + +use crate::i18n::Locale; +use crate::wslc::{ + ContainerInfo, EngineStatus, ImageInfo, PullEvent, SdkSetupEvent, SessionPrefs, VolumeInfo, + WslcClient, +}; + +mod confirm; +mod containers; +mod images; +mod overview; +mod parsing; +mod poll; +mod refresh; +mod sdk_setup; +mod settings; +mod shell; +mod state; +mod volumes; +mod widgets; + +pub use state::Section; + +use state::{ConfirmAction, PullPhase, SdkSetupPhase}; + +pub struct AppView { + client: Option, + status: EngineStatus, + section: Section, + locale: Locale, + theme_mode: ThemeMode, + containers: Vec, + images: Vec, + volumes: Vec, + selected_container: Option, + selected_image: Option, + selected_volume: Option, + show_all_containers: bool, + busy: bool, + /// Background result for `with_busy` (start/stop/create/…). + busy_rx: Option>>, + busy_poll_generation: u64, + status_line: SharedString, + image_input: Entity, + name_input: Entity, + ports_input: Entity, + bind_mounts_input: Entity, + named_volumes_input: Entity, + command_input: Entity, + pull_input: Entity, + volume_input: Entity, + exec_input: Entity, + create_auto_remove: bool, + volume_size_gib: u32, + session_prefs: SessionPrefs, + pull_modal_open: bool, + create_modal_open: bool, + volume_modal_open: bool, + logs_modal_open: bool, + logs_title: SharedString, + logs_text: SharedString, + exec_modal_open: bool, + exec_target_key: SharedString, + confirm: Option, + pull_phase: PullPhase, + pull_status: SharedString, + pull_progress: f32, + pull_logs: Vec, + pull_rx: Option>, + pull_poll_generation: u64, + /// `Some` while the blocking SDK setup dialog must stay up. + sdk_setup_phase: Option, + sdk_setup_status: SharedString, + sdk_setup_progress: f32, + sdk_setup_logs: Vec, + sdk_setup_rx: Option>, + sdk_setup_poll_generation: u64, +} + +impl AppView { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let locale = Locale::from_system(); + let t = locale.strings(); + // Theme is applied once in `main` before the window opens to avoid a + // Light→Dark flash from gpui_kit::init's default Light theme. + + let image_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("nginx:alpine") + .default_value("nginx:alpine") + }); + let name_input = + cx.new(|cx| InputState::new(window, cx).placeholder(t.name_placeholder)); + let ports_input = cx.new(|cx| { + InputState::new(window, cx).placeholder(t.ports_placeholder) + }); + let bind_mounts_input = cx.new(|cx| { + InputState::new(window, cx).placeholder(t.bind_mounts_placeholder) + }); + let named_volumes_input = cx.new(|cx| { + InputState::new(window, cx).placeholder(t.named_volumes_placeholder) + }); + let command_input = cx.new(|cx| { + InputState::new(window, cx).placeholder(t.command_placeholder) + }); + let pull_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("library/alpine:latest") + .default_value("alpine:latest") + }); + let volume_input = + cx.new(|cx| InputState::new(window, cx).placeholder(t.volume_placeholder)); + let exec_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(t.container_exec_placeholder) + .default_value("uname -a") + }); + let session_prefs = WslcClient::load_session_prefs(); + + let mut view = Self { + client: None, + status: EngineStatus::unavailable(t.sdk_locked), + section: Section::Overview, + locale, + theme_mode: ThemeMode::Dark, + containers: Vec::new(), + images: Vec::new(), + volumes: Vec::new(), + selected_container: None, + selected_image: None, + selected_volume: None, + show_all_containers: true, + busy: false, + busy_rx: None, + busy_poll_generation: 0, + status_line: t.sdk_locked.into(), + image_input, + name_input, + ports_input, + bind_mounts_input, + named_volumes_input, + command_input, + pull_input, + volume_input, + exec_input, + create_auto_remove: false, + volume_size_gib: 1, + session_prefs, + pull_modal_open: false, + create_modal_open: false, + volume_modal_open: false, + logs_modal_open: false, + logs_title: SharedString::default(), + logs_text: SharedString::default(), + exec_modal_open: false, + exec_target_key: SharedString::default(), + confirm: None, + pull_phase: PullPhase::Idle, + pull_status: t.pull_hint.into(), + pull_progress: 0.0, + pull_logs: Vec::new(), + pull_rx: None, + pull_poll_generation: 0, + sdk_setup_phase: None, + sdk_setup_status: t.sdk_setup_installing.into(), + sdk_setup_progress: 0.0, + sdk_setup_logs: Vec::new(), + sdk_setup_rx: None, + sdk_setup_poll_generation: 0, + }; + view.begin_sdk_setup(false, cx); + view + } +} + +impl Render for AppView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let show_setup = self.sdk_setup_phase.is_some(); + div() + .relative() + .size_full() + .bg(cx.theme().background) + .child( + h_flex() + .size_full() + .child(self.render_sidebar(cx)) + .child( + v_flex() + .size_full() + .min_w_0() + .child(self.render_title_bar(cx)) + .child( + v_flex() + .flex_1() + .min_h_0() + .p_4() + .gap_4() + .child(self.render_toolbar(cx)) + .child( + div().id("section").flex_1().min_h_0().map(|panel| { + match self.section { + Section::Overview => { + panel.child(self.render_overview(cx)) + } + Section::Containers => { + panel.child(self.render_containers(cx)) + } + Section::Images => { + panel.child(self.render_images(cx)) + } + Section::Volumes => { + panel.child(self.render_volumes(cx)) + } + Section::Settings => { + panel.child(self.render_settings(cx)) + } + } + }), + ), + ) + .child(self.render_status_bar(cx)), + ), + ) + .when(show_setup, |root| { + root.child(self.render_sdk_setup_modal(cx)) + }) + .when(self.pull_modal_open, |root| root.child(self.render_pull_modal(cx))) + .when(self.create_modal_open, |root| { + root.child(self.render_create_modal(cx)) + }) + .when(self.volume_modal_open, |root| { + root.child(self.render_volume_modal(cx)) + }) + .when(self.logs_modal_open, |root| root.child(self.render_logs_modal(cx))) + .when(self.exec_modal_open, |root| root.child(self.render_exec_modal(cx))) + .when(self.confirm.is_some(), |root| { + root.child(self.render_confirm_modal(cx)) + }) + .when( + self.busy + && !show_setup + && !self.pull_modal_open + && !self.volume_modal_open + && !self.exec_modal_open + && self.confirm.is_none(), + |root| root.child(self.render_busy_overlay(cx)), + ) + } +} diff --git a/src/app/overview.rs b/src/app/overview.rs new file mode 100644 index 0000000..4eda05f --- /dev/null +++ b/src/app/overview.rs @@ -0,0 +1,277 @@ + +use gpui_kit::component::{ + ActiveTheme, Icon, IconName, Sizable as _, StyledExt as _, + button::{Button, ButtonVariants as _}, + h_flex, + v_flex, +}; +use gpui_kit::*; + +use crate::wslc::BackendKind; + +use super::state::Section; +use super::AppView; + +const SESSION_NAME: &str = "wslc-desktop"; +const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + +impl AppView { + pub(crate) fn render_overview(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); + let running = self.containers.iter().filter(|c| c.is_running()).count(); + let (backend_label, backend_color) = match self.status.backend { + BackendKind::Live => (t.overview_status_live, cx.theme().green), + BackendKind::Unavailable => (t.overview_status_off, cx.theme().red), + }; + let sdk_version = if self.status.version.is_empty() { + t.overview_na.to_string() + } else { + self.status.version.clone() + }; + let storage = std::env::var_os("LOCALAPPDATA") + .map(|p| { + std::path::PathBuf::from(p) + .join("WSLcDesktop") + .join("session") + .display() + .to_string() + }) + .unwrap_or_else(|| r"%LOCALAPPDATA%\WSLcDesktop\session".into()); + + v_flex() + .size_full() + .gap_4() + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(t.overview_intro), + ) + .child( + h_flex() + .w_full() + .gap_3() + .items_stretch() + .child(self.overview_card( + t.overview_engine, + cx, + v_flex() + .gap_3() + .child(self.overview_row( + t.overview_backend, + backend_label, + Some(backend_color), + cx, + )) + .child(self.overview_row( + t.overview_sdk_version, + &sdk_version, + None, + cx, + )) + .child(self.overview_row( + t.overview_app_version, + APP_VERSION, + None, + cx, + )) + .child(self.overview_row( + "wslcsdk", + &self.status.binary, + None, + cx, + )) + .child(self.overview_row( + t.overview_cpu, + if self.status.cpu_count == 0 { + t.overview_na.to_string() + } else { + self.status.cpu_count.to_string() + }, + None, + cx, + )) + .child(self.overview_row( + t.overview_memory, + if self.status.memory_mb == 0 { + t.overview_na.to_string() + } else { + format!("{} MiB", self.status.memory_mb) + }, + None, + cx, + )), + )) + .child(self.overview_card( + t.overview_resources, + cx, + v_flex() + .gap_3() + .child(self.overview_stat_row( + t.overview_containers, + self.containers.len().to_string(), + IconName::HardDrive, + cx, + )) + .child(self.overview_stat_row( + t.overview_running, + running.to_string(), + IconName::Play, + cx, + )) + .child(self.overview_stat_row( + t.overview_images, + self.images.len().to_string(), + IconName::Folder, + cx, + )) + .child(self.overview_stat_row( + t.overview_volumes, + self.volumes.len().to_string(), + IconName::Inbox, + cx, + )), + )), + ) + .child(self.overview_card( + t.overview_session, + cx, + v_flex() + .gap_3() + .child(self.overview_row( + t.overview_session_name, + SESSION_NAME, + None, + cx, + )) + .child(self.overview_row( + t.overview_storage, + &storage, + None, + cx, + )) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.status.message.clone()), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t.overview_persist_hint), + ), + )) + .child( + h_flex() + .gap_2() + .child( + Button::new("overview-go-containers") + .small() + .primary() + .label(t.containers) + .icon(IconName::HardDrive) + .on_click(cx.listener(|this, _, _, cx| { + this.section = Section::Containers; + cx.notify(); + })), + ) + .child( + Button::new("overview-go-images") + .small() + .label(t.images) + .icon(IconName::Folder) + .on_click(cx.listener(|this, _, _, cx| { + this.section = Section::Images; + cx.notify(); + })), + ) + .child( + Button::new("overview-go-volumes") + .small() + .label(t.volumes) + .icon(IconName::Inbox) + .on_click(cx.listener(|this, _, _, cx| { + this.section = Section::Volumes; + cx.notify(); + })), + ), + ) + } + + fn overview_card( + &self, + title: &'static str, + cx: &Context, + child: impl IntoElement, + ) -> impl IntoElement { + v_flex() + .flex_1() + .min_w_0() + .gap_3() + .p_4() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().secondary.opacity(0.28)) + .child(div().font_medium().text_sm().child(title)) + .child(child) + } + + fn overview_row( + &self, + label: &'static str, + value: impl Into, + value_color: Option, + cx: &Context, + ) -> impl IntoElement { + let color = value_color.unwrap_or(cx.theme().foreground); + h_flex() + .w_full() + .items_center() + .justify_between() + .gap_3() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(label), + ) + .child( + div() + .text_sm() + .font_medium() + .text_color(color) + .truncate() + .child(value.into()), + ) + } + + fn overview_stat_row( + &self, + label: &'static str, + value: String, + icon: IconName, + cx: &Context, + ) -> impl IntoElement { + h_flex() + .w_full() + .items_center() + .justify_between() + .gap_3() + .child( + h_flex() + .items_center() + .gap_2() + .child(Icon::new(icon).text_color(cx.theme().muted_foreground)) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(label), + ), + ) + .child(div().text_lg().font_semibold().child(value)) + } +} diff --git a/src/app/parsing.rs b/src/app/parsing.rs new file mode 100644 index 0000000..68b1156 --- /dev/null +++ b/src/app/parsing.rs @@ -0,0 +1,97 @@ +use crate::wslc::{PortMapping, VolumeMapping}; + +pub(crate) fn parse_port_mappings(raw: &str) -> Result, String> { + let mut out = Vec::new(); + for part in raw.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + let Some((host_s, container_s)) = part.split_once(':') else { + return Err(format!("expected host:container, got `{part}`")); + }; + let host = host_s + .trim() + .parse::() + .map_err(|_| format!("invalid host port in `{part}`"))?; + let container = container_s + .trim() + .parse::() + .map_err(|_| format!("invalid container port in `{part}`"))?; + out.push(PortMapping { host, container }); + } + Ok(out) +} + +/// Bind mounts: `C:\data:/data[:ro]` +pub(crate) fn parse_bind_mounts(raw: &str) -> Result, String> { + parse_volume_specs(raw, false) +} + +/// Named session volumes: `data:/data[:ro]` +pub(crate) fn parse_named_volumes(raw: &str) -> Result, String> { + parse_volume_specs(raw, true) +} + +fn parse_volume_specs(raw: &str, named: bool) -> Result, String> { + let mut out = Vec::new(); + for part in raw.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + let lower = part.to_ascii_lowercase(); + let read_only = lower.ends_with(":ro"); + let core = if read_only { + part[..part.len() - 3].trim_end() + } else { + part + }; + let Some(idx) = core.find(":/") else { + return Err(format!("expected host:/container path, got `{part}`")); + }; + let host = core[..idx].trim().to_string(); + let container = core[idx + 1..].trim().to_string(); + if host.is_empty() || container.is_empty() { + return Err(format!("empty host or mount path in `{part}`")); + } + if named { + if host.contains('\\') || host.contains('/') || looks_like_windows_drive(&host) { + return Err(format!( + "named volume expected a simple name, got `{host}`" + )); + } + } + out.push(VolumeMapping { + host, + container, + read_only, + named, + }); + } + Ok(out) +} + +fn looks_like_windows_drive(host: &str) -> bool { + let bytes = host.as_bytes(); + bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_named_and_bind() { + let named = parse_named_volumes("data:/data, cache:/var/cache:ro").unwrap(); + assert_eq!(named.len(), 2); + assert!(named[0].named); + assert!(!named[0].read_only); + assert!(named[1].read_only); + + let bind = parse_bind_mounts(r"C:\data:/data:ro").unwrap(); + assert_eq!(bind.len(), 1); + assert!(!bind[0].named); + assert!(bind[0].read_only); + } +} diff --git a/src/app/poll.rs b/src/app/poll.rs new file mode 100644 index 0000000..d925d9c --- /dev/null +++ b/src/app/poll.rs @@ -0,0 +1,18 @@ +use std::sync::mpsc::{Receiver, TryRecvError}; + +/// Drain all pending events from a channel; returns `(events, disconnected)`. +pub(crate) fn recv_all(rx: &Receiver) -> (Vec, bool) { + let mut events = Vec::new(); + let mut disconnected = false; + loop { + match rx.try_recv() { + Ok(event) => events.push(event), + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + (events, disconnected) +} diff --git a/src/app/refresh.rs b/src/app/refresh.rs new file mode 100644 index 0000000..f712f00 --- /dev/null +++ b/src/app/refresh.rs @@ -0,0 +1,327 @@ +use std::sync::mpsc; +use std::time::Duration; + +use gpui_kit::component::{ + ActiveTheme, Theme, ThemeMode, + h_flex, + label::Label, + v_flex, +}; +use gpui_kit::*; +use smol::Timer; + +use crate::i18n::{self, Locale}; +use crate::wslc::{ + BackendKind, EngineStatus, WslcClient, +}; + +use super::poll::recv_all; +use super::state::PullPhase; +use super::widgets::modal_scrim; +use super::AppView; + +impl AppView { + pub(crate) fn apply_locale(&mut self, locale: Locale, window: &mut Window, cx: &mut Context) { + if self.locale == locale { + return; + } + self.locale = locale; + let t = self.t(); + self.name_input.update(cx, |state, cx| { + state.set_placeholder(t.name_placeholder, window, cx); + }); + self.ports_input.update(cx, |state, cx| { + state.set_placeholder(t.ports_placeholder, window, cx); + }); + self.bind_mounts_input.update(cx, |state, cx| { + state.set_placeholder(t.bind_mounts_placeholder, window, cx); + }); + self.named_volumes_input.update(cx, |state, cx| { + state.set_placeholder(t.named_volumes_placeholder, window, cx); + }); + self.command_input.update(cx, |state, cx| { + state.set_placeholder(t.command_placeholder, window, cx); + }); + self.volume_input.update(cx, |state, cx| { + state.set_placeholder(t.volume_placeholder, window, cx); + }); + self.exec_input.update(cx, |state, cx| { + state.set_placeholder(t.container_exec_placeholder, window, cx); + }); + if self.pull_phase == PullPhase::Idle { + self.pull_status = t.pull_hint.into(); + } + // Relocalize in-memory status only — do not re-probe the SDK. + self.status.message = self.localize_engine_message(&self.status); + if self.sdk_locked() { + self.status_line = t.sdk_locked.into(); + } else { + let mode = match self.status.backend { + BackendKind::Live => t.mode_live, + BackendKind::Unavailable => t.mode_unavailable, + }; + self.status_line = i18n::fmt4( + t.refreshed_summary, + "containers", + self.containers.len().to_string(), + "images", + self.images.len().to_string(), + "volumes", + self.volumes.len().to_string(), + "mode", + mode, + ) + .into(); + } + cx.notify(); + } + + pub(crate) fn apply_theme(&mut self, mode: ThemeMode, window: &mut Window, cx: &mut Context) { + if self.theme_mode == mode { + return; + } + self.theme_mode = mode; + Theme::change(mode, Some(window), cx); + cx.notify(); + } + + pub(crate) fn localize_engine_message(&self, status: &EngineStatus) -> String { + let t = self.t(); + match status.backend { + BackendKind::Live => i18n::fmt1(t.engine_connected, "bin", &status.version), + BackendKind::Unavailable => { + let detail = status.message.as_str(); + let lower = detail.to_ascii_lowercase(); + if lower.contains("sdk_needs_update") { + t.engine_sdk_outdated.to_string() + } else if lower.contains("missing wsl components") + || lower.contains("virtual_machine_platform") + || lower.contains("wsl_package") + { + i18n::fmt1(t.engine_missing_components, "err", detail) + } else if detail.is_empty() + || lower.contains("sdk not found") + || lower.contains("wslcsdk.dll") + || lower.contains("sdk install failed") + { + if detail.is_empty() || lower.contains("wslcsdk.dll") || lower.contains("sdk not found") + { + t.engine_missing.to_string() + } else { + i18n::fmt1(t.engine_unusable, "err", detail) + } + } else { + i18n::fmt1(t.engine_unusable, "err", detail) + } + } + } + } + + pub(crate) fn refresh(&mut self, cx: &mut Context) { + let t = self.t(); + let Some(client) = self.client.clone() else { + self.status = EngineStatus::unavailable(t.sdk_locked); + self.status.message = self.localize_engine_message(&self.status); + self.status_line = t.sdk_locked.into(); + cx.notify(); + return; + }; + + self.status = client.status(); + self.status.message = self.localize_engine_message(&self.status); + + // Live session dropped — lock the UI and force SDK setup again. + if self.status.backend != BackendKind::Live { + self.client = None; + self.containers.clear(); + self.images.clear(); + self.volumes.clear(); + self.selected_container = None; + self.selected_image = None; + self.selected_volume = None; + self.status_line = t.sdk_locked.into(); + if self.sdk_setup_phase.is_none() { + self.begin_sdk_setup(false, cx); + } else { + cx.notify(); + } + return; + } + + let mut ok = true; + match client.list_containers(self.show_all_containers) { + Ok(rows) => { + self.containers = rows; + if self + .selected_container + .is_some_and(|i| i >= self.containers.len()) + { + self.selected_container = None; + } + } + Err(err) => { + ok = false; + self.status_line = i18n::fmt1(t.list_containers_failed, "err", err.to_string()).into(); + } + } + match client.list_images() { + Ok(rows) => { + self.images = rows; + if self.selected_image.is_some_and(|i| i >= self.images.len()) { + self.selected_image = None; + } + } + Err(err) => { + ok = false; + self.status_line = i18n::fmt1(t.list_images_failed, "err", err.to_string()).into(); + } + } + match client.list_volumes() { + Ok(rows) => { + self.volumes = rows; + if self.selected_volume.is_some_and(|i| i >= self.volumes.len()) { + self.selected_volume = None; + } + } + Err(err) => { + ok = false; + self.status_line = i18n::fmt1(t.list_volumes_failed, "err", err.to_string()).into(); + } + } + if ok { + self.status_line = i18n::fmt4( + t.refreshed_summary, + "containers", + self.containers.len().to_string(), + "images", + self.images.len().to_string(), + "volumes", + self.volumes.len().to_string(), + "mode", + t.mode_live, + ) + .into(); + } + cx.notify(); + } + + /// Run an SDK call off the UI thread and show a busy overlay until it finishes. + pub(crate) fn with_busy( + &mut self, + cx: &mut Context, + op: impl FnOnce(&WslcClient) -> anyhow::Result + Send + 'static, + ) { + if self.sdk_locked() || self.busy || self.pull_phase == PullPhase::Pulling { + return; + } + let Some(client) = self.client.clone() else { + return; + }; + + let (tx, rx) = mpsc::channel(); + std::thread::Builder::new() + .name("wslc-ui-op".into()) + .spawn(move || { + let result = op(&client); + let _ = tx.send(result); + }) + .expect("spawn UI op thread"); + + self.busy = true; + self.busy_rx = Some(rx); + self.status_line = self.t().working.into(); + self.busy_poll_generation = self.busy_poll_generation.wrapping_add(1); + let generation = self.busy_poll_generation; + cx.notify(); + + cx.spawn(async move |this, cx| { + loop { + Timer::after(Duration::from_millis(50)).await; + let cont = this + .update(cx, |this, cx| { + if this.busy_poll_generation != generation { + return false; + } + this.poll_busy(cx) + }) + .unwrap_or(false); + if !cont { + break; + } + } + }) + .detach(); + } + + /// Returns `true` while the background op is still running. + fn poll_busy(&mut self, cx: &mut Context) -> bool { + let Some(rx) = self.busy_rx.take() else { + return false; + }; + let (events, disconnected) = recv_all(&rx); + if let Some(result) = events.into_iter().last() { + self.busy = false; + self.busy_rx = None; + match result { + Ok(msg) if msg.starts_with("__LOGS__\0") => { + let rest = &msg["__LOGS__\0".len()..]; + if let Some((title, text)) = rest.split_once('\0') { + self.logs_title = title.to_string().into(); + self.logs_text = text.to_string().into(); + self.logs_modal_open = true; + self.status_line = self.t().ready.into(); + } else { + self.set_status(msg, cx); + } + cx.notify(); + } + Ok(msg) => { + if self.create_modal_open { + self.create_modal_open = false; + } + self.set_status(msg, cx); + self.refresh(cx); + } + Err(err) => { + let msg = i18n::fmt1(self.t().error_prefix, "err", err.to_string()); + self.set_status(msg, cx); + } + } + cx.notify(); + return false; + } + if disconnected { + self.busy = false; + self.busy_rx = None; + self.set_status(self.t().op_channel_disconnected, cx); + cx.notify(); + return false; + } + self.busy_rx = Some(rx); + true + } + + pub(crate) fn render_busy_overlay(&self, cx: &Context) -> impl IntoElement { + let t = self.t(); + modal_scrim( + "busy-overlay", + cx, + h_flex() + .items_center() + .justify_center() + .px_6() + .py_4() + .rounded(cx.theme().radius_lg) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().background) + .shadow_md() + .child( + v_flex() + .gap_1() + .items_center() + .child(Label::new(t.working).text_sm()), + ), + ) + } +} diff --git a/src/app/sdk_setup.rs b/src/app/sdk_setup.rs new file mode 100644 index 0000000..942b5d4 --- /dev/null +++ b/src/app/sdk_setup.rs @@ -0,0 +1,250 @@ +use std::time::Duration; + +use gpui_kit::component::{ + ActiveTheme, IconName, StyledExt as _, + button::{Button, ButtonVariants as _}, + h_flex, + progress::Progress, + v_flex, +}; +use gpui_kit::prelude::{FluentBuilder as _, InteractiveElement as _}; +use gpui_kit::*; +use smol::Timer; + +use crate::i18n::{self}; +use crate::wslc::{ + EngineStatus, SdkSetupEvent, WslcClient, +}; + +use super::state::{SdkSetupPhase, MAX_SDK_LOG_LINES}; +use super::AppView; +use super::poll::recv_all; +use super::widgets::{modal_panel, modal_scrim}; + +impl AppView { + pub(crate) fn append_sdk_log(&mut self, line: impl Into) { + self.sdk_setup_logs.push(line.into()); + if self.sdk_setup_logs.len() > MAX_SDK_LOG_LINES { + let drop = self.sdk_setup_logs.len() - MAX_SDK_LOG_LINES; + self.sdk_setup_logs.drain(0..drop); + } + } + + pub(crate) fn begin_sdk_setup(&mut self, force_redownload: bool, cx: &mut Context) { + if self.sdk_setup_phase == Some(SdkSetupPhase::Running) { + return; + } + + let (tx, rx) = std::sync::mpsc::channel(); + WslcClient::start_setup(force_redownload, tx); + self.client = None; + self.sdk_setup_rx = Some(rx); + self.sdk_setup_phase = Some(SdkSetupPhase::Running); + self.sdk_setup_progress = 0.0; + self.sdk_setup_status = self.t().sdk_setup_installing.into(); + self.sdk_setup_logs.clear(); + self.append_sdk_log(if force_redownload { + "Retry: reinstalling WSL Containers SDK…" + } else { + "Starting WSL Containers SDK setup…" + }); + self.status = EngineStatus::unavailable(self.t().sdk_locked); + self.status_line = self.t().sdk_locked.into(); + self.sdk_setup_poll_generation = self.sdk_setup_poll_generation.wrapping_add(1); + let generation = self.sdk_setup_poll_generation; + cx.notify(); + + cx.spawn(async move |this, cx| { + loop { + Timer::after(Duration::from_millis(80)).await; + let cont = this + .update(cx, |this, cx| { + if this.sdk_setup_poll_generation != generation { + return false; + } + this.poll_sdk_setup(cx) + }) + .unwrap_or(false); + if !cont { + break; + } + } + }) + .detach(); + } + + /// Returns `true` while setup is still running. + pub(crate) fn poll_sdk_setup(&mut self, cx: &mut Context) -> bool { + let Some(rx) = self.sdk_setup_rx.take() else { + return false; + }; + + let (events, disconnected) = recv_all(&rx); + + let mut finished = disconnected; + for event in events { + match event { + SdkSetupEvent::Status(status) => { + self.sdk_setup_status = status.into(); + } + SdkSetupEvent::Progress(value) => { + self.sdk_setup_progress = value.clamp(0.0, 100.0); + } + SdkSetupEvent::Log(line) => { + self.append_sdk_log(line); + } + SdkSetupEvent::Finished(result) => { + finished = true; + match result { + Ok(client) => { + self.client = Some(client); + self.sdk_setup_phase = None; + self.sdk_setup_progress = 100.0; + self.sdk_setup_status = self.t().ready.into(); + self.append_sdk_log("✓ SDK connected"); + self.refresh(cx); + } + Err(err) => { + self.client = None; + self.sdk_setup_phase = Some(SdkSetupPhase::Failed); + self.status = EngineStatus::unavailable(err.clone()); + self.status.message = self.localize_engine_message(&self.status); + let msg = i18n::fmt1(self.t().engine_unusable, "err", &err); + self.sdk_setup_status = self.t().sdk_setup_failed.into(); + self.append_sdk_log(format!("✗ {err}")); + self.status_line = msg.into(); + } + } + } + } + } + + if disconnected && self.sdk_setup_phase == Some(SdkSetupPhase::Running) { + self.sdk_setup_phase = Some(SdkSetupPhase::Failed); + self.sdk_setup_status = self.t().sdk_setup_channel_disconnected.into(); + self.append_sdk_log(self.t().sdk_setup_channel_disconnected); + self.status = EngineStatus::unavailable(self.t().sdk_setup_channel_disconnected); + finished = true; + } + + if finished { + self.sdk_setup_rx = None; + } else { + self.sdk_setup_rx = Some(rx); + } + cx.notify(); + self.sdk_setup_phase == Some(SdkSetupPhase::Running) + } + pub(crate) fn render_sdk_setup_modal(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); + let phase = self.sdk_setup_phase.unwrap_or(SdkSetupPhase::Running); + let failed = phase == SdkSetupPhase::Failed; + let badge_color = if failed { + cx.theme().red + } else { + cx.theme().blue + }; + let logs = self + .sdk_setup_logs + .iter() + .cloned() + .collect::>(); + + modal_scrim( + "sdk-setup-modal", + cx, + modal_panel(cx) + .w(px(560.)) + .max_h(px(520.)) + .child( + h_flex() + .items_center() + .justify_between() + .child(div().font_semibold().text_base().child(t.sdk_setup_title)) + .child( + div() + .px_2() + .py_0p5() + .rounded(cx.theme().radius) + .bg(badge_color.opacity(0.18)) + .text_color(badge_color) + .text_xs() + .font_semibold() + .child(phase.label(t)), + ), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t.sdk_setup_intro), + ) + .child( + h_flex() + .items_center() + .justify_between() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .truncate() + .child(self.sdk_setup_status.clone()), + ) + .child( + div() + .text_xs() + .font_semibold() + .child(format!("{:.0}%", self.sdk_setup_progress)), + ), + ) + .child( + Progress::new("sdk-setup-progress") + .w_full() + .h_2() + .value(self.sdk_setup_progress), + ) + .child( + div() + .id("sdk-setup-logs") + .h(px(220.)) + .w_full() + .overflow_y_scroll() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().background.opacity(0.65)) + .p_2() + .child( + v_flex().gap_0p5().w_full().map(|list| { + if logs.is_empty() { + list.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t.sdk_setup_logs_placeholder), + ) + } else { + list.children(logs.into_iter().map(|line| { + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(line) + })) + } + }), + ), + ) + .when(failed, |el| { + el.child( + Button::new("sdk-setup-retry") + .primary() + .label(t.sdk_setup_retry) + .icon(IconName::RefreshCw) + .on_click(cx.listener(|this, _, _, cx| { + this.begin_sdk_setup(true, cx); + })), + ) + }), + ) + } +} diff --git a/src/app/settings.rs b/src/app/settings.rs new file mode 100644 index 0000000..965a258 --- /dev/null +++ b/src/app/settings.rs @@ -0,0 +1,183 @@ + +use gpui_kit::component::{ + ActiveTheme, Sizable as _, StyledExt as _, ThemeMode, + button::{Button, ButtonVariants as _}, + h_flex, + v_flex, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::*; + +use crate::i18n::Locale; +use crate::wslc::{SessionPrefs, WslcClient}; + +use super::widgets::settings_card; +use super::AppView; + +const CPU_CHOICES: [u32; 5] = [1, 2, 4, 6, 8]; +const MEMORY_CHOICES_MB: [u32; 5] = [1024, 2048, 4096, 8192, 16384]; + +impl AppView { + pub(crate) fn save_session_prefs(&mut self, cx: &mut Context) { + let prefs = self.session_prefs.clone().clamp(); + self.session_prefs = prefs.clone(); + match WslcClient::save_session_prefs(prefs) { + Ok(()) => self.set_status(self.t().settings_prefs_saved, cx), + Err(err) => { + let msg = crate::i18n::fmt1(self.t().error_prefix, "err", err.to_string()); + self.set_status(msg, cx); + } + } + } + + pub(crate) fn render_settings(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); + let locale = self.locale; + let theme_mode = self.theme_mode; + let cpu = self.session_prefs.cpu_count; + let memory = self.session_prefs.memory_mb; + + div() + .id("settings-panel") + .size_full() + .min_h_0() + .overflow_y_scroll() + .child( + v_flex() + .gap_6() + .max_w(px(560.)) + .pb_6() + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(t.settings_intro), + ) + .child( + settings_card(cx) + .child(div().font_medium().text_sm().child(t.language)) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t.language_hint), + ) + .child( + h_flex() + .gap_2() + .child( + Button::new("lang-zh") + .small() + .label(Locale::Zh.label()) + .when(locale == Locale::Zh, |b| b.primary()) + .on_click(cx.listener(|this, _, window, cx| { + this.apply_locale(Locale::Zh, window, cx); + })), + ) + .child( + Button::new("lang-en") + .small() + .label(Locale::En.label()) + .when(locale == Locale::En, |b| b.primary()) + .on_click(cx.listener(|this, _, window, cx| { + this.apply_locale(Locale::En, window, cx); + })), + ), + ), + ) + .child( + settings_card(cx) + .child(div().font_medium().text_sm().child(t.theme)) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t.theme_hint), + ) + .child( + h_flex() + .gap_2() + .child( + Button::new("theme-dark") + .small() + .label(t.theme_dark) + .when(theme_mode == ThemeMode::Dark, |b| b.primary()) + .on_click(cx.listener(|this, _, window, cx| { + this.apply_theme(ThemeMode::Dark, window, cx); + })), + ) + .child( + Button::new("theme-light") + .small() + .label(t.theme_light) + .when(theme_mode == ThemeMode::Light, |b| b.primary()) + .on_click(cx.listener(|this, _, window, cx| { + this.apply_theme(ThemeMode::Light, window, cx); + })), + ), + ), + ) + .child( + settings_card(cx) + .child(div().font_medium().text_sm().child(t.settings_session)) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t.settings_session_hint), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t.settings_cpu), + ) + .child( + h_flex().gap_2().children(CPU_CHOICES.into_iter().map(|n| { + Button::new(("settings-cpu", n as usize)) + .small() + .label(n.to_string()) + .when(cpu == n, |b| b.primary()) + .on_click(cx.listener(move |this, _, _, cx| { + this.session_prefs.cpu_count = n; + this.save_session_prefs(cx); + })) + .into_any_element() + })), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t.settings_memory), + ) + .child( + h_flex().gap_2().children(MEMORY_CHOICES_MB.into_iter().map( + |mb| { + Button::new(("settings-mem", mb as usize)) + .small() + .label(format!("{} MiB", mb)) + .when(memory == mb, |b| b.primary()) + .on_click(cx.listener(move |this, _, _, cx| { + this.session_prefs.memory_mb = mb; + this.save_session_prefs(cx); + })) + .into_any_element() + }, + )), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!( + "{} · {} CPU · {} MiB", + SessionPrefs::path().display(), + self.session_prefs.cpu_count, + self.session_prefs.memory_mb + )), + ), + ), + ) + } +} diff --git a/src/app/shell.rs b/src/app/shell.rs new file mode 100644 index 0000000..39e8c17 --- /dev/null +++ b/src/app/shell.rs @@ -0,0 +1,231 @@ + +use gpui_kit::component::{ + ActiveTheme, Disableable as _, Icon, IconName, Selectable as _, StyledExt as _, TitleBar, + button::{Button, ButtonVariants as _}, + h_flex, + sidebar::{Sidebar, SidebarGroup, SidebarHeader, SidebarMenu, SidebarMenuItem}, + v_flex, +}; +use gpui_kit::prelude::{FluentBuilder as _, InteractiveElement as _}; +use gpui_kit::*; + +use crate::i18n::{self}; +use crate::wslc::BackendKind; + +use super::state::Section; +use super::AppView; + +impl AppView { + pub(crate) fn render_engine_badge(&self, cx: &Context) -> impl IntoElement { + let (label, color) = match self.status.backend { + BackendKind::Live => ("LIVE", cx.theme().green), + BackendKind::Unavailable => ("OFF", cx.theme().red), + }; + + h_flex() + .items_center() + .gap_2() + .child( + div() + .px_2() + .py_0p5() + .rounded(cx.theme().radius) + .bg(color.opacity(0.18)) + .text_color(color) + .text_xs() + .font_semibold() + .child(label), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.status.version.clone()), + ) + } + pub(crate) fn render_sidebar(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); + Sidebar::new("nav") + .w(px(220.)) + .header( + SidebarHeader::new().child( + h_flex() + .items_center() + .gap_3() + .child( + div() + .flex() + .items_center() + .justify_center() + .size_8() + .rounded(cx.theme().radius) + .bg(cx.theme().sidebar_primary) + .text_color(cx.theme().sidebar_primary_foreground) + .child(Icon::new(IconName::SquareTerminal)), + ) + .child( + v_flex() + .gap_0p5() + .child( + div() + .font_semibold() + .text_sm() + .child("WSLc Desktop"), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t.app_subtitle), + ), + ), + ), + ) + .child( + SidebarGroup::new(t.browse).child( + SidebarMenu::new().children([ + SidebarMenuItem::new(t.overview) + .icon(IconName::LayoutDashboard) + .active(self.section == Section::Overview) + .on_click(cx.listener(|this, _, _, cx| { + this.section = Section::Overview; + cx.notify(); + })), + SidebarMenuItem::new(t.containers) + .icon(IconName::HardDrive) + .active(self.section == Section::Containers) + .on_click(cx.listener(|this, _, _, cx| { + this.section = Section::Containers; + cx.notify(); + })), + SidebarMenuItem::new(t.images) + .icon(IconName::Folder) + .active(self.section == Section::Images) + .on_click(cx.listener(|this, _, _, cx| { + this.section = Section::Images; + cx.notify(); + })), + SidebarMenuItem::new(t.volumes) + .icon(IconName::Inbox) + .active(self.section == Section::Volumes) + .on_click(cx.listener(|this, _, _, cx| { + this.section = Section::Volumes; + cx.notify(); + })), + ]), + ), + ) + } + + pub(crate) fn render_title_bar(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); + // TitleBar content is WindowControlArea::Drag (HTCAPTION on Windows), + // which swallows clicks. Keep TitleBar empty for drag + window controls, + // and overlay the settings button as a sibling that `.occlude()`s the + // drag hitbox underneath. + div() + .relative() + .w_full() + .child(TitleBar::new()) + .child( + h_flex() + .id("title-bar-settings") + .absolute() + .top_0() + .bottom_0() + // Leave room for Windows min/max/close (~3 × 35px). + .right(px(120.)) + .items_center() + .occlude() + .child( + Button::new("titlebar-settings") + .ghost() + .icon(IconName::Settings) + .tooltip(t.settings) + .selected(self.section == Section::Settings) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + this.section = Section::Settings; + cx.notify(); + }), + ) + .on_click(cx.listener(|this, _, _, cx| { + this.section = Section::Settings; + cx.notify(); + })), + ), + ) + } + + pub(crate) fn render_toolbar(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); + h_flex() + .items_center() + .justify_between() + .gap_3() + .child( + v_flex() + .gap_1() + .child( + div() + .text_lg() + .font_semibold() + .child(self.section.label(t)), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.status.message.clone()), + ), + ) + .child( + h_flex() + .items_center() + .gap_2() + .child(self.render_engine_badge(cx)) + .when(self.section != Section::Settings, |this| { + this.child( + Button::new("refresh") + .label(if self.busy { t.working } else { t.refresh }) + .icon(IconName::RefreshCw) + .disabled(self.ops_disabled()) + .on_click(cx.listener(|this, _, _, cx| { + this.set_status(this.t().refreshing, cx); + this.refresh(cx); + })), + ) + }), + ) + } + pub(crate) fn render_status_bar(&self, cx: &Context) -> impl IntoElement { + h_flex() + .h_8() + .px_3() + .items_center() + .justify_between() + .border_t_1() + .border_color(cx.theme().border) + .bg(cx.theme().tab_bar) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .truncate() + .child(self.status_line.clone()), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(i18n::fmt1( + self.t().binary_label, + "name", + &self.status.binary, + )), + ) + } +} diff --git a/src/app/state.rs b/src/app/state.rs new file mode 100644 index 0000000..28b45f2 --- /dev/null +++ b/src/app/state.rs @@ -0,0 +1,105 @@ +use gpui_kit::*; + +use crate::i18n::Strings; +use crate::wslc::ContainerInfo; + +pub(crate) const MAX_PULL_LOG_LINES: usize = 400; +pub(crate) const MAX_SDK_LOG_LINES: usize = 500; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Section { + #[default] + Overview = 0, + Containers = 1, + Images = 2, + Volumes = 3, + Settings = 4, +} + +impl Section { + pub(crate) fn label(self, t: &Strings) -> &'static str { + match self { + Self::Overview => t.overview, + Self::Containers => t.containers, + Self::Images => t.images, + Self::Volumes => t.volumes, + Self::Settings => t.settings, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum PullPhase { + #[default] + Idle, + Pulling, + Success, + Failed, +} + +impl PullPhase { + pub(crate) fn label(self, t: &Strings) -> &'static str { + match self { + Self::Idle => t.pull_idle, + Self::Pulling => t.pull_phase_pulling, + Self::Success => t.pull_ok, + Self::Failed => t.pull_failed, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SdkSetupPhase { + Running, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ConfirmAction { + RemoveContainer { key: String, label: String }, + RemoveImage { reference: String }, + RemoveVolume { name: String }, +} + +impl SdkSetupPhase { + pub(crate) fn label(self, t: &Strings) -> &'static str { + match self { + Self::Running => t.sdk_setup_phase_running, + Self::Failed => t.sdk_setup_phase_failed, + } + } +} + +impl super::AppView { + pub(crate) fn sdk_locked(&self) -> bool { + self.sdk_setup_phase.is_some() || self.client.is_none() + } + + pub(crate) fn ops_disabled(&self) -> bool { + self.sdk_locked() + || self.busy + || self.pull_phase == PullPhase::Pulling + || self.logs_modal_open + || self.exec_modal_open + || self.confirm.is_some() + || self.volume_modal_open + || (self.create_modal_open && self.busy) + } + + pub(crate) fn container_key(item: &ContainerInfo) -> String { + if item.name.is_empty() { + item.id.clone() + } else { + item.name.clone() + } + } + + pub(crate) fn t(&self) -> &'static Strings { + self.locale.strings() + } + + pub(crate) fn set_status(&mut self, msg: impl Into, cx: &mut Context) { + self.status_line = msg.into(); + cx.notify(); + } +} diff --git a/src/app/volumes.rs b/src/app/volumes.rs new file mode 100644 index 0000000..69f819f --- /dev/null +++ b/src/app/volumes.rs @@ -0,0 +1,259 @@ + +use gpui_kit::component::{ + ActiveTheme, Disableable as _, Icon, IconName, Sizable as _, StyledExt as _, + button::{Button, ButtonVariants as _}, + h_flex, + input::Input, + label::Label, + v_flex, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::*; + +use crate::i18n::{self}; +use crate::wslc::VolumeInfo; + +use super::state::ConfirmAction; +use super::widgets::{ + list_header, modal_panel, modal_scrim, resource_list, resource_row, row_actions, +}; +use super::AppView; + +const VOLUME_SIZES_GIB: [u32; 4] = [1, 2, 5, 10]; + +impl AppView { + pub(crate) fn open_volume_modal(&mut self, window: &mut Window, cx: &mut Context) { + if self.sdk_locked() || self.ops_disabled() { + return; + } + self.volume_input.update(cx, |state, cx| { + state.set_value("", window, cx); + }); + self.volume_size_gib = 1; + self.volume_modal_open = true; + cx.notify(); + } + + pub(crate) fn dismiss_volume_modal(&mut self, window: &mut Window, cx: &mut Context) { + if self.busy && self.volume_modal_open { + return; + } + self.volume_modal_open = false; + self.volume_input.update(cx, |state, cx| { + state.set_value("", window, cx); + }); + cx.notify(); + } + + pub(crate) fn submit_create_volume(&mut self, window: &mut Window, cx: &mut Context) { + if self.sdk_locked() || self.busy { + return; + } + let name = self.volume_input.read(cx).value().trim().to_string(); + if name.is_empty() { + self.set_status(self.t().volume_name_empty, cx); + return; + } + let size_bytes = u64::from(self.volume_size_gib) * 1024 * 1024 * 1024; + let template = self.t().created_volume; + self.volume_modal_open = false; + self.volume_input.update(cx, |state, cx| { + state.set_value("", window, cx); + }); + self.with_busy(cx, move |client| { + client.create_volume(&name, size_bytes)?; + Ok(i18n::fmt1(template, "name", &name)) + }); + } + + pub(crate) fn render_volume_row( + &self, + index: usize, + item: &VolumeInfo, + cx: &mut Context, + ) -> impl IntoElement { + let t = self.t(); + let selected = self.selected_volume == Some(index); + let name = item.name.clone(); + let driver = item.driver.clone(); + let mount = if item.mountpoint.is_empty() { + "—".to_string() + } else { + item.mountpoint.clone() + }; + let meta = format!( + "{} · {} · {}", + driver, + if item.size.is_empty() { + "—" + } else { + item.size.as_str() + }, + if item.created.is_empty() { + "—" + } else { + item.created.as_str() + } + ); + let ops_off = self.ops_disabled(); + + resource_row(("volume-row", index), selected, cx) + .on_click(cx.listener(move |this, _, _, cx| { + this.selected_volume = Some(index); + cx.notify(); + })) + .child( + h_flex() + .w_full() + .items_center() + .gap_3() + .child( + Icon::new(IconName::HardDrive).text_color(cx.theme().muted_foreground), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() + .child(div().font_medium().text_sm().child(name)) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .truncate() + .child(format!("{meta} · {mount}")), + ), + ) + .child( + row_actions().child( + Button::new(("volume-remove", index)) + .small() + .danger() + .label(t.remove) + .disabled(ops_off) + .on_click(cx.listener(move |this, _, _, cx| { + this.selected_volume = Some(index); + let Some(vol) = this.volumes.get(index).cloned() else { + return; + }; + this.request_confirm( + ConfirmAction::RemoveVolume { name: vol.name }, + cx, + ); + })), + ), + ), + ) + } + + pub(crate) fn render_volume_modal(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); + let creating = self.busy && self.volume_modal_open; + let size_gib = self.volume_size_gib; + + modal_scrim( + "create-volume-modal", + cx, + modal_panel(cx) + .w(px(480.)) + .child(div().font_semibold().text_base().child(t.create_volume_title)) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t.create_volume_intro), + ) + .child( + v_flex() + .gap_2() + .child(Label::new(t.volume_name_label)) + .child(Input::new(&self.volume_input)), + ) + .child( + v_flex() + .gap_2() + .child(Label::new(t.volume_size_label)) + .child( + h_flex().gap_2().children(VOLUME_SIZES_GIB.into_iter().map(|gib| { + Button::new(("volume-modal-size", gib as usize)) + .small() + .label(format!("{gib} GiB")) + .when(size_gib == gib, |b| b.primary()) + .disabled(creating) + .on_click(cx.listener(move |this, _, _, cx| { + this.volume_size_gib = gib; + cx.notify(); + })) + .into_any_element() + })), + ), + ) + .child( + h_flex() + .gap_2() + .justify_end() + .child( + Button::new("volume-modal-cancel") + .label(t.close) + .disabled(creating) + .on_click(cx.listener(|this, _, window, cx| { + this.dismiss_volume_modal(window, cx); + })), + ) + .child( + Button::new("volume-modal-submit") + .primary() + .label(if creating { t.working } else { t.create }) + .icon(IconName::Plus) + .disabled(self.sdk_locked() || creating) + .on_click(cx.listener(|this, _, window, cx| { + this.submit_create_volume(window, cx); + })), + ), + ), + ) + } + + pub(crate) fn render_volumes(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); + v_flex() + .size_full() + .gap_3() + .child(list_header( + self.muted_count( + i18n::fmt1(t.volumes_count, "count", self.volumes.len().to_string()), + cx, + ), + Button::new("open-volume-modal") + .small() + .primary() + .label(t.create) + .icon(IconName::Plus) + .disabled(self.ops_disabled()) + .on_click(cx.listener(|this, _, window, cx| { + this.open_volume_modal(window, cx); + })), + )) + .child( + resource_list("volume-list").child({ + if self.volumes.is_empty() { + v_flex().gap_1().w_full().child(self.empty_state( + t.no_volumes_title, + t.no_volumes_detail, + cx, + )) + } else { + let rows: Vec<_> = self + .volumes + .iter() + .enumerate() + .map(|(index, item)| { + self.render_volume_row(index, item, cx).into_any_element() + }) + .collect(); + v_flex().gap_1().w_full().children(rows) + } + }), + ) + } +} diff --git a/src/app/widgets.rs b/src/app/widgets.rs new file mode 100644 index 0000000..47d1033 --- /dev/null +++ b/src/app/widgets.rs @@ -0,0 +1,142 @@ +use gpui_kit::component::{ActiveTheme, StyledExt as _, h_flex, v_flex}; +use gpui_kit::*; + +use super::AppView; + +impl AppView { + pub(crate) fn empty_state( + &self, + title: &'static str, + detail: &'static str, + cx: &Context, + ) -> impl IntoElement { + v_flex() + .items_center() + .justify_center() + .gap_2() + .py_16() + .child( + div() + .text_sm() + .font_medium() + .text_color(cx.theme().foreground) + .child(title), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .max_w(px(360.)) + .text_center() + .child(detail), + ) + } + + pub(crate) fn muted_count( + &self, + text: impl Into, + cx: &Context, + ) -> impl IntoElement { + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(text.into()) + } +} + +/// Selectable list row shell (Containers / Images / Volumes). +pub(crate) fn resource_row( + id: impl Into, + selected: bool, + cx: &Context, +) -> Stateful
{ + div() + .id(id) + .w_full() + .px_3() + .py_2() + .rounded(cx.theme().radius) + .border_1() + .border_color(if selected { + cx.theme().border + } else { + cx.theme().transparent + }) + .bg(if selected { + cx.theme().accent.opacity(0.12) + } else { + cx.theme().transparent + }) + .hover(|s| s.bg(cx.theme().accent.opacity(0.08))) + .cursor_pointer() +} + +/// Scrollable resource list body. +pub(crate) fn resource_list(id: impl Into) -> Stateful
{ + div() + .id(id) + .flex_1() + .w_full() + .min_h_0() + .overflow_y_scroll() +} + +/// Per-row action cluster (occludes row click). +pub(crate) fn row_actions() -> Div { + h_flex().gap_1().flex_shrink_0().occlude() +} + +/// Section header: count on the left, toolbar actions on the right. +pub(crate) fn list_header( + count: impl IntoElement, + actions: impl IntoElement, +) -> impl IntoElement { + h_flex() + .items_center() + .justify_between() + .child(count) + .child(actions) +} + +pub(crate) fn modal_scrim( + id: impl Into, + cx: &Context, + child: impl IntoElement, +) -> impl IntoElement { + div() + .id(id) + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .flex() + .items_center() + .justify_center() + .bg(cx.theme().overlay) + .occlude() + .child(child) +} + +pub(crate) fn modal_panel(cx: &Context) -> Div { + v_flex() + .m_6() + .p_5() + .gap_3() + .rounded(cx.theme().radius_lg) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().background) + .shadow_md() +} + +/// Settings / overview card surface. +pub(crate) fn settings_card(cx: &Context) -> Div { + v_flex() + .gap_3() + .p_4() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().secondary.opacity(0.28)) +} diff --git a/src/i18n.rs b/src/i18n.rs index 2c89427..f467bdc 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -41,12 +41,30 @@ impl Locale { pub struct Strings { pub app_subtitle: &'static str, pub browse: &'static str, + pub overview: &'static str, pub containers: &'static str, pub images: &'static str, pub volumes: &'static str, - pub run: &'static str, pub settings: &'static str, + pub overview_intro: &'static str, + pub overview_engine: &'static str, + pub overview_resources: &'static str, + pub overview_session: &'static str, + pub overview_app_version: &'static str, + pub overview_sdk_version: &'static str, + pub overview_backend: &'static str, + pub overview_session_name: &'static str, + pub overview_storage: &'static str, + pub overview_running: &'static str, + pub overview_containers: &'static str, + pub overview_images: &'static str, + pub overview_volumes: &'static str, + pub overview_status_live: &'static str, + pub overview_status_off: &'static str, + pub overview_na: &'static str, + pub overview_persist_hint: &'static str, + pub refresh: &'static str, pub working: &'static str, pub refreshing: &'static str, @@ -56,18 +74,28 @@ pub struct Strings { pub running: &'static str, pub start: &'static str, pub stop: &'static str, + pub restart: &'static str, pub remove: &'static str, pub create: &'static str, pub pull: &'static str, pub pulling: &'static str, - pub run_detached: &'static str, + pub close: &'static str, + pub cancel: &'static str, + pub logs: &'static str, + pub inspect: &'static str, + pub exec: &'static str, + + pub confirm_remove_title: &'static str, + pub confirm_remove_container: &'static str, + pub confirm_remove_image: &'static str, + pub confirm_remove_volume: &'static str, pub containers_count: &'static str, pub images_count: &'static str, pub volumes_count: &'static str, pub refreshed_summary: &'static str, pub mode_live: &'static str, - pub mode_demo: &'static str, + pub mode_unavailable: &'static str, pub no_containers_title: &'static str, pub no_containers_detail: &'static str, @@ -76,6 +104,11 @@ pub struct Strings { pub no_volumes_title: &'static str, pub no_volumes_detail: &'static str, + pub create_volume_title: &'static str, + pub create_volume_intro: &'static str, + pub volume_name_label: &'static str, + pub volume_name_empty: &'static str, + pub pull_image: &'static str, pub pull_hint: &'static str, pub pull_logs_placeholder: &'static str, @@ -86,12 +119,44 @@ pub struct Strings { pub image_ref_empty: &'static str, pub pulled_image: &'static str, - pub run_intro: &'static str, + pub sdk_setup_title: &'static str, + pub sdk_setup_intro: &'static str, + pub sdk_setup_retry: &'static str, + pub sdk_setup_installing: &'static str, + pub sdk_setup_failed: &'static str, + pub sdk_setup_logs_placeholder: &'static str, + pub sdk_setup_phase_running: &'static str, + pub sdk_setup_phase_failed: &'static str, + pub sdk_setup_channel_disconnected: &'static str, + pub sdk_locked: &'static str, + + pub create_container_title: &'static str, + pub create_container_intro: &'static str, pub image_label: &'static str, pub name_optional: &'static str, - pub cli_equivalents: &'static str, pub name_placeholder: &'static str, + pub ports_label: &'static str, + pub ports_placeholder: &'static str, + pub bind_mounts_label: &'static str, + pub bind_mounts_placeholder: &'static str, + pub named_volumes_label: &'static str, + pub named_volumes_placeholder: &'static str, + pub auto_remove_label: &'static str, + pub auto_remove_on: &'static str, + pub auto_remove_off: &'static str, + pub auto_remove_badge: &'static str, + pub command_label: &'static str, + pub command_placeholder: &'static str, pub volume_placeholder: &'static str, + pub volume_size_label: &'static str, + + pub container_logs_title: &'static str, + pub container_inspect_title: &'static str, + pub container_exec_title: &'static str, + pub container_exec_intro: &'static str, + pub container_exec_placeholder: &'static str, + pub container_exec_run: &'static str, + pub logs_empty: &'static str, pub binary_label: &'static str, @@ -107,6 +172,14 @@ pub struct Strings { pub theme_hint: &'static str, pub theme_dark: &'static str, pub theme_light: &'static str, + pub settings_session: &'static str, + pub settings_session_hint: &'static str, + pub settings_cpu: &'static str, + pub settings_memory: &'static str, + pub settings_prefs_saved: &'static str, + + pub overview_cpu: &'static str, + pub overview_memory: &'static str, pub list_containers_failed: &'static str, pub list_images_failed: &'static str, @@ -114,26 +187,57 @@ pub struct Strings { pub error_prefix: &'static str, pub started: &'static str, pub stopped: &'static str, + pub restarted: &'static str, pub removed: &'static str, - pub launched: &'static str, + pub created_container: &'static str, pub created_volume: &'static str, pub pulling_image: &'static str, pub pull_failed_msg: &'static str, + pub parse_ports_failed: &'static str, + pub parse_bind_mounts_failed: &'static str, + pub parse_named_volumes_failed: &'static str, + pub container_logs_failed: &'static str, + pub container_inspect_failed: &'static str, + pub container_exec_failed: &'static str, + pub image_empty: &'static str, + pub op_channel_disconnected: &'static str, pub engine_connected: &'static str, pub engine_unusable: &'static str, pub engine_missing: &'static str, + pub engine_missing_components: &'static str, + pub engine_sdk_outdated: &'static str, } pub static EN: Strings = Strings { app_subtitle: "WSL Containers", browse: "Browse", + overview: "Overview", containers: "Containers", images: "Images", volumes: "Volumes", - run: "Run", settings: "Settings", + overview_intro: "WSLc Desktop status, versions, and resource summary.", + overview_engine: "Engine", + overview_resources: "Resources", + overview_session: "Session", + overview_app_version: "App version", + overview_sdk_version: "SDK version", + overview_backend: "Backend", + overview_session_name: "Session name", + overview_storage: "Storage", + overview_running: "Running", + overview_containers: "Containers", + overview_images: "Images", + overview_volumes: "Volumes", + overview_status_live: "Connected (LIVE)", + overview_status_off: "Unavailable (OFF)", + overview_na: "—", + overview_persist_hint: "Containers and volumes are restored after app restart (session is not terminated on exit).", + overview_cpu: "CPU", + overview_memory: "Memory", + refresh: "Refresh", working: "Working…", refreshing: "Refreshing…", @@ -143,25 +247,40 @@ pub static EN: Strings = Strings { running: "Running", start: "Start", stop: "Stop", + restart: "Restart", remove: "Remove", create: "Create", pull: "Pull", pulling: "Pulling…", - run_detached: "Run detached", + close: "Close", + cancel: "Cancel", + logs: "Logs", + inspect: "Inspect", + exec: "Exec", + + confirm_remove_title: "Confirm remove", + confirm_remove_container: "Remove container “{name}”? This cannot be undone.", + confirm_remove_image: "Remove image “{name}”? This cannot be undone.", + confirm_remove_volume: "Remove volume “{name}”? This cannot be undone.", containers_count: "{total} total · {running} running", images_count: "{count} images", volumes_count: "{count} volumes", refreshed_summary: "Refreshed · {containers} containers · {images} images · {volumes} volumes · {mode}", mode_live: "live", - mode_demo: "demo", + mode_unavailable: "unavailable", no_containers_title: "No containers", - no_containers_detail: "Run an image from the Run tab, or refresh after starting one with wslc.", + no_containers_detail: "Pull an image, create a container, then refresh.", no_images_title: "No images", - no_images_detail: "Type an image name above and pull it, then refresh.", + no_images_detail: "Use Pull to download an image. Create or remove from each row.", no_volumes_title: "No volumes", - no_volumes_detail: "Create a named volume above, or refresh after creating one with wslc volume create.", + no_volumes_detail: "Use Create to add a VHD volume. Remove from each row.", + + create_volume_title: "Create volume", + create_volume_intro: "Creates a dynamic VHD in this desktop session. Pick a size, then create.", + volume_name_label: "Name", + volume_name_empty: "Volume name is empty", pull_image: "Pull image", pull_hint: "Enter an image reference and pull", @@ -173,27 +292,64 @@ pub static EN: Strings = Strings { image_ref_empty: "Image reference is empty", pulled_image: "Pulled image", - run_intro: "Start a detached container through the local wslc CLI. Without WSLc, demo mode only updates the in-memory list.", + sdk_setup_title: "WSL Containers SDK", + sdk_setup_intro: "Install or update the SDK to continue. The app stays locked until a live session starts. If this keeps failing, install or update WSL yourself (`wsl --install` / `wsl --update`), then retry.", + sdk_setup_retry: "Retry install SDK", + sdk_setup_installing: "Installing / connecting…", + sdk_setup_failed: "SDK setup failed", + sdk_setup_logs_placeholder: "Setup logs will appear here.", + sdk_setup_phase_running: "SETUP", + sdk_setup_phase_failed: "FAILED", + sdk_setup_channel_disconnected: "SDK setup channel disconnected", + sdk_locked: "Waiting for WSL Containers SDK…", + + create_container_title: "Create container", + create_container_intro: "Map host ports (e.g. 8080:80) to reach the container. Leave command empty to use the image entrypoint.", image_label: "Image", name_optional: "Name (optional)", - cli_equivalents: "CLI equivalents", name_placeholder: "optional container name", + ports_label: "Ports", + ports_placeholder: "8080:80, 443:443", + bind_mounts_label: "Bind mounts", + bind_mounts_placeholder: "C:\\data:/data or D:\\logs:/var/log:ro", + named_volumes_label: "Named volumes", + named_volumes_placeholder: "data:/data, cache:/var/cache:ro", + auto_remove_label: "Remove on exit", + auto_remove_on: "On", + auto_remove_off: "Off", + auto_remove_badge: "auto-remove", + command_label: "Command (optional)", + command_placeholder: "optional; leave empty for image default", volume_placeholder: "my-volume", + volume_size_label: "Size", - binary_label: "binary: {name}", + container_logs_title: "Container logs", + container_inspect_title: "Container inspect", + container_exec_title: "Exec", + container_exec_intro: "Run a one-shot command (non-interactive). Example: uname -a", + container_exec_placeholder: "uname -a", + container_exec_run: "Run", + logs_empty: "(no log output)", + + binary_label: "sdk: {name}", pull_idle: "IDLE", pull_phase_pulling: "PULLING", pull_ok: "OK", pull_failed: "FAILED", - settings_intro: "Language and appearance. Language defaults from the system locale (Chinese → 中文, otherwise English).", + settings_intro: "Language, appearance, and session resources. Language defaults from the system locale (Chinese → 中文, otherwise English).", language: "Language", language_hint: "UI language for menus, buttons, and status text.", theme: "Theme", theme_hint: "Window color scheme.", theme_dark: "Dark", theme_light: "Light", + settings_session: "Session resources", + settings_session_hint: "CPU and memory for the wslc-desktop session. Saved immediately; applied the next time a new session is created (existing sessions keep their original limits).", + settings_cpu: "CPU count", + settings_memory: "Memory (MiB)", + settings_prefs_saved: "Session preferences saved", list_containers_failed: "List containers failed: {err}", list_images_failed: "List images failed: {err}", @@ -201,26 +357,57 @@ pub static EN: Strings = Strings { error_prefix: "Error: {err}", started: "Started {name}", stopped: "Stopped {name}", + restarted: "Restarted {name}", removed: "Removed {name}", - launched: "Launched {name}", + created_container: "Created {name}", created_volume: "Created volume {name}", pulling_image: "Pulling {name}…", pull_failed_msg: "Pull failed: {err}", - - engine_connected: "Connected via `{bin}`", - engine_unusable: "CLI present but unusable ({err}); using demo data", - engine_missing: "WSLc CLI not found — showing demo data (install WSL ≥ 2.9.3 on Windows)", + parse_ports_failed: "Invalid ports: {err}", + parse_bind_mounts_failed: "Invalid bind mounts: {err}", + parse_named_volumes_failed: "Invalid named volumes: {err}", + container_logs_failed: "Logs failed: {err}", + container_inspect_failed: "Inspect failed: {err}", + container_exec_failed: "Exec failed: {err}", + image_empty: "Image is required", + op_channel_disconnected: "Background operation disconnected", + + engine_connected: "Connected via WSL Containers SDK {bin}", + engine_unusable: "SDK unavailable ({err}). Retry install in the setup dialog, or install/update WSL (`wsl --install` / `wsl --update`).", + engine_missing: "wslcsdk.dll not available. Need network to download the SDK, plus WSL (`wsl --update`). Use Retry in the setup dialog.", + engine_missing_components: "WSL Containers runtime incomplete ({err}). Run `wsl --install` / `wsl --update`, then reboot if prompted, and retry.", + engine_sdk_outdated: "Cached wslcsdk.dll is too old for this WSL. Use Retry to re-download a matching SDK, or run `wsl --update`.", }; pub static ZH: Strings = Strings { app_subtitle: "WSL 容器", browse: "浏览", + overview: "总览", containers: "容器", images: "镜像", volumes: "卷", - run: "运行", settings: "设置", + overview_intro: "WSLc Desktop 状态、版本与资源摘要。", + overview_engine: "引擎", + overview_resources: "资源", + overview_session: "会话", + overview_app_version: "应用版本", + overview_sdk_version: "SDK 版本", + overview_backend: "后端", + overview_session_name: "会话名称", + overview_storage: "存储路径", + overview_running: "运行中", + overview_containers: "容器", + overview_images: "镜像", + overview_volumes: "卷", + overview_status_live: "已连接(LIVE)", + overview_status_off: "不可用(OFF)", + overview_na: "—", + overview_persist_hint: "退出应用不会终止会话;重启后会自动恢复容器与卷。", + overview_cpu: "CPU", + overview_memory: "内存", + refresh: "刷新", working: "处理中…", refreshing: "刷新中…", @@ -230,25 +417,40 @@ pub static ZH: Strings = Strings { running: "运行中", start: "启动", stop: "停止", + restart: "重启", remove: "删除", create: "创建", pull: "拉取", pulling: "拉取中…", - run_detached: "后台运行", + close: "关闭", + cancel: "取消", + logs: "日志", + inspect: "检查", + exec: "执行", + + confirm_remove_title: "确认删除", + confirm_remove_container: "删除容器「{name}」?此操作不可撤销。", + confirm_remove_image: "删除镜像「{name}」?此操作不可撤销。", + confirm_remove_volume: "删除卷「{name}」?此操作不可撤销。", containers_count: "共 {total} · 运行中 {running}", images_count: "{count} 个镜像", volumes_count: "{count} 个卷", refreshed_summary: "已刷新 · {containers} 容器 · {images} 镜像 · {volumes} 卷 · {mode}", mode_live: "实时", - mode_demo: "演示", + mode_unavailable: "不可用", no_containers_title: "暂无容器", - no_containers_detail: "可在「运行」页启动镜像,或用 wslc 创建后再刷新。", + no_containers_detail: "拉取镜像并创建容器后刷新。", no_images_title: "暂无镜像", - no_images_detail: "在上方输入镜像名并拉取,然后刷新。", + no_images_detail: "点击「拉取」下载镜像。在列表行内创建容器或删除。", no_volumes_title: "暂无卷", - no_volumes_detail: "在上方创建命名卷,或用 wslc volume create 创建后再刷新。", + no_volumes_detail: "点击「创建」添加 VHD 卷。在列表行内删除。", + + create_volume_title: "创建卷", + create_volume_intro: "在本桌面会话中创建动态 VHD。选择容量后创建。", + volume_name_label: "名称", + volume_name_empty: "卷名称为空", pull_image: "拉取镜像", pull_hint: "输入镜像引用后拉取", @@ -260,27 +462,64 @@ pub static ZH: Strings = Strings { image_ref_empty: "镜像引用为空", pulled_image: "已拉取镜像", - run_intro: "通过本机 wslc CLI 启动分离式容器。未安装 WSLc 时,演示模式仅更新内存列表。", + sdk_setup_title: "WSL Containers SDK", + sdk_setup_intro: "请安装或更新 SDK 后才能继续。在成功建立实时会话前,应用将保持锁定。若反复失败,请自行安装/更新 WSL(`wsl --install` / `wsl --update`),然后再重试。", + sdk_setup_retry: "重新安装 SDK", + sdk_setup_installing: "正在安装 / 连接…", + sdk_setup_failed: "SDK 安装失败", + sdk_setup_logs_placeholder: "安装日志将显示在这里。", + sdk_setup_phase_running: "安装中", + sdk_setup_phase_failed: "失败", + sdk_setup_channel_disconnected: "SDK 安装通道已断开", + sdk_locked: "等待 WSL Containers SDK…", + + create_container_title: "创建容器", + create_container_intro: "请映射主机端口(如 8080:80)才能从本机访问。命令留空则使用镜像默认入口。", image_label: "镜像", name_optional: "名称(可选)", - cli_equivalents: "等效 CLI 命令", name_placeholder: "可选容器名称", + ports_label: "端口", + ports_placeholder: "8080:80, 443:443", + bind_mounts_label: "绑定挂载", + bind_mounts_placeholder: "C:\\data:/data 或 D:\\logs:/var/log:ro", + named_volumes_label: "命名卷", + named_volumes_placeholder: "data:/data, cache:/var/cache:ro", + auto_remove_label: "退出后删除", + auto_remove_on: "开", + auto_remove_off: "关", + auto_remove_badge: "退出删除", + command_label: "命令(可选)", + command_placeholder: "可选;留空则用镜像默认命令", volume_placeholder: "我的卷", + volume_size_label: "容量", + + container_logs_title: "容器日志", + container_inspect_title: "容器检查", + container_exec_title: "执行命令", + container_exec_intro: "运行一次性命令(非交互)。例如:uname -a", + container_exec_placeholder: "uname -a", + container_exec_run: "运行", + logs_empty: "(无日志输出)", - binary_label: "二进制: {name}", + binary_label: "sdk: {name}", pull_idle: "空闲", pull_phase_pulling: "拉取中", pull_ok: "成功", pull_failed: "失败", - settings_intro: "语言与外观。语言默认跟随系统区域设置(中文 → 中文,否则英文)。", + settings_intro: "语言、外观与会话资源。语言默认跟随系统区域设置(中文 → 中文,否则英文)。", language: "语言", language_hint: "菜单、按钮与状态栏使用的界面语言。", theme: "主题", theme_hint: "窗口配色方案。", theme_dark: "深色", theme_light: "浅色", + settings_session: "会话资源", + settings_session_hint: "wslc-desktop 会话的 CPU 与内存。立即保存;仅在新建会话时生效(已有会话仍沿用原限额)。", + settings_cpu: "CPU 数量", + settings_memory: "内存(MiB)", + settings_prefs_saved: "会话偏好已保存", list_containers_failed: "列出容器失败: {err}", list_images_failed: "列出镜像失败: {err}", @@ -288,15 +527,26 @@ pub static ZH: Strings = Strings { error_prefix: "错误: {err}", started: "已启动 {name}", stopped: "已停止 {name}", + restarted: "已重启 {name}", removed: "已删除 {name}", - launched: "已启动 {name}", + created_container: "已创建 {name}", created_volume: "已创建卷 {name}", pulling_image: "正在拉取 {name}…", pull_failed_msg: "拉取失败: {err}", - - engine_connected: "已通过 `{bin}` 连接", - engine_unusable: "检测到 CLI 但不可用({err});使用演示数据", - engine_missing: "未找到 WSLc CLI — 显示演示数据(请在 Windows 安装 WSL ≥ 2.9.3)", + parse_ports_failed: "端口格式无效: {err}", + parse_bind_mounts_failed: "绑定挂载格式无效: {err}", + parse_named_volumes_failed: "命名卷格式无效: {err}", + container_logs_failed: "获取日志失败: {err}", + container_inspect_failed: "检查失败: {err}", + container_exec_failed: "执行失败: {err}", + image_empty: "请填写镜像", + op_channel_disconnected: "后台操作已断开", + + engine_connected: "已通过 WSL Containers SDK {bin} 连接", + engine_unusable: "SDK 不可用({err})。请在安装弹窗中重试,或自行安装/更新 WSL(`wsl --install` / `wsl --update`)。", + engine_missing: "无法使用 wslcsdk.dll。需联网下载 SDK,并已安装/更新 WSL(`wsl --update`)。请在安装弹窗中重试。", + engine_missing_components: "WSL Containers 运行时不完整({err})。请执行 `wsl --install` / `wsl --update`,必要时重启后再重试。", + engine_sdk_outdated: "缓存的 wslcsdk.dll 过旧。请点击重试以下载匹配 SDK,或先 `wsl --update`。", }; pub fn fmt1(template: &str, key: &str, value: impl AsRef) -> String { diff --git a/src/main.rs b/src/main.rs index bfd807a..dfb5700 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,10 @@ +#![cfg_attr(target_os = "windows", windows_subsystem = "windows")] + mod app; mod i18n; mod wslc; -use gpui_kit::component::TitleBar; +use gpui_kit::component::{Theme, ThemeMode, TitleBar}; use gpui_kit::*; use app::AppView; @@ -14,6 +16,8 @@ fn main() { app.run(move |cx| { gpui_kit::init(cx); + // Override init's default Light theme before the first frame is shown. + Theme::change(ThemeMode::Dark, None, cx); cx.bind_keys([KeyBinding::new("alt-f4", Quit, None)]); diff --git a/src/wslc.rs b/src/wslc.rs deleted file mode 100644 index 5c3657c..0000000 --- a/src/wslc.rs +++ /dev/null @@ -1,1082 +0,0 @@ -//! Thin wrapper around the `wslc` / `container` CLI. -//! -//! When WSLc is missing or unusable on Windows (for example older WSL), the -//! client falls back to an in-memory demo dataset so the UI remains previewable. - -use std::io::{BufRead, BufReader}; -use std::process::{Command, Stdio}; -use std::sync::mpsc::Sender; -use std::sync::{Mutex, OnceLock}; -use std::thread; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use anyhow::{Context as _, Result, anyhow}; -use serde::Deserialize; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BackendKind { - Live, - Demo, -} - -#[derive(Debug, Clone)] -pub struct EngineStatus { - pub backend: BackendKind, - pub binary: String, - pub version: String, - pub message: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ContainerInfo { - pub id: String, - pub name: String, - pub image: String, - pub status: String, - pub ports: String, - pub created: String, -} - -impl ContainerInfo { - pub fn is_running(&self) -> bool { - let s = self.status.to_ascii_lowercase(); - s.contains("up") || s.contains("running") - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ImageInfo { - pub repository: String, - pub tag: String, - pub id: String, - pub size: String, - pub created: String, -} - -impl ImageInfo { - pub fn reference(&self) -> String { - if self.tag.is_empty() || self.tag == "" { - self.repository.clone() - } else { - format!("{}:{}", self.repository, self.tag) - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct VolumeInfo { - pub name: String, - pub driver: String, - pub mountpoint: String, - pub created: String, - pub size: String, -} - -#[derive(Debug, Clone)] -pub enum PullEvent { - Status(String), - Progress(f32), - Log(String), - Finished(Result<(), String>), -} - -#[derive(Debug, Clone, Default)] -struct DemoState { - containers: Vec, - images: Vec, - volumes: Vec, -} - -fn demo_state() -> &'static Mutex { - static STATE: OnceLock> = OnceLock::new(); - STATE.get_or_init(|| { - Mutex::new(DemoState { - containers: vec![ - ContainerInfo { - id: "a1b2c3d4e5f6".into(), - name: "web".into(), - image: "nginx:alpine".into(), - status: "Up 2 hours".into(), - ports: "0.0.0.0:8080->80/tcp".into(), - created: "2 hours ago".into(), - }, - ContainerInfo { - id: "f6e5d4c3b2a1".into(), - name: "redis-dev".into(), - image: "redis:7".into(), - status: "Exited (0) 10 minutes ago".into(), - ports: "".into(), - created: "1 day ago".into(), - }, - ContainerInfo { - id: "112233445566".into(), - name: "hello".into(), - image: "hello-world".into(), - status: "Exited (0) 3 days ago".into(), - ports: "".into(), - created: "3 days ago".into(), - }, - ], - images: vec![ - ImageInfo { - repository: "nginx".into(), - tag: "alpine".into(), - id: "sha256:9beeda1".into(), - size: "42.2 MB".into(), - created: "2 weeks ago".into(), - }, - ImageInfo { - repository: "redis".into(), - tag: "7".into(), - id: "sha256:7c1bb1".into(), - size: "117 MB".into(), - created: "1 month ago".into(), - }, - ImageInfo { - repository: "hello-world".into(), - tag: "latest".into(), - id: "sha256:d2c94e".into(), - size: "13.3 kB".into(), - created: "3 months ago".into(), - }, - ImageInfo { - repository: "ubuntu".into(), - tag: "24.04".into(), - id: "sha256:c6ad44".into(), - size: "78.1 MB".into(), - created: "5 weeks ago".into(), - }, - ], - volumes: vec![ - VolumeInfo { - name: "app-data".into(), - driver: "local".into(), - mountpoint: "/var/lib/wslc/volumes/app-data/_data".into(), - created: "3 days ago".into(), - size: "128 MB".into(), - }, - VolumeInfo { - name: "postgres-data".into(), - driver: "local".into(), - mountpoint: "/var/lib/wslc/volumes/postgres-data/_data".into(), - created: "1 week ago".into(), - size: "512 MB".into(), - }, - VolumeInfo { - name: "cache".into(), - driver: "local".into(), - mountpoint: "/var/lib/wslc/volumes/cache/_data".into(), - created: "2 weeks ago".into(), - size: "64 MB".into(), - }, - ], - }) - }) -} - -fn now_label() -> String { - let secs = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs() % 10_000) - .unwrap_or(0); - format!("demo-{secs}") -} - -#[derive(Clone)] -pub struct WslcClient { - binary: Option, -} - -impl WslcClient { - pub fn detect() -> Self { - let binary = ["wslc", "wslc.exe", "container", "container.exe"] - .into_iter() - .find(|name| Command::new(name).arg("version").output().is_ok_and(|o| o.status.success())) - .map(str::to_string); - - Self { binary } - } - - pub fn status(&self) -> EngineStatus { - match &self.binary { - Some(bin) => match self.run_raw(&[bin.as_str(), "version"]) { - Ok(out) => EngineStatus { - backend: BackendKind::Live, - binary: bin.clone(), - version: first_line(&out).unwrap_or_else(|| "unknown".into()), - message: format!("Connected via `{bin}`"), - }, - Err(err) => EngineStatus { - backend: BackendKind::Demo, - binary: bin.clone(), - version: "unavailable".into(), - message: format!("CLI present but unusable ({err}); using demo data"), - }, - }, - None => EngineStatus { - backend: BackendKind::Demo, - binary: "demo".into(), - version: "demo-0.1".into(), - message: "WSLc CLI not found — showing demo data (install WSL ≥ 2.9.3 on Windows)" - .into(), - }, - } - } - - pub fn is_demo(&self) -> bool { - self.binary.is_none() - } - - pub fn list_containers(&self, all: bool) -> Result> { - if self.is_demo() { - return Ok(demo_state().lock().expect("demo lock").containers.clone()); - } - - let bin = self.binary.as_deref().unwrap(); - let mut args = vec![bin, "container", "list"]; - if all { - args.push("--all"); - } - - if let Ok(json) = self.try_json(&args) { - if let Ok(rows) = parse_containers_json(&json) { - return Ok(rows); - } - } - - let out = self.run_raw(&args)?; - parse_containers_table(&out) - } - - pub fn list_images(&self) -> Result> { - if self.is_demo() { - return Ok(demo_state().lock().expect("demo lock").images.clone()); - } - - let bin = self.binary.as_deref().unwrap(); - let args = [bin, "image", "list"]; - - if let Ok(json) = self.try_json(&args) { - if let Ok(rows) = parse_images_json(&json) { - return Ok(rows); - } - } - - let out = self.run_raw(&args)?; - parse_images_table(&out) - } - - pub fn start_container(&self, name_or_id: &str) -> Result<()> { - if self.is_demo() { - let mut state = demo_state().lock().expect("demo lock"); - let c = state - .containers - .iter_mut() - .find(|c| c.name == name_or_id || c.id == name_or_id) - .ok_or_else(|| anyhow!("container not found: {name_or_id}"))?; - c.status = "Up Just now".into(); - return Ok(()); - } - - let bin = self.binary.as_deref().unwrap(); - self.run_checked(&[bin, "container", "start", name_or_id]) - } - - pub fn stop_container(&self, name_or_id: &str) -> Result<()> { - if self.is_demo() { - let mut state = demo_state().lock().expect("demo lock"); - let c = state - .containers - .iter_mut() - .find(|c| c.name == name_or_id || c.id == name_or_id) - .ok_or_else(|| anyhow!("container not found: {name_or_id}"))?; - c.status = "Exited (0) Just now".into(); - c.ports.clear(); - return Ok(()); - } - - let bin = self.binary.as_deref().unwrap(); - self.run_checked(&[bin, "container", "stop", name_or_id]) - } - - pub fn remove_container(&self, name_or_id: &str) -> Result<()> { - if self.is_demo() { - let mut state = demo_state().lock().expect("demo lock"); - let before = state.containers.len(); - state - .containers - .retain(|c| c.name != name_or_id && c.id != name_or_id); - if state.containers.len() == before { - return Err(anyhow!("container not found: {name_or_id}")); - } - return Ok(()); - } - - let bin = self.binary.as_deref().unwrap(); - // Prefer docker-compatible `rm`, fall back to `container remove`. - if self.run_checked(&[bin, "rm", "-f", name_or_id]).is_ok() { - return Ok(()); - } - self.run_checked(&[bin, "container", "rm", "-f", name_or_id]) - } - - pub fn remove_image(&self, reference: &str) -> Result<()> { - if self.is_demo() { - let mut state = demo_state().lock().expect("demo lock"); - let before = state.images.len(); - state - .images - .retain(|img| img.reference() != reference && img.id != reference); - if state.images.len() == before { - return Err(anyhow!("image not found: {reference}")); - } - return Ok(()); - } - - let bin = self.binary.as_deref().unwrap(); - if self.run_checked(&[bin, "rmi", reference]).is_ok() { - return Ok(()); - } - self.run_checked(&[bin, "image", "rm", reference]) - } - - pub fn run_detached(&self, image: &str, name: Option<&str>) -> Result { - let image = image.trim(); - if image.is_empty() { - return Err(anyhow!("image reference is empty")); - } - - if self.is_demo() { - let mut state = demo_state().lock().expect("demo lock"); - let name = name - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .unwrap_or_else(now_label); - let id = format!("{:x}", name.len() * 97 + image.len() * 13); - state.containers.insert( - 0, - ContainerInfo { - id: id.clone(), - name: name.clone(), - image: image.to_string(), - status: "Up Just now".into(), - ports: "".into(), - created: "Just now".into(), - }, - ); - if !state.images.iter().any(|i| i.reference() == image) { - let (repo, tag) = split_ref(image); - state.images.insert( - 0, - ImageInfo { - repository: repo, - tag, - id: format!("sha256:{id}"), - size: "—".into(), - created: "Just now".into(), - }, - ); - } - return Ok(name); - } - - let bin = self.binary.as_deref().unwrap(); - let mut args = vec![bin.to_string(), "run".into(), "-d".into()]; - if let Some(name) = name.map(str::trim).filter(|s| !s.is_empty()) { - args.push("--name".into()); - args.push(name.to_string()); - } - args.push(image.to_string()); - let owned: Vec<&str> = args.iter().map(String::as_str).collect(); - let out = self.run_raw(&owned)?; - Ok(first_line(&out).unwrap_or_else(|| image.to_string())) - } - - pub fn list_volumes(&self) -> Result> { - if self.is_demo() { - return Ok(demo_state().lock().expect("demo lock").volumes.clone()); - } - - let bin = self.binary.as_deref().unwrap(); - let args = [bin, "volume", "list"]; - if let Ok(json) = self.try_json(&args) { - if let Ok(rows) = parse_volumes_json(&json) { - return Ok(rows); - } - } - let out = self - .run_raw(&args) - .or_else(|_| self.run_raw(&[bin, "volume", "ls"]))?; - parse_volumes_table(&out) - } - - pub fn create_volume(&self, name: &str) -> Result<()> { - let name = name.trim(); - if name.is_empty() { - return Err(anyhow!("volume name is empty")); - } - - if self.is_demo() { - let mut state = demo_state().lock().expect("demo lock"); - if state.volumes.iter().any(|v| v.name == name) { - return Err(anyhow!("volume already exists: {name}")); - } - state.volumes.insert( - 0, - VolumeInfo { - name: name.to_string(), - driver: "local".into(), - mountpoint: format!("/var/lib/wslc/volumes/{name}/_data"), - created: "Just now".into(), - size: "0 B".into(), - }, - ); - return Ok(()); - } - - let bin = self.binary.as_deref().unwrap(); - self.run_checked(&[bin, "volume", "create", name]) - } - - pub fn remove_volume(&self, name: &str) -> Result<()> { - let name = name.trim(); - if name.is_empty() { - return Err(anyhow!("volume name is empty")); - } - - if self.is_demo() { - let mut state = demo_state().lock().expect("demo lock"); - let before = state.volumes.len(); - state.volumes.retain(|v| v.name != name); - if state.volumes.len() == before { - return Err(anyhow!("volume not found: {name}")); - } - return Ok(()); - } - - let bin = self.binary.as_deref().unwrap(); - if self.run_checked(&[bin, "volume", "rm", name]).is_ok() { - return Ok(()); - } - self.run_checked(&[bin, "volume", "remove", name]) - } - - /// Spawn a background pull and stream status / progress / logs on `tx`. - pub fn start_pull(&self, reference: &str, tx: Sender) { - let reference = reference.trim().to_string(); - let binary = self.binary.clone(); - thread::spawn(move || { - if reference.is_empty() { - let _ = tx.send(PullEvent::Finished(Err("image reference is empty".into()))); - return; - } - - let _ = tx.send(PullEvent::Status(format!("Pulling {reference}…"))); - let _ = tx.send(PullEvent::Progress(0.0)); - - if binary.is_none() { - demo_pull(&reference, &tx); - return; - } - - let bin = binary.unwrap(); - // Prefer `pull `, fall back to `image pull `. - if !stream_pull_command(&bin, &["pull", &reference], &tx) { - let _ = stream_pull_command(&bin, &["image", "pull", &reference], &tx); - } - }); - } - - fn try_json(&self, base_args: &[&str]) -> Result { - let mut args: Vec<&str> = base_args.to_vec(); - args.extend_from_slice(&["--format", "json"]); - self.run_raw(&args) - } - - fn run_checked(&self, args: &[&str]) -> Result<()> { - let _ = self.run_raw(args)?; - Ok(()) - } - - fn run_raw(&self, args: &[&str]) -> Result { - let (program, rest) = args - .split_first() - .ok_or_else(|| anyhow!("empty command"))?; - - let output = Command::new(program) - .args(rest) - .output() - .with_context(|| format!("failed to spawn `{program}`"))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - return Err(anyhow!( - "`{}` failed: {}", - args.join(" "), - prefer_nonempty(stderr.trim(), stdout.trim()) - )); - } - - Ok(String::from_utf8_lossy(&output.stdout).into_owned()) - } -} - -fn prefer_nonempty<'a>(primary: &'a str, fallback: &'a str) -> &'a str { - if primary.is_empty() { - fallback - } else { - primary - } -} - -fn first_line(s: &str) -> Option { - s.lines() - .map(str::trim) - .find(|l| !l.is_empty()) - .map(str::to_string) -} - -fn split_ref(reference: &str) -> (String, String) { - if let Some((repo, tag)) = reference.rsplit_once(':') { - if !repo.contains('/') || tag.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') { - // Prefer last ':' as tag separator when it looks like a tag. - if !tag.contains('/') { - return (repo.to_string(), tag.to_string()); - } - } - } - (reference.to_string(), "latest".into()) -} - -#[derive(Debug, Deserialize)] -struct JsonContainer { - #[serde(default, alias = "ID", alias = "Id")] - id: String, - #[serde(default, alias = "Names", alias = "Name")] - name: serde_json::Value, - #[serde(default, alias = "Image")] - image: String, - #[serde(default, alias = "Status", alias = "State")] - status: String, - #[serde(default, alias = "Ports")] - ports: serde_json::Value, - #[serde(default, alias = "CreatedAt", alias = "Created")] - created: serde_json::Value, -} - -fn json_stringish(v: &serde_json::Value) -> String { - match v { - serde_json::Value::Null => String::new(), - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Array(items) => items - .iter() - .filter_map(|i| i.as_str()) - .collect::>() - .join(", "), - other => other.to_string().trim_matches('"').to_string(), - } -} - -fn parse_containers_json(raw: &str) -> Result> { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Ok(Vec::new()); - } - - // NDJSON or a JSON array. - if trimmed.starts_with('[') { - let rows: Vec = serde_json::from_str(trimmed)?; - return Ok(rows.into_iter().map(into_container).collect()); - } - - let mut rows = Vec::new(); - for line in trimmed.lines() { - let line = line.trim(); - if line.is_empty() { - continue; - } - let row: JsonContainer = serde_json::from_str(line)?; - rows.push(into_container(row)); - } - Ok(rows) -} - -fn into_container(row: JsonContainer) -> ContainerInfo { - let name = json_stringish(&row.name) - .trim_start_matches('/') - .to_string(); - ContainerInfo { - id: row.id, - name, - image: row.image, - status: row.status, - ports: json_stringish(&row.ports), - created: json_stringish(&row.created), - } -} - -#[derive(Debug, Deserialize)] -struct JsonImage { - #[serde(default, alias = "Repository", alias = "RepoTags")] - repository: serde_json::Value, - #[serde(default, alias = "Tag")] - tag: String, - #[serde(default, alias = "ID", alias = "Id")] - id: String, - #[serde(default, alias = "Size")] - size: serde_json::Value, - #[serde(default, alias = "CreatedAt", alias = "Created")] - created: serde_json::Value, -} - -fn parse_images_json(raw: &str) -> Result> { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Ok(Vec::new()); - } - - let parse_one = |row: JsonImage| -> ImageInfo { - let repo_raw = json_stringish(&row.repository); - let (repository, tag) = if repo_raw.contains(':') && row.tag.is_empty() { - split_ref(&repo_raw) - } else if repo_raw.is_empty() { - ("".into(), row.tag) - } else { - (repo_raw, if row.tag.is_empty() { "latest".into() } else { row.tag }) - }; - ImageInfo { - repository, - tag, - id: row.id, - size: json_stringish(&row.size), - created: json_stringish(&row.created), - } - }; - - if trimmed.starts_with('[') { - let rows: Vec = serde_json::from_str(trimmed)?; - return Ok(rows.into_iter().map(parse_one).collect()); - } - - let mut rows = Vec::new(); - for line in trimmed.lines() { - let line = line.trim(); - if line.is_empty() { - continue; - } - rows.push(parse_one(serde_json::from_str(line)?)); - } - Ok(rows) -} - -fn split_columns(line: &str) -> Vec { - // Prefer two-or-more spaces as column separators (docker / wslc table style). - let mut cols = Vec::new(); - let mut current = String::new(); - let mut space_run = 0usize; - for ch in line.chars() { - if ch == ' ' { - space_run += 1; - continue; - } - if space_run >= 2 { - if !current.is_empty() { - cols.push(std::mem::take(&mut current)); - } - } else if space_run == 1 && !current.is_empty() { - current.push(' '); - } - space_run = 0; - current.push(ch); - } - if !current.is_empty() { - cols.push(current); - } - cols -} - -fn header_index(headers: &[String], names: &[&str]) -> Option { - headers.iter().position(|h| { - let h = h.to_ascii_lowercase(); - names.iter().any(|n| h.contains(n)) - }) -} - -fn parse_containers_table(raw: &str) -> Result> { - let mut lines = raw.lines().filter(|l| !l.trim().is_empty()); - let header = lines - .next() - .ok_or_else(|| anyhow!("empty container list output"))?; - let headers = split_columns(header); - - let id_i = header_index(&headers, &["container id", "id"]).unwrap_or(0); - let image_i = header_index(&headers, &["image"]).unwrap_or(1); - let status_i = header_index(&headers, &["status"]).unwrap_or(4); - let ports_i = header_index(&headers, &["ports"]); - let names_i = header_index(&headers, &["names", "name"]); - let created_i = header_index(&headers, &["created"]); - - let mut rows = Vec::new(); - for line in lines { - let cols = split_columns(line); - if cols.is_empty() { - continue; - } - let get = |i: Option| i.and_then(|i| cols.get(i).cloned()).unwrap_or_default(); - let get_req = |i: usize| cols.get(i).cloned().unwrap_or_default(); - - // When COMMAND/CREATED collapse under single spaces, prefer the last - // column for Names (docker / wslc table convention). - let name = match names_i { - Some(i) if i < cols.len() => cols[i].clone(), - _ => cols.last().cloned().unwrap_or_default(), - }; - - rows.push(ContainerInfo { - id: get_req(id_i), - name: name.trim_start_matches('/').to_string(), - image: get_req(image_i), - status: get_req(status_i.min(cols.len().saturating_sub(1))), - ports: get(ports_i.filter(|i| *i < cols.len())), - created: get(created_i.filter(|i| *i < cols.len())), - }); - } - Ok(rows) -} - -fn parse_images_table(raw: &str) -> Result> { - let mut lines = raw.lines().filter(|l| !l.trim().is_empty()); - let header = lines - .next() - .ok_or_else(|| anyhow!("empty image list output"))?; - let headers = split_columns(header); - - let repo_i = header_index(&headers, &["repository", "repo"]).unwrap_or(0); - let tag_i = header_index(&headers, &["tag"]).unwrap_or(1); - let id_i = header_index(&headers, &["image id", "id"]).unwrap_or(2); - let created_i = header_index(&headers, &["created"]); - let size_i = header_index(&headers, &["size"]); - - let mut rows = Vec::new(); - for line in lines { - let cols = split_columns(line); - if cols.is_empty() { - continue; - } - let get = |i: Option| i.and_then(|i| cols.get(i).cloned()).unwrap_or_default(); - let get_req = |i: usize| cols.get(i).cloned().unwrap_or_default(); - - rows.push(ImageInfo { - repository: get_req(repo_i), - tag: get_req(tag_i), - id: get_req(id_i), - size: get(size_i), - created: get(created_i), - }); - } - Ok(rows) -} - -#[derive(Debug, Deserialize)] -struct JsonVolume { - #[serde(default, alias = "Name", alias = "NAME")] - name: String, - #[serde(default, alias = "Driver")] - driver: String, - #[serde(default, alias = "Mountpoint", alias = "MountPoint")] - mountpoint: String, - #[serde(default, alias = "CreatedAt", alias = "Created")] - created: serde_json::Value, - #[serde(default, alias = "Size")] - size: serde_json::Value, -} - -fn parse_volumes_json(raw: &str) -> Result> { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Ok(Vec::new()); - } - - let parse_one = |row: JsonVolume| VolumeInfo { - name: row.name, - driver: if row.driver.is_empty() { - "local".into() - } else { - row.driver - }, - mountpoint: row.mountpoint, - created: json_stringish(&row.created), - size: json_stringish(&row.size), - }; - - if trimmed.starts_with('[') { - let rows: Vec = serde_json::from_str(trimmed)?; - return Ok(rows.into_iter().map(parse_one).collect()); - } - - let mut rows = Vec::new(); - for line in trimmed.lines() { - let line = line.trim(); - if line.is_empty() { - continue; - } - rows.push(parse_one(serde_json::from_str(line)?)); - } - Ok(rows) -} - -fn parse_volumes_table(raw: &str) -> Result> { - let mut lines = raw.lines().filter(|l| !l.trim().is_empty()); - let header = lines - .next() - .ok_or_else(|| anyhow!("empty volume list output"))?; - let headers = split_columns(header); - - let name_i = header_index(&headers, &["volume name", "name"]).unwrap_or(0); - let driver_i = header_index(&headers, &["driver"]); - let mount_i = header_index(&headers, &["mountpoint", "mount"]); - let created_i = header_index(&headers, &["created"]); - let size_i = header_index(&headers, &["size"]); - - let mut rows = Vec::new(); - for line in lines { - let cols = split_columns(line); - if cols.is_empty() { - continue; - } - let get = |i: Option| i.and_then(|i| cols.get(i).cloned()).unwrap_or_default(); - let get_req = |i: usize| cols.get(i).cloned().unwrap_or_default(); - rows.push(VolumeInfo { - name: get_req(name_i), - driver: { - let d = get(driver_i); - if d.is_empty() { "local".into() } else { d } - }, - mountpoint: get(mount_i), - created: get(created_i), - size: get(size_i), - }); - } - Ok(rows) -} - -fn demo_pull(reference: &str, tx: &Sender) { - let (repo, tag) = split_ref(reference); - let layers = [ - ("Resolving manifest", 8.0), - ("Pulling fs layer sha256:aaa111", 22.0), - ("Downloading [=========> ] 4.2MB/9.1MB", 45.0), - ("Downloading [=================> ] 8.4MB/9.1MB", 68.0), - ("Download complete", 78.0), - ("Extracting layers", 90.0), - ("Pull complete", 97.0), - ]; - - let _ = tx.send(PullEvent::Log(format!("{repo}:{tag}: Pulling from registry (demo)"))); - for (line, progress) in layers { - thread::sleep(Duration::from_millis(220)); - let _ = tx.send(PullEvent::Log(line.into())); - let _ = tx.send(PullEvent::Progress(progress)); - let _ = tx.send(PullEvent::Status(line.into())); - } - - { - let mut state = demo_state().lock().expect("demo lock"); - if !state.images.iter().any(|i| i.reference() == format!("{repo}:{tag}") || (i.repository == repo && i.tag == tag)) - { - state.images.insert( - 0, - ImageInfo { - repository: repo.clone(), - tag: tag.clone(), - id: format!("sha256:{:x}", repo.len() * 31 + tag.len() * 17), - size: "—".into(), - created: "Just now".into(), - }, - ); - } - } - - let _ = tx.send(PullEvent::Log(format!( - "Status: Downloaded newer image for {repo}:{tag}" - ))); - let _ = tx.send(PullEvent::Progress(100.0)); - let _ = tx.send(PullEvent::Status("Pull complete".into())); - let _ = tx.send(PullEvent::Finished(Ok(()))); -} - -/// Returns `true` if the process was spawned (success or failure of pull itself). -fn stream_pull_command(bin: &str, args: &[&str], tx: &Sender) -> bool { - let mut child = match Command::new(bin) - .args(args) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - { - Ok(child) => child, - Err(err) => { - let _ = tx.send(PullEvent::Log(format!("failed to spawn `{bin}`: {err}"))); - return false; - } - }; - - let stdout = child.stdout.take(); - let stderr = child.stderr.take(); - let tx_out = tx.clone(); - let out_handle = thread::spawn(move || { - if let Some(out) = stdout { - for line in BufReader::new(out).lines().map_while(Result::ok) { - emit_pull_line(&tx_out, &line); - } - } - }); - let tx_err = tx.clone(); - let err_handle = thread::spawn(move || { - if let Some(err) = stderr { - for line in BufReader::new(err).lines().map_while(Result::ok) { - emit_pull_line(&tx_err, &line); - } - } - }); - - let status = child.wait(); - let _ = out_handle.join(); - let _ = err_handle.join(); - - match status { - Ok(code) if code.success() => { - let _ = tx.send(PullEvent::Progress(100.0)); - let _ = tx.send(PullEvent::Status("Pull complete".into())); - let _ = tx.send(PullEvent::Finished(Ok(()))); - } - Ok(code) => { - let _ = tx.send(PullEvent::Finished(Err(format!( - "pull exited with {code}" - )))); - } - Err(err) => { - let _ = tx.send(PullEvent::Finished(Err(format!("wait failed: {err}")))); - } - } - true -} - -fn emit_pull_line(tx: &Sender, line: &str) { - let line = line.trim_end(); - if line.is_empty() { - return; - } - let _ = tx.send(PullEvent::Log(line.to_string())); - if let Some(progress) = parse_pull_progress(line) { - let _ = tx.send(PullEvent::Progress(progress)); - } - if line.starts_with("Status:") - || line.contains("Pulling from") - || line.contains("Download complete") - || line.contains("Pull complete") - || line.contains("Extracting") - || line.contains("Downloading") - { - let _ = tx.send(PullEvent::Status(line.to_string())); - } -} - -fn parse_pull_progress(line: &str) -> Option { - if let Some(idx) = line.find('%') { - let start = line[..idx] - .rfind(|c: char| !c.is_ascii_digit() && c != '.') - .map(|i| i + 1) - .unwrap_or(0); - if let Ok(v) = line[start..idx].parse::() { - return Some(v.clamp(0.0, 100.0)); - } - } - - // Docker-style bar: [=========> ] - if let Some(open) = line.find('[') - && let Some(close) = line[open..].find(']') - { - let bar = &line[open + 1..open + close]; - let filled = bar.chars().filter(|&c| c == '=' || c == '>').count(); - let total = bar.chars().count().max(1); - return Some((filled as f32 / total as f32 * 100.0).clamp(0.0, 100.0)); - } - - let lower = line.to_ascii_lowercase(); - if lower.contains("download complete") || lower.contains("pull complete") { - return Some(100.0); - } - if lower.contains("extracting") { - return Some(85.0); - } - None -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_docker_style_container_table() { - let raw = "\ -CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES -a1b2c3d4e5f6 nginx:alpine \"/docker-entrypoint…\" 2 hours ago Up 2 hours 80/tcp web -f6e5d4c3b2a1 redis:7 \"redis-server\" 1 day ago Exited (0) 1m redis-dev -"; - let rows = parse_containers_table(raw).unwrap(); - assert_eq!(rows.len(), 2); - assert_eq!(rows[0].name, "web"); - assert_eq!(rows[0].image, "nginx:alpine"); - assert!(rows[0].is_running()); - assert_eq!(rows[1].name, "redis-dev"); - assert!(!rows[1].is_running()); - } - - #[test] - fn demo_roundtrip_start_stop() { - let client = WslcClient { binary: None }; - let before = client.list_containers(true).unwrap(); - assert!(!before.is_empty()); - let name = before[1].name.clone(); - client.start_container(&name).unwrap(); - let running = client - .list_containers(true) - .unwrap() - .into_iter() - .find(|c| c.name == name) - .unwrap(); - assert!(running.is_running()); - client.stop_container(&name).unwrap(); - } - - #[test] - fn parses_pull_progress_from_bar_and_percent() { - assert!((parse_pull_progress("Downloading [=====> ] 1MB").unwrap() - 60.0).abs() < 1.0); - assert_eq!(parse_pull_progress("Extracting 42.5%").unwrap(), 42.5); - assert_eq!(parse_pull_progress("Download complete").unwrap(), 100.0); - } - - #[test] - fn demo_volume_create_remove() { - let client = WslcClient { binary: None }; - let name = format!("vol-{}", now_label()); - client.create_volume(&name).unwrap(); - assert!( - client - .list_volumes() - .unwrap() - .iter() - .any(|v| v.name == name) - ); - client.remove_volume(&name).unwrap(); - assert!( - !client - .list_volumes() - .unwrap() - .iter() - .any(|v| v.name == name) - ); - } -} diff --git a/src/wslc/catalog.rs b/src/wslc/catalog.rs new file mode 100644 index 0000000..1046873 --- /dev/null +++ b/src/wslc/catalog.rs @@ -0,0 +1,114 @@ +//! Persist desktop-tracked containers / volumes across app restarts. +//! +//! Stored outside the WSLC session root (which must stay empty for CreateSession). +//! Path: `%LOCALAPPDATA%\WSLcDesktop\catalog.json` + +use std::fs; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use super::types::VolumeInfo; + +pub const CATALOG_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PersistedContainer { + pub id: String, + pub name: String, + pub image: String, + #[serde(default)] + pub ports: String, + #[serde(default)] + pub volumes: String, + #[serde(default)] + pub auto_remove: bool, + #[serde(default)] + pub created: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Catalog { + pub version: u32, + #[serde(default)] + pub containers: Vec, + #[serde(default)] + pub volumes: Vec, +} + +impl Default for Catalog { + fn default() -> Self { + Self { + version: CATALOG_VERSION, + containers: Vec::new(), + volumes: Vec::new(), + } + } +} + +#[cfg_attr(not(windows), allow(dead_code))] +impl Catalog { + pub fn catalog_path() -> PathBuf { + let base = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\WslcData")); + base.join("WSLcDesktop").join("catalog.json") + } + + pub fn load() -> Self { + let path = Self::catalog_path(); + let Ok(bytes) = fs::read(&path) else { + return Self::default(); + }; + match serde_json::from_slice::(&bytes) { + Ok(mut cat) => { + cat.version = CATALOG_VERSION; + cat + } + Err(_) => Self::default(), + } + } + + pub fn save(&self) -> Result<(), String> { + let path = Self::catalog_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("create catalog dir: {e}"))?; + } + let json = serde_json::to_vec_pretty(self).map_err(|e| format!("serialize catalog: {e}"))?; + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, &json).map_err(|e| format!("write catalog tmp: {e}"))?; + fs::rename(&tmp, &path).map_err(|e| format!("replace catalog: {e}"))?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_catalog_json() { + let cat = Catalog { + version: CATALOG_VERSION, + containers: vec![PersistedContainer { + id: "abc123".into(), + name: "web".into(), + image: "nginx:alpine".into(), + ports: "8080:80".into(), + volumes: String::new(), + auto_remove: false, + created: "Just now".into(), + }], + volumes: vec![VolumeInfo { + name: "data".into(), + driver: "vhd".into(), + mountpoint: "session:data".into(), + created: "Just now".into(), + size: "1.0 GB".into(), + }], + }; + let bytes = serde_json::to_vec(&cat).unwrap(); + let back: Catalog = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(back, cat); + } +} diff --git a/src/wslc/live.rs b/src/wslc/live.rs new file mode 100644 index 0000000..6dbe480 --- /dev/null +++ b/src/wslc/live.rs @@ -0,0 +1,1030 @@ +//! Windows-only WSL Containers SDK worker thread. + +use std::path::PathBuf; +use std::sync::mpsc::{self, Sender}; +use std::thread; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, Result}; + +use super::catalog::{Catalog, PersistedContainer}; +use super::prefs::SessionPrefs; +use super::{ + split_ref, BackendKind, ContainerInfo, CreateContainerRequest, EngineStatus, ImageInfo, + PortMapping, PullEvent, SessionStats, VolumeInfo, VolumeMapping, +}; + +const SESSION_NAME: &str = "wslc-desktop"; +const DEFAULT_VOLUME_BYTES: u64 = 1024 * 1024 * 1024; // 1 GiB +const STOP_TIMEOUT: Duration = Duration::from_secs(10); + +pub enum Cmd { + Status(Sender), + Stats(Sender), + ListContainers { + all: bool, + reply: Sender>>, + }, + ListImages { + reply: Sender>>, + }, + ListVolumes { + reply: Sender>>, + }, + StartContainer { + key: String, + reply: Sender>, + }, + StopContainer { + key: String, + reply: Sender>, + }, + RestartContainer { + key: String, + reply: Sender>, + }, + RemoveContainer { + key: String, + reply: Sender>, + }, + RemoveImage { + reference: String, + reply: Sender>, + }, + CreateContainer { + request: CreateContainerRequest, + reply: Sender>, + }, + CreateVolume { + name: String, + size_bytes: u64, + reply: Sender>, + }, + RemoveVolume { + name: String, + reply: Sender>, + }, + StartPull { + reference: String, + events: Sender, + }, + ContainerLogs { + key: String, + reply: Sender>, + }, + ContainerInspect { + key: String, + reply: Sender>, + }, + ContainerExec { + key: String, + command: String, + reply: Sender>, + }, + Shutdown, +} + +pub fn call( + tx: &Sender, + build: impl FnOnce(Sender>) -> Cmd, +) -> Result { + let (reply_tx, reply_rx) = mpsc::channel(); + tx.send(build(reply_tx)) + .map_err(|_| anyhow!("SDK worker disconnected"))?; + reply_rx + .recv() + .map_err(|_| anyhow!("SDK worker disconnected"))? +} + +/// Start the SDK worker. Returns `(cmd_tx, version)` on success. +pub fn spawn_worker() -> Result<(Sender, String), String> { + let (ready_tx, ready_rx) = mpsc::channel(); + let (cmd_tx, cmd_rx) = mpsc::channel(); + + thread::Builder::new() + .name("wslc-sdk".into()) + .spawn(move || match LiveState::boot() { + Ok(state) => { + let version = state.version.clone(); + let _ = ready_tx.send(Ok(version)); + live_worker(state, cmd_rx); + } + Err(err) => { + let _ = ready_tx.send(Err(err.to_string())); + } + }) + .map_err(|e| format!("failed to spawn SDK worker: {e}"))?; + + match ready_rx.recv() { + Ok(Ok(version)) => Ok((cmd_tx, version)), + Ok(Err(detail)) => Err(detail), + Err(_) => Err("SDK worker failed to start".into()), + } +} + +fn format_bytes(bytes: i64) -> String { + if bytes < 0 { + return "—".into(); + } + let b = bytes as f64; + const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; + let mut value = b; + let mut unit = 0usize; + while value >= 1024.0 && unit + 1 < UNITS.len() { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes} {}", UNITS[unit]) + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + +fn format_unix_time(ts: u64) -> String { + if ts == 0 { + return String::new(); + } + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(ts); + let age = now.saturating_sub(ts); + if age < 60 { + "Just now".into() + } else if age < 3600 { + format!("{} minutes ago", age / 60) + } else if age < 86400 { + format!("{} hours ago", age / 3600) + } else { + format!("{} days ago", age / 86400) + } +} + +fn sha256_id(digest: &[u8; 32]) -> String { + let mut hex = String::with_capacity(16); + for byte in digest.iter().take(8) { + hex.push_str(&format!("{byte:02x}")); + } + format!("sha256:{hex}") +} + +fn map_sdk_image(image: wslc::ImageInfo) -> ImageInfo { + let (repository, tag) = split_ref(&image.name); + ImageInfo { + repository, + tag, + id: sha256_id(&image.sha256), + size: format_bytes(image.size_bytes), + created: format_unix_time(image.created_unix_time), + } +} + +fn state_label(state: wslc::ContainerState) -> String { + match state { + wslc::ContainerState::Running => "running".into(), + wslc::ContainerState::Created => "created".into(), + wslc::ContainerState::Exited => "exited".into(), + wslc::ContainerState::Deleted => "deleted".into(), + wslc::ContainerState::Invalid => "invalid".into(), + } +} + +/// Prefer inspect JSON status when present (closer to CLI wording). +fn status_from_handle(handle: &wslc::Container) -> String { + if let Ok(raw) = handle.inspect() { + if let Ok(value) = serde_json::from_str::(&raw) { + for path in [ + &["State", "Status"][..], + &["state", "status"][..], + &["Status"][..], + &["status"][..], + ] { + let mut cur = &value; + let mut ok = true; + for key in path { + match cur.get(*key) { + Some(next) => cur = next, + None => { + ok = false; + break; + } + } + } + if ok { + if let Some(s) = cur.as_str() { + let trimmed = s.trim(); + if !trimmed.is_empty() { + return trimmed.to_ascii_lowercase(); + } + } + } + } + } + } + match handle.state() { + Ok(state) => state_label(state), + Err(err) => format!("error ({err})"), + } +} + +fn progress_percent(current: u64, total: u64) -> Option { + if total == 0 { + return None; + } + Some(((current as f64 / total as f64) * 100.0).clamp(0.0, 100.0) as f32) +} + +fn now_label() -> String { + static SEQ: AtomicU64 = AtomicU64::new(0); + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let seq = SEQ.fetch_add(1, Ordering::Relaxed); + // Full epoch seconds + monotonic seq: unique within a process, and + // collision-resistant across restarts (unlike secs % 10_000). + format!("ctr-{secs}-{seq}") +} + +fn format_ports(ports: &[PortMapping]) -> String { + ports + .iter() + .map(|p| format!("0.0.0.0:{}->{}/tcp", p.host, p.container)) + .collect::>() + .join(", ") +} + +fn format_volumes(volumes: &[VolumeMapping]) -> String { + volumes + .iter() + .map(|v| { + let prefix = if v.named { "vol:" } else { "" }; + if v.read_only { + format!("{prefix}{}:{}:ro", v.host, v.container) + } else { + format!("{prefix}{}:{}", v.host, v.container) + } + }) + .collect::>() + .join(", ") +} + +fn split_command_line(cmd: &str) -> Vec { + let mut args = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + for ch in cmd.chars() { + match ch { + '"' => in_quotes = !in_quotes, + c if c.is_whitespace() && !in_quotes => { + if !current.is_empty() { + args.push(std::mem::take(&mut current)); + } + } + c => current.push(c), + } + } + if !current.is_empty() { + args.push(current); + } + args +} + +struct TrackedContainer { + id: String, + name: String, + image: String, + created: String, + ports: String, + volumes: String, + auto_remove: bool, + handle: wslc::Container, +} + +struct LiveState { + session: wslc::Session, + version: String, + prefs: SessionPrefs, + containers: Vec, + volumes: Vec, +} + +impl LiveState { + fn boot() -> Result { + wslc::Service::ensure_available().map_err(|e| anyhow!("{e}"))?; + let version = wslc::Service::version().map_err(|e| anyhow!("{e}"))?; + let version = format!("{}.{}.{}", version.major, version.minor, version.revision); + + let prefs = SessionPrefs::load().clamp(); + + // Dedicated desktop session — not shared with the `wslc` CLI. + let path = storage_path(); + std::fs::create_dir_all(&path) + .map_err(|e| anyhow!("failed to create session storage {}: {e}", path.display()))?; + + let session = wslc::Session::builder(SESSION_NAME, path) + .cpu_count(prefs.cpu_count) + .memory_mb(prefs.memory_mb) + .start() + .map_err(|e| anyhow!("{e}"))?; + + let mut state = Self { + session, + version, + prefs, + containers: Vec::new(), + volumes: Vec::new(), + }; + state.restore_catalog(); + Ok(state) + } + + fn restore_catalog(&mut self) { + let catalog = Catalog::load(); + self.volumes = catalog.volumes; + let mut restored = Vec::new(); + for entry in catalog.containers { + let key = if !entry.id.is_empty() { + entry.id.as_str() + } else { + entry.name.as_str() + }; + if key.is_empty() { + continue; + } + match self.session.open_container(key) { + Ok(handle) => { + let id = handle.id().unwrap_or_else(|_| entry.id.clone()); + restored.push(TrackedContainer { + id, + name: entry.name, + image: entry.image, + created: entry.created, + ports: entry.ports, + volumes: entry.volumes, + auto_remove: entry.auto_remove, + handle, + }); + } + Err(_) => { + // Gone from the session (auto-remove, deleted outside, etc.) + } + } + } + self.containers = restored; + let _ = self.persist_catalog(); + } + + fn persist_catalog(&self) -> Result<()> { + let catalog = Catalog { + version: super::catalog::CATALOG_VERSION, + containers: self + .containers + .iter() + .map(|c| PersistedContainer { + id: c.id.clone(), + name: c.name.clone(), + image: c.image.clone(), + ports: c.ports.clone(), + volumes: c.volumes.clone(), + auto_remove: c.auto_remove, + created: c.created.clone(), + }) + .collect(), + volumes: self.volumes.clone(), + }; + catalog.save().map_err(|e| anyhow!("{e}")) + } + + fn status(&self) -> EngineStatus { + EngineStatus { + backend: BackendKind::Live, + binary: "wslcsdk".into(), + version: self.version.clone(), + message: format!("Connected via WSL Containers SDK {}", self.version), + cpu_count: self.prefs.cpu_count, + memory_mb: self.prefs.memory_mb, + } + } + + 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() + .find(|c| c.name == key || c.id == key || c.id.starts_with(key)) + .ok_or_else(|| anyhow!("container not found: {key}")) + } + + fn find_container(&self, key: &str) -> Result<&TrackedContainer> { + self.containers + .iter() + .find(|c| c.name == key || c.id == key || c.id.starts_with(key)) + .ok_or_else(|| anyhow!("container not found: {key}")) + } + + fn list_containers(&mut self, all: bool) -> Result> { + let mut rows = Vec::new(); + let mut keep = Vec::new(); + let prior_len = self.containers.len(); + for mut tracked in self.containers.drain(..) { + match tracked.handle.state() { + Ok(wslc::ContainerState::Deleted) => { + let _ = tracked + .handle + .delete(wslc::DeleteContainerOptions::default().force(true)); + } + Ok(state) => { + let _ = state; // status comes from inspect when possible + let status = status_from_handle(&tracked.handle); + let inspect_ports = parse_ports_from_inspect(&tracked.handle); + if !inspect_ports.is_empty() { + tracked.ports = inspect_ports; + } + let inspect_vols = parse_volumes_from_inspect(&tracked.handle); + if !inspect_vols.is_empty() { + tracked.volumes = inspect_vols; + } + let info = ContainerInfo { + id: tracked.id.clone(), + name: tracked.name.clone(), + image: tracked.image.clone(), + status, + ports: tracked.ports.clone(), + volumes: tracked.volumes.clone(), + auto_remove: tracked.auto_remove, + created: tracked.created.clone(), + }; + if all || info.is_running() { + rows.push(info); + } + keep.push(tracked); + } + Err(err) => { + // Handle gone after restart / external delete — drop from catalog. + let msg = err.to_string(); + if msg.to_ascii_lowercase().contains("not found") { + continue; + } + rows.push(ContainerInfo { + id: tracked.id.clone(), + name: tracked.name.clone(), + image: tracked.image.clone(), + status: format!("Error ({err})"), + ports: tracked.ports.clone(), + volumes: tracked.volumes.clone(), + auto_remove: tracked.auto_remove, + created: tracked.created.clone(), + }); + keep.push(tracked); + } + } + } + let pruned = keep.len() != prior_len; + self.containers = keep; + if pruned { + let _ = self.persist_catalog(); + } + Ok(rows) + } + + fn list_images(&self) -> Result> { + let images = self.session.list_images().map_err(|e| anyhow!("{e}"))?; + Ok(images.into_iter().map(map_sdk_image).collect()) + } + + fn list_volumes(&self) -> Result> { + Ok(self.volumes.clone()) + } + + fn start_container(&mut self, key: &str) -> Result<()> { + let c = self.find_container_mut(key)?; + c.handle.start().map_err(|e| anyhow!("{e}")) + } + + fn stop_container(&mut self, key: &str) -> Result<()> { + let c = self.find_container_mut(key)?; + c.handle + .stop(wslc::Signal::Sigterm, STOP_TIMEOUT) + .map_err(|e| anyhow!("{e}")) + } + + fn restart_container(&mut self, key: &str) -> Result<()> { + { + let c = self.find_container_mut(key)?; + let _ = c.handle.stop(wslc::Signal::Sigterm, STOP_TIMEOUT); + } + let c = self.find_container_mut(key)?; + c.handle.start().map_err(|e| anyhow!("{e}")) + } + + fn container_inspect(&self, key: &str) -> Result { + let tracked = self.find_container(key)?; + let raw = tracked.handle.inspect().map_err(|e| anyhow!("{e}"))?; + if let Ok(pretty) = serde_json::from_str::(&raw) { + Ok(serde_json::to_string_pretty(&pretty).unwrap_or(raw)) + } else { + Ok(raw) + } + } + + fn container_exec(&self, key: &str, command: &str) -> Result { + let tracked = self.find_container(key)?; + let cmd = command.trim(); + if cmd.is_empty() { + return Err(anyhow!("command is empty")); + } + let args = split_command_line(cmd); + if args.is_empty() { + return Err(anyhow!("command is empty")); + } + let proc = tracked + .handle + .exec( + wslc::ProcessOptions::new(args) + .capture_stdout() + .capture_stderr(), + ) + .map_err(|e| anyhow!("{e}"))?; + let out = proc.wait_with_output().map_err(|e| anyhow!("{e}"))?; + let mut body = String::from_utf8_lossy(&out.stdout).into_owned(); + let err = String::from_utf8_lossy(&out.stderr); + if !err.trim().is_empty() { + if !body.is_empty() { + body.push('\n'); + } + body.push_str(&err); + } + if body.trim().is_empty() { + Ok(format!("(exit {})", out.status)) + } else { + Ok(body) + } + } + + fn remove_container(&mut self, key: &str) -> Result<()> { + let idx = self + .containers + .iter() + .position(|c| c.name == key || c.id == key || c.id.starts_with(key)) + .ok_or_else(|| anyhow!("container not found: {key}"))?; + let tracked = self.containers.remove(idx); + let _ = tracked + .handle + .stop(wslc::Signal::Sigkill, Duration::from_secs(1)); + tracked + .handle + .delete(wslc::DeleteContainerOptions::default().force(true)) + .map_err(|e| anyhow!("{e}"))?; + let _ = self.persist_catalog(); + Ok(()) + } + + fn remove_image(&self, reference: &str) -> Result<()> { + self.session + .delete_image(reference) + .map_err(|e| anyhow!("{e}")) + } + + fn create_container(&mut self, request: CreateContainerRequest) -> Result { + let image = request.image.trim(); + if image.is_empty() { + return Err(anyhow!("image reference is empty")); + } + let name = request + .name + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .unwrap_or_else(now_label); + + let ports_label = format_ports(&request.ports); + let volumes_label = format_volumes(&request.volumes); + + let mut builder = self + .session + .container(wslc::ContainerOptions::new(image)) + .name(&name) + .auto_remove(request.auto_remove); + + for port in &request.ports { + builder = builder.port(port.host, port.container); + } + for vol in &request.volumes { + builder = if vol.named { + if vol.read_only { + builder.named_volume_read_only(vol.host.clone(), vol.container.clone()) + } else { + builder.named_volume(vol.host.clone(), vol.container.clone()) + } + } else { + let host = PathBuf::from(&vol.host); + if vol.read_only { + builder.volume_read_only(host, vol.container.clone()) + } else { + builder.volume(host, vol.container.clone()) + } + }; + } + + // Only override init when the user supplies a command. Otherwise keep + // the image ENTRYPOINT/CMD (e.g. nginx) so the container actually serves. + if let Some(cmd) = request + .command + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + let args = split_command_line(cmd); + if args.is_empty() { + return Err(anyhow!("command is empty")); + } + builder = builder.init_process(wslc::ProcessOptions::new(args)); + } + + let handle = builder.create().map_err(|e| anyhow!("{e}"))?; + handle.start().map_err(|e| anyhow!("{e}"))?; + let id = handle.id().unwrap_or_else(|_| name.clone()); + + let mut ports = ports_label; + let inspect_ports = parse_ports_from_inspect(&handle); + if !inspect_ports.is_empty() { + ports = inspect_ports; + } + let mut volumes = volumes_label; + let inspect_vols = parse_volumes_from_inspect(&handle); + if !inspect_vols.is_empty() { + volumes = inspect_vols; + } + + self.containers.push(TrackedContainer { + id, + name: name.clone(), + image: image.to_string(), + created: "Just now".into(), + ports, + volumes, + auto_remove: request.auto_remove, + handle, + }); + let _ = self.persist_catalog(); + Ok(name) + } + + fn create_volume(&mut self, name: &str, size_bytes: u64) -> Result<()> { + let name = name.trim(); + if name.is_empty() { + return Err(anyhow!("volume name is empty")); + } + if self.volumes.iter().any(|v| v.name == name) { + return Err(anyhow!("volume already exists: {name}")); + } + let size_bytes = if size_bytes == 0 { + DEFAULT_VOLUME_BYTES + } else { + size_bytes.max(64 * 1024 * 1024) + }; + self.session + .create_vhd_volume( + wslc::VhdOptions::new(name, size_bytes).vhd_type(wslc::VhdType::Dynamic), + ) + .map_err(|e| anyhow!("{e}"))?; + self.volumes.insert( + 0, + VolumeInfo { + name: name.to_string(), + driver: "vhd".into(), + mountpoint: format!("session:{name}"), + created: "Just now".into(), + size: format_bytes(size_bytes as i64), + }, + ); + let _ = self.persist_catalog(); + Ok(()) + } + + fn remove_volume(&mut self, name: &str) -> Result<()> { + let name = name.trim(); + if name.is_empty() { + return Err(anyhow!("volume name is empty")); + } + self.session + .delete_vhd_volume(name) + .map_err(|e| anyhow!("{e}"))?; + self.volumes.retain(|v| v.name != name); + let _ = self.persist_catalog(); + Ok(()) + } + + fn container_logs(&self, key: &str) -> Result { + let tracked = self.find_container(key)?; + // WSLC Rust SDK has no Logs API yet; collect runtime log files via exec. + // Never dump inspect JSON here — that is metadata, not container output. + let script = r#"set +e +found=0 +# Common application / system log paths inside the container. +for f in \ + /var/log/nginx/access.log /var/log/nginx/error.log \ + /var/log/apache2/access.log /var/log/apache2/error.log \ + /var/log/httpd/access_log /var/log/httpd/error_log \ + /var/log/containers/*.log /var/log/*.log \ + /app/*.log /tmp/*.log /var/log/syslog /var/log/messages +do + [ -f "$f" ] || continue + echo "===== $f =====" + tail -n 200 "$f" 2>/dev/null + echo + found=1 +done +# Fall back to any readable files under /var/log (depth-limited). +if [ "$found" -eq 0 ]; then + while IFS= read -r f; do + [ -f "$f" ] || continue + echo "===== $f =====" + tail -n 100 "$f" 2>/dev/null + echo + found=1 + done </dev/null | head -n 20) +EOF +fi +if [ "$found" -eq 0 ]; then + echo "(no log files found under common paths)" + echo + echo "===== pid 1 =====" + if [ -r /proc/1/cmdline ]; then + tr '\0' ' ' < /proc/1/cmdline 2>/dev/null + echo + fi + (ps aux 2>/dev/null || ps -ef 2>/dev/null | head -n 30) +fi +"#; + match tracked.handle.exec( + wslc::ProcessOptions::new(["/bin/sh", "-c", script]) + .capture_stdout() + .capture_stderr(), + ) { + Ok(proc) => match proc.wait_with_output() { + Ok(out) => { + let mut body = String::from_utf8_lossy(&out.stdout).into_owned(); + let err = String::from_utf8_lossy(&out.stderr); + if !err.trim().is_empty() { + if !body.is_empty() { + body.push('\n'); + } + body.push_str(&err); + } + if body.trim().is_empty() { + Ok("(no runtime log output)".into()) + } else { + Ok(body) + } + } + Err(err) => Err(anyhow!("wait for log collector failed: {err}")), + }, + Err(err) => Err(anyhow!("exec log collector failed: {err}")), + } + } + + fn pull(&self, reference: &str, events: &Sender) { + let reference = reference.trim(); + if reference.is_empty() { + let _ = events.send(PullEvent::Finished(Err("image reference is empty".into()))); + return; + } + + let _ = events.send(PullEvent::Status(format!("Pulling {reference}…"))); + let _ = events.send(PullEvent::Progress(0.0)); + let _ = events.send(PullEvent::Log(format!("pull {reference} via wslcsdk"))); + + let events_cb = events.clone(); + let result = self + .session + .pull_image(wslc::ImagePullOptions::new(reference)) + .on_progress(move |p| { + let status = format!("{:?} {}", p.status, p.id); + let _ = events_cb.send(PullEvent::Log(status.clone())); + let _ = events_cb.send(PullEvent::Status(status)); + if let Some(pct) = progress_percent(p.current_bytes, p.total_bytes) { + let _ = events_cb.send(PullEvent::Progress(pct)); + } else if matches!(p.status, wslc::ImageProgressStatus::Complete) { + let _ = events_cb.send(PullEvent::Progress(100.0)); + } + }) + .run(); + + match result { + Ok(()) => { + let _ = events.send(PullEvent::Progress(100.0)); + let _ = events.send(PullEvent::Status("Pull complete".into())); + let _ = events.send(PullEvent::Finished(Ok(()))); + } + Err(err) => { + let _ = events.send(PullEvent::Finished(Err(err.to_string()))); + } + } + } + + fn shutdown(self) { + // Do not terminate the session: keep the VM + containers alive so the + // next launch can reopen handles via OpenContainer + the local catalog. + let _ = self.persist_catalog(); + } +} + +fn storage_path() -> PathBuf { + // Dedicated directory: WSLC rejects non-empty storage roots. + // SDK downloads live under `%LOCALAPPDATA%\WSLcDesktop\sdk\` — keep session data apart. + let base = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\WslcData")); + base.join("WSLcDesktop").join("session") +} + +fn parse_ports_from_inspect(container: &wslc::Container) -> String { + let Ok(raw) = container.inspect() else { + return String::new(); + }; + let Ok(value) = serde_json::from_str::(&raw) else { + return String::new(); + }; + for key in ["Ports", "ports", "PortMappings", "portMappings"] { + if let Some(ports) = value.get(key) { + match ports { + serde_json::Value::String(s) => return s.clone(), + serde_json::Value::Array(items) => { + let rendered: Vec = items + .iter() + .filter_map(|item| { + if let Some(s) = item.as_str() { + return Some(s.to_string()); + } + let host = item + .get("HostPort") + .or_else(|| item.get("windowsPort")) + .or_else(|| item.get("WindowsPort")) + .and_then(|v| { + v.as_u64() + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) + }); + let cont = item + .get("ContainerPort") + .or_else(|| item.get("containerPort")) + .and_then(|v| { + v.as_u64() + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) + }); + match (host, cont) { + (Some(h), Some(c)) => Some(format!("0.0.0.0:{h}->{c}/tcp")), + _ => None, + } + }) + .collect(); + if !rendered.is_empty() { + return rendered.join(", "); + } + } + _ => {} + } + } + } + String::new() +} + +fn parse_volumes_from_inspect(container: &wslc::Container) -> String { + let Ok(raw) = container.inspect() else { + return String::new(); + }; + let Ok(value) = serde_json::from_str::(&raw) else { + return String::new(); + }; + for key in ["Mounts", "mounts", "Volumes", "volumes", "VolumeMounts"] { + if let Some(vols) = value.get(key) { + match vols { + serde_json::Value::String(s) => return s.clone(), + serde_json::Value::Array(items) => { + let rendered: Vec = items + .iter() + .filter_map(|item| { + if let Some(s) = item.as_str() { + return Some(s.to_string()); + } + let host = item + .get("Source") + .or_else(|| item.get("windowsPath")) + .or_else(|| item.get("WindowsPath")) + .or_else(|| item.get("HostPath")) + .and_then(|v| v.as_str()); + let cont = item + .get("Destination") + .or_else(|| item.get("containerPath")) + .or_else(|| item.get("ContainerPath")) + .or_else(|| item.get("Target")) + .and_then(|v| v.as_str()); + match (host, cont) { + (Some(h), Some(c)) => Some(format!("{h}:{c}")), + _ => None, + } + }) + .collect(); + if !rendered.is_empty() { + return rendered.join(", "); + } + } + _ => {} + } + } + } + String::new() +} + +fn live_worker(mut state: LiveState, rx: mpsc::Receiver) { + while let Ok(cmd) = rx.recv() { + match cmd { + 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)); + } + Cmd::ListImages { reply } => { + let _ = reply.send(state.list_images()); + } + Cmd::ListVolumes { reply } => { + let _ = reply.send(state.list_volumes()); + } + Cmd::StartContainer { key, reply } => { + let _ = reply.send(state.start_container(&key)); + } + Cmd::StopContainer { key, reply } => { + let _ = reply.send(state.stop_container(&key)); + } + Cmd::RestartContainer { key, reply } => { + let _ = reply.send(state.restart_container(&key)); + } + Cmd::RemoveContainer { key, reply } => { + let _ = reply.send(state.remove_container(&key)); + } + Cmd::RemoveImage { reference, reply } => { + let _ = reply.send(state.remove_image(&reference)); + } + Cmd::CreateContainer { request, reply } => { + let _ = reply.send(state.create_container(request)); + } + Cmd::CreateVolume { + name, + size_bytes, + reply, + } => { + let _ = reply.send(state.create_volume(&name, size_bytes)); + } + Cmd::RemoveVolume { name, reply } => { + let _ = reply.send(state.remove_volume(&name)); + } + Cmd::StartPull { reference, events } => { + state.pull(&reference, &events); + } + Cmd::ContainerLogs { key, reply } => { + let _ = reply.send(state.container_logs(&key)); + } + Cmd::ContainerInspect { key, reply } => { + let _ = reply.send(state.container_inspect(&key)); + } + Cmd::ContainerExec { + key, + command, + reply, + } => { + let _ = reply.send(state.container_exec(&key, &command)); + } + Cmd::Shutdown => break, + } + } + state.shutdown(); +} diff --git a/src/wslc/mod.rs b/src/wslc/mod.rs new file mode 100644 index 0000000..1b5d144 --- /dev/null +++ b/src/wslc/mod.rs @@ -0,0 +1,445 @@ +//! Backend for WSL Containers via the official `wslc` Rust SDK (`wslcsdk.dll`). +//! +//! There is no CLI path and no demo mode. The app must obtain a live SDK session +//! (download/cache `wslcsdk.dll`, then open a Session) before any operations. + +use std::sync::mpsc::Sender; +use std::sync::Arc; +use std::thread; + +use anyhow::{anyhow, Result}; + +mod catalog; +#[cfg(windows)] +mod live; +mod prefs; +#[cfg(windows)] +mod sdk_fetch; +mod sdk_version; +#[cfg(windows)] +mod syspath; +mod types; + +pub use prefs::SessionPrefs; +pub use types::*; + +pub(super) fn split_ref(reference: &str) -> (String, String) { + if let Some((repo, tag)) = reference.rsplit_once(':') { + if !tag.contains('/') { + return (repo.to_string(), tag.to_string()); + } + } + (reference.to_string(), "latest".into()) +} + +enum Backend { + #[cfg(windows)] + Live { + tx: std::sync::mpsc::Sender, + version: String, + }, + /// Placeholder so non-Windows builds type-check; never constructed. + #[cfg(not(windows))] + #[allow(dead_code)] + Unsupported, +} + +struct ClientInner { + backend: Backend, +} + +impl Drop for ClientInner { + fn drop(&mut self) { + #[cfg(windows)] + if let Backend::Live { tx, .. } = &self.backend { + let _ = tx.send(live::Cmd::Shutdown); + } + } +} + +#[derive(Clone)] +pub struct WslcClient { + inner: Arc, +} + +#[cfg_attr(not(windows), allow(unused_variables))] +impl WslcClient { + #[cfg(windows)] + fn from_live(tx: std::sync::mpsc::Sender, version: String) -> Self { + Self { + inner: Arc::new(ClientInner { + backend: Backend::Live { tx, version }, + }), + } + } + + /// Spawn a background install/connect attempt and stream events on `tx`. + /// + /// When `force_redownload` is true, the cached NuGet DLL is discarded first + /// so Retry reinstalls the SDK. + pub fn start_setup(force_redownload: bool, tx: Sender) { + thread::Builder::new() + .name("wslc-sdk-setup".into()) + .spawn(move || Self::run_setup(force_redownload, tx)) + .expect("spawn SDK setup thread"); + } + + fn run_setup(force_redownload: bool, tx: Sender) { + #[cfg(not(windows))] + { + let _ = force_redownload; + let _ = tx.send(SdkSetupEvent::Status( + "WSLc Desktop requires Windows".into(), + )); + let _ = tx.send(SdkSetupEvent::Log( + "This build is not Windows; the WSL Containers SDK cannot load.".into(), + )); + let _ = tx.send(SdkSetupEvent::Progress(0.0)); + let _ = tx.send(SdkSetupEvent::Finished(Err( + "WSLc Desktop only runs on Windows with WSL Containers".into(), + ))); + } + + #[cfg(windows)] + { + Self::run_setup_windows(force_redownload, tx); + } + } + + #[cfg(windows)] + fn run_setup_windows(force_redownload: bool, tx: Sender) { + let _ = tx.send(SdkSetupEvent::Status( + "Preparing WSL Containers SDK…".into(), + )); + let _ = tx.send(SdkSetupEvent::Progress(2.0)); + let _ = tx.send(SdkSetupEvent::Log( + "Resolving host wslc/wsl version and matching NuGet package…".into(), + )); + + let tx_fetch = tx.clone(); + let fetch_result = sdk_fetch::ensure_sdk_dir(force_redownload, move |ev| { + let mapped = match ev { + sdk_fetch::FetchEvent::Status(s) => SdkSetupEvent::Status(s), + sdk_fetch::FetchEvent::Log(s) => SdkSetupEvent::Log(s), + sdk_fetch::FetchEvent::Progress(p) => { + // Reserve 0–80% for download/cache; 80–100% for session boot. + SdkSetupEvent::Progress((p * 0.8).clamp(0.0, 80.0)) + } + }; + let _ = tx_fetch.send(mapped); + }); + + let mut preferred = Vec::new(); + match fetch_result { + Ok(dir) => { + let _ = tx.send(SdkSetupEvent::Log(format!( + "SDK directory ready: {}", + dir.display() + ))); + preferred.push(dir); + } + Err(err) => { + let _ = tx.send(SdkSetupEvent::Log(format!("SDK install failed: {err:#}"))); + let _ = tx.send(SdkSetupEvent::Progress(0.0)); + let _ = tx.send(SdkSetupEvent::Finished(Err(format!( + "SDK install failed: {err:#}" + )))); + return; + } + } + + let _ = tx.send(SdkSetupEvent::Status("Loading wslcsdk.dll…".into())); + let _ = tx.send(SdkSetupEvent::Progress(82.0)); + let _ = tx.send(SdkSetupEvent::Log( + "Prepending SDK path and starting live session…".into(), + )); + syspath::prepare_sdk_search_path(&preferred); + + let _ = tx.send(SdkSetupEvent::Progress(88.0)); + match live::spawn_worker() { + Ok((cmd_tx, version)) => { + let _ = tx.send(SdkSetupEvent::Log(format!( + "WSL Containers SDK session ready (version {version})" + ))); + let _ = tx.send(SdkSetupEvent::Progress(100.0)); + let _ = tx.send(SdkSetupEvent::Status("Connected".into())); + let _ = tx.send(SdkSetupEvent::Finished(Ok(Self::from_live( + cmd_tx, version, + )))); + } + Err(detail) => { + let _ = tx.send(SdkSetupEvent::Log(format!( + "Live session failed: {detail}" + ))); + let _ = tx.send(SdkSetupEvent::Progress(0.0)); + let _ = tx.send(SdkSetupEvent::Finished(Err(detail))); + } + } + } + + pub fn status(&self) -> EngineStatus { + match &self.inner.backend { + #[cfg(windows)] + Backend::Live { tx, version } => { + let (reply_tx, reply_rx) = std::sync::mpsc::channel(); + if tx.send(live::Cmd::Status(reply_tx)).is_ok() { + if let Ok(status) = reply_rx.recv() { + return status; + } + } + EngineStatus::unavailable(format!( + "SDK session became unavailable (was {version})" + )) + } + #[cfg(not(windows))] + Backend::Unsupported => EngineStatus::unavailable( + "WSLc Desktop only runs on Windows with WSL Containers", + ), + } + } + + pub fn list_containers(&self, all: bool) -> Result> { + match &self.inner.backend { + #[cfg(windows)] + Backend::Live { tx, .. } => live::call(tx, |reply| live::Cmd::ListContainers { + all, + reply, + }), + #[cfg(not(windows))] + Backend::Unsupported => Err(anyhow!("WSL Containers SDK unavailable")), + } + } + + pub fn list_images(&self) -> Result> { + match &self.inner.backend { + #[cfg(windows)] + Backend::Live { tx, .. } => live::call(tx, |reply| live::Cmd::ListImages { reply }), + #[cfg(not(windows))] + Backend::Unsupported => Err(anyhow!("WSL Containers SDK unavailable")), + } + } + + pub fn start_container(&self, name_or_id: &str) -> Result<()> { + match &self.inner.backend { + #[cfg(windows)] + Backend::Live { tx, .. } => live::call(tx, |reply| live::Cmd::StartContainer { + key: name_or_id.to_string(), + reply, + }), + #[cfg(not(windows))] + Backend::Unsupported => Err(anyhow!("WSL Containers SDK unavailable")), + } + } + + pub fn stop_container(&self, name_or_id: &str) -> Result<()> { + match &self.inner.backend { + #[cfg(windows)] + Backend::Live { tx, .. } => live::call(tx, |reply| live::Cmd::StopContainer { + key: name_or_id.to_string(), + reply, + }), + #[cfg(not(windows))] + Backend::Unsupported => Err(anyhow!("WSL Containers SDK unavailable")), + } + } + + pub fn restart_container(&self, name_or_id: &str) -> Result<()> { + match &self.inner.backend { + #[cfg(windows)] + Backend::Live { tx, .. } => live::call(tx, |reply| live::Cmd::RestartContainer { + key: name_or_id.to_string(), + reply, + }), + #[cfg(not(windows))] + Backend::Unsupported => Err(anyhow!("WSL Containers SDK unavailable")), + } + } + + pub fn remove_container(&self, name_or_id: &str) -> Result<()> { + match &self.inner.backend { + #[cfg(windows)] + Backend::Live { tx, .. } => live::call(tx, |reply| live::Cmd::RemoveContainer { + key: name_or_id.to_string(), + reply, + }), + #[cfg(not(windows))] + Backend::Unsupported => Err(anyhow!("WSL Containers SDK unavailable")), + } + } + + pub fn remove_image(&self, reference: &str) -> Result<()> { + match &self.inner.backend { + #[cfg(windows)] + Backend::Live { tx, .. } => live::call(tx, |reply| live::Cmd::RemoveImage { + reference: reference.to_string(), + reply, + }), + #[cfg(not(windows))] + Backend::Unsupported => Err(anyhow!("WSL Containers SDK unavailable")), + } + } + + pub fn create_container(&self, request: CreateContainerRequest) -> Result { + if request.image.trim().is_empty() { + return Err(anyhow!("image reference is empty")); + } + + match &self.inner.backend { + #[cfg(windows)] + Backend::Live { tx, .. } => { + live::call(tx, |reply| live::Cmd::CreateContainer { request, reply }) + } + #[cfg(not(windows))] + Backend::Unsupported => Err(anyhow!("WSL Containers SDK unavailable")), + } + } + + pub fn container_logs(&self, name_or_id: &str) -> Result { + match &self.inner.backend { + #[cfg(windows)] + Backend::Live { tx, .. } => live::call(tx, |reply| live::Cmd::ContainerLogs { + key: name_or_id.to_string(), + reply, + }), + #[cfg(not(windows))] + Backend::Unsupported => Err(anyhow!("WSL Containers SDK unavailable")), + } + } + + pub fn container_inspect(&self, name_or_id: &str) -> Result { + match &self.inner.backend { + #[cfg(windows)] + Backend::Live { tx, .. } => live::call(tx, |reply| live::Cmd::ContainerInspect { + key: name_or_id.to_string(), + reply, + }), + #[cfg(not(windows))] + Backend::Unsupported => Err(anyhow!("WSL Containers SDK unavailable")), + } + } + + pub fn container_exec(&self, name_or_id: &str, command: &str) -> Result { + match &self.inner.backend { + #[cfg(windows)] + Backend::Live { tx, .. } => live::call(tx, |reply| live::Cmd::ContainerExec { + key: name_or_id.to_string(), + command: command.to_string(), + reply, + }), + #[cfg(not(windows))] + Backend::Unsupported => Err(anyhow!("WSL Containers SDK unavailable")), + } + } + + #[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)] + Backend::Live { tx, .. } => live::call(tx, |reply| live::Cmd::ListVolumes { reply }), + #[cfg(not(windows))] + Backend::Unsupported => Err(anyhow!("WSL Containers SDK unavailable")), + } + } + + pub fn create_volume(&self, name: &str, size_bytes: u64) -> Result<()> { + let name = name.trim(); + if name.is_empty() { + return Err(anyhow!("volume name is empty")); + } + + match &self.inner.backend { + #[cfg(windows)] + Backend::Live { tx, .. } => live::call(tx, |reply| live::Cmd::CreateVolume { + name: name.to_string(), + size_bytes, + reply, + }), + #[cfg(not(windows))] + Backend::Unsupported => Err(anyhow!("WSL Containers SDK unavailable")), + } + } + + pub fn save_session_prefs(prefs: SessionPrefs) -> Result<()> { + prefs.clamp().save().map_err(|e| anyhow!("{e}")) + } + + pub fn load_session_prefs() -> SessionPrefs { + SessionPrefs::load().clamp() + } + + pub fn remove_volume(&self, name: &str) -> Result<()> { + let name = name.trim(); + if name.is_empty() { + return Err(anyhow!("volume name is empty")); + } + + match &self.inner.backend { + #[cfg(windows)] + Backend::Live { tx, .. } => live::call(tx, |reply| live::Cmd::RemoveVolume { + name: name.to_string(), + reply, + }), + #[cfg(not(windows))] + Backend::Unsupported => Err(anyhow!("WSL Containers SDK unavailable")), + } + } + + /// Spawn a background pull and stream status / progress / logs on `tx`. + pub fn start_pull(&self, reference: &str, tx: Sender) { + let reference = reference.trim().to_string(); + match &self.inner.backend { + #[cfg(windows)] + Backend::Live { tx: cmd_tx, .. } => { + let _ = cmd_tx.send(live::Cmd::StartPull { + reference, + events: tx, + }); + } + #[cfg(not(windows))] + Backend::Unsupported => { + let _ = tx.send(PullEvent::Finished(Err( + "WSL Containers SDK unavailable".into(), + ))); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_refs() { + assert_eq!(split_ref("nginx:alpine"), ("nginx".into(), "alpine".into())); + assert_eq!( + split_ref("docker.io/library/alpine"), + ("docker.io/library/alpine".into(), "latest".into()) + ); + } + + #[test] + fn unavailable_status_helper() { + let s = EngineStatus::unavailable("no sdk"); + assert_eq!(s.backend, BackendKind::Unavailable); + assert_eq!(s.message, "no sdk"); + } +} diff --git a/src/wslc/prefs.rs b/src/wslc/prefs.rs new file mode 100644 index 0000000..da15d0d --- /dev/null +++ b/src/wslc/prefs.rs @@ -0,0 +1,86 @@ +//! Desktop preferences (session CPU / memory). Applied on session start. + +use std::fs; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +pub const DEFAULT_CPU_COUNT: u32 = 2; +pub const DEFAULT_MEMORY_MB: u32 = 2048; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionPrefs { + pub cpu_count: u32, + pub memory_mb: u32, +} + +impl Default for SessionPrefs { + fn default() -> Self { + Self { + cpu_count: DEFAULT_CPU_COUNT, + memory_mb: DEFAULT_MEMORY_MB, + } + } +} + +#[cfg_attr(not(windows), allow(dead_code))] +impl SessionPrefs { + pub fn path() -> PathBuf { + let base = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\WslcData")); + base.join("WSLcDesktop").join("prefs.json") + } + + pub fn load() -> Self { + let Ok(bytes) = fs::read(Self::path()) else { + return Self::default(); + }; + match serde_json::from_slice::(&bytes) { + Ok(mut p) => { + if p.cpu_count == 0 { + p.cpu_count = DEFAULT_CPU_COUNT; + } + if p.memory_mb < 256 { + p.memory_mb = DEFAULT_MEMORY_MB; + } + p + } + Err(_) => Self::default(), + } + } + + pub fn save(&self) -> Result<(), String> { + let path = Self::path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("create prefs dir: {e}"))?; + } + let json = serde_json::to_vec_pretty(self).map_err(|e| format!("serialize prefs: {e}"))?; + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, json).map_err(|e| format!("write prefs tmp: {e}"))?; + fs::rename(&tmp, path).map_err(|e| format!("replace prefs: {e}"))?; + Ok(()) + } + + pub fn clamp(mut self) -> Self { + self.cpu_count = self.cpu_count.clamp(1, 16); + self.memory_mb = self.memory_mb.clamp(512, 32768); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prefs_roundtrip() { + let p = SessionPrefs { + cpu_count: 4, + memory_mb: 4096, + }; + let bytes = serde_json::to_vec(&p).unwrap(); + let back: SessionPrefs = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(back, p); + } +} diff --git a/src/wslc/sdk_fetch.rs b/src/wslc/sdk_fetch.rs new file mode 100644 index 0000000..d512d7d --- /dev/null +++ b/src/wslc/sdk_fetch.rs @@ -0,0 +1,412 @@ +//! Download and cache `wslcsdk.dll` matched to the host `wslc` / WSL version. +//! +//! 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. + +#![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::{extract_version, pick_nuget_version}; + +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 + +#[derive(Debug, Clone)] +pub enum FetchEvent { + Status(String), + Log(String), + Progress(f32), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct IndexCache { + fetched_unix: u64, + 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. +pub fn ensure_sdk_dir( + force_redownload: bool, + mut on_event: impl FnMut(FetchEvent), +) -> Result { + let mut emit = |ev: FetchEvent| on_event(ev); + + emit(FetchEvent::Status("Detecting WSL / wslc version…".into())); + emit(FetchEvent::Progress(5.0)); + let host = match probe_host_version() { + Ok(v) => { + emit(FetchEvent::Log(format!("Host version: {v}"))); + v + } + Err(err) => { + emit(FetchEvent::Log(format!( + "Could not read wslc/wsl version: {err:#}" + ))); + emit(FetchEvent::Log( + "Install or update WSL (`wsl --install` / `wsl --update`), ensure it is on PATH, then retry." + .into(), + )); + "unknown".into() + } + }; + + let arch = native_arch_folder(); + emit(FetchEvent::Log(format!("Native arch folder: {arch}"))); + emit(FetchEvent::Progress(12.0)); + + let stamp_path = sdk_root().join("active.json"); + + // Prefer a matching on-disk DLL before touching NuGet (offline / stale index). + if !force_redownload { + if let Some(dir) = try_active_sdk_dir(&stamp_path, Some(&host), arch) { + emit(FetchEvent::Status("Using cached SDK".into())); + emit(FetchEvent::Log(format!( + "Cache hit (active stamp): {}", + dir.join("wslcsdk.dll").display() + ))); + emit(FetchEvent::Progress(100.0)); + return Ok(dir); + } + } + + emit(FetchEvent::Status("Listing Microsoft.WSL.Containers on NuGet…".into())); + let versions = match 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); + } + } + return Err(err).context("list Microsoft.WSL.Containers versions"); + } + }; + emit(FetchEvent::Log(format!( + "NuGet published {} package version(s)", + versions.len() + ))); + emit(FetchEvent::Progress(22.0)); + + let nuget = pick_nuget_version(&host, &versions) + .ok_or_else(|| anyhow!("no Microsoft.WSL.Containers versions published"))?; + emit(FetchEvent::Log(format!( + "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() { + emit(FetchEvent::Log(format!( + "Retry requested — removing cached DLL at {}", + dll.display() + ))); + let _ = fs::remove_file(&dll); + let _ = fs::remove_file(&stamp_path); + } + + 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); + } + } + + emit(FetchEvent::Status(format!( + "Downloading Microsoft.WSL.Containers {nuget}…" + ))); + 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)?; + 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", + } +} + +fn probe_host_version() -> Result { + 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) { + 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}`"))?; + let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); + if text.trim().is_empty() { + text = String::from_utf8_lossy(&output.stderr).into_owned(); + } + 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 { + 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; + } + } + let dir = sdk_root() + .join(format!("nuget-{}", stamp.nuget_version)) + .join(arch); + nuget_dir_ready(&dir).then_some(dir) +} + +fn nuget_versions(emit: &mut dyn FnMut(FetchEvent)) -> Result> { + let cache_path = sdk_root().join("nuget-index.json"); + let cached = fs::read_to_string(&cache_path) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .filter(|cache| !cache.versions.is_empty()); + + if let Some(ref cache) = cached { + let age = now_unix().saturating_sub(cache.fetched_unix); + if age < INDEX_MAX_AGE.as_secs() { + emit(FetchEvent::Log( + "Using cached NuGet version index (< 1h old)".into(), + )); + return Ok(cache.versions.clone()); + } + } + + emit(FetchEvent::Log(format!("GET {NUGET_INDEX}"))); + let fetched = (|| -> Result> { + let body: serde_json::Value = ureq::get(NUGET_INDEX) + .call() + .context("GET nuget index")? + .into_json() + .context("parse nuget index")?; + let versions = body + .get("versions") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + if versions.is_empty() { + return Err(anyhow!("empty nuget version list")); + } + Ok(versions) + })(); + + match fetched { + Ok(versions) => { + fs::create_dir_all(sdk_root())?; + let cache = IndexCache { + fetched_unix: now_unix(), + versions: versions.clone(), + }; + fs::write(&cache_path, serde_json::to_string_pretty(&cache)?)?; + Ok(versions) + } + Err(err) => { + if let Some(cache) = cached { + emit(FetchEvent::Log(format!( + "NuGet index refresh failed ({err:#}); using stale cached index" + ))); + Ok(cache.versions) + } else { + Err(err) + } + } + } +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn download_nuget_dll( + version: &str, + arch: &str, + out_dir: &Path, + emit: &mut dyn FnMut(FetchEvent), +) -> Result<()> { + fs::create_dir_all(out_dir)?; + let url = format!( + "https://api.nuget.org/v3-flatcontainer/microsoft.wsl.containers/{version}/microsoft.wsl.containers.{version}.nupkg" + ); + let tmp_nupkg = out_dir.join(format!("microsoft.wsl.containers.{version}.nupkg")); + emit(FetchEvent::Log(format!("GET {url}"))); + { + let resp = ureq::get(&url) + .call() + .with_context(|| format!("GET {url}"))?; + let total = resp + .header("Content-Length") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let mut reader = resp.into_reader(); + let mut file = File::create(&tmp_nupkg)?; + let mut buf = [0u8; 64 * 1024]; + let mut written: u64 = 0; + loop { + let n = reader.read(&mut buf)?; + if n == 0 { + break; + } + file.write_all(&buf[..n])?; + 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!( + "Downloaded {} KB…", + written / 1024 + ))); + } + } + file.flush()?; + emit(FetchEvent::Log(format!( + "Downloaded nupkg ({} KB)", + written / 1024 + ))); + } + + emit(FetchEvent::Status("Extracting wslcsdk.dll…".into())); + emit(FetchEvent::Progress(78.0)); + let file = File::open(&tmp_nupkg)?; + let mut archive = zip::ZipArchive::new(file).context("open nupkg as zip")?; + let member = format!("runtimes/{arch}/native/wslcsdk.dll"); + let mut entry = archive + .by_name(&member) + .with_context(|| format!("nupkg missing {member}"))?; + let dll_path = out_dir.join("wslcsdk.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() { + return Err(anyhow!("extracted dll missing at {}", dll_path.display())); + } + emit(FetchEvent::Log(format!( + "Extracted {}", + dll_path.display() + ))); + emit(FetchEvent::Progress(95.0)); + Ok(()) +} diff --git a/src/wslc/sdk_version.rs b/src/wslc/sdk_version.rs new file mode 100644 index 0000000..55b046a --- /dev/null +++ b/src/wslc/sdk_version.rs @@ -0,0 +1,102 @@ +//! Host / NuGet version matching (no Windows-only deps). + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct SemVer { + pub major: u32, + pub minor: u32, + pub patch: u32, +} + +impl SemVer { + pub fn parse(s: &str) -> Option { + let mut parts = s.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next().unwrap_or("0").parse().ok()?; + Some(Self { + major, + minor, + patch, + }) + } +} + +/// Pull the first `x.y.z` / `x.y.z.w` from command output. +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])); + } + } + 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). +pub fn pick_nuget_version(host: &str, available: &[String]) -> Option { + let mut parsed: Vec<(SemVer, &String)> = available + .iter() + .filter_map(|v| SemVer::parse(v).map(|sv| (sv, v))) + .collect(); + if parsed.is_empty() { + return available.last().cloned(); + } + parsed.sort_by(|a, b| a.0.cmp(&b.0)); + + if let Some(h) = SemVer::parse(host) { + if let Some((_, v)) = parsed.iter().find(|(sv, _)| *sv == h) { + return Some((*v).clone()); + } + if let Some((_, v)) = parsed.iter().rev().find(|(sv, _)| sv.major == h.major) { + return Some((*v).clone()); + } + } + parsed.last().map(|(_, v)| (*v).clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_version_tokens() { + assert_eq!( + extract_version("WSL version: 2.9.3.0\n"), + Some("2.9.3".into()) + ); + assert_eq!(extract_version("wslc 3.0.1\n"), Some("3.0.1".into())); + } + + #[test] + fn picks_exact_same_major_or_latest() { + let available = vec!["2.9.3".into(), "2.9.9".into(), "3.0.1".into()]; + assert_eq!( + pick_nuget_version("2.9.9", &available).as_deref(), + Some("2.9.9") + ); + assert_eq!( + pick_nuget_version("2.9.5", &available).as_deref(), + Some("2.9.9") + ); + assert_eq!( + pick_nuget_version("3.0.0", &available).as_deref(), + Some("3.0.1") + ); + assert_eq!( + pick_nuget_version("4.0.0", &available).as_deref(), + Some("3.0.1") + ); + assert_eq!( + pick_nuget_version("unknown", &available).as_deref(), + Some("3.0.1") + ); + } +} diff --git a/src/wslc/syspath.rs b/src/wslc/syspath.rs new file mode 100644 index 0000000..75d752d --- /dev/null +++ b/src/wslc/syspath.rs @@ -0,0 +1,105 @@ +//! Put directories that contain `wslcsdk.dll` on the process PATH so LoadLibrary +//! can resolve the bare name used by `wslc-sys`. + +#![cfg(windows)] + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +/// Prefer `preferred` dirs (e.g. auto-downloaded cache), then any system copy. +pub fn prepare_sdk_search_path(preferred: &[PathBuf]) { + let mut dirs = Vec::new(); + + for dir in preferred { + if dir.join("wslcsdk.dll").is_file() { + dirs.push(dir.clone()); + } + } + + for candidate in candidate_dirs() { + if candidate.join("wslcsdk.dll").is_file() { + dirs.push(candidate); + } + } + + let mut seen = std::collections::HashSet::new(); + dirs.retain(|d| seen.insert(d.clone())); + + if dirs.is_empty() { + return; + } + + let mut new_path = OsString::new(); + for dir in &dirs { + if !new_path.is_empty() { + new_path.push(";"); + } + new_path.push(dir.as_os_str()); + } + if let Some(existing) = std::env::var_os("PATH") { + new_path.push(";"); + new_path.push(existing); + } + unsafe { std::env::set_var("PATH", new_path) }; +} + +fn candidate_dirs() -> Vec { + let mut dirs = Vec::new(); + + if let Ok(exe) = std::env::current_exe() { + if let Some(parent) = exe.parent() { + dirs.push(parent.to_path_buf()); + } + } + + for name in ["wslc.exe", "wslc", "wsl.exe", "wsl"] { + if let Some(dir) = which_dir(name) { + dirs.push(dir); + } + } + + for base in [std::env::var_os("ProgramFiles"), std::env::var_os("ProgramW6432")] + .into_iter() + .flatten() + { + let root = PathBuf::from(base).join("WSL"); + dirs.push(root.clone()); + dirs.push(root.join("tools")); + dirs.push(root.join("lib")); + dirs.push(root.join("bin")); + } + + if let Some(sys) = std::env::var_os("SystemRoot") { + dirs.push(PathBuf::from(&sys).join("System32")); + dirs.push(PathBuf::from(sys).join("SysWOW64")); + } + + if let Some(path) = std::env::var_os("PATH") { + for part in std::env::split_paths(&path) { + if !part.as_os_str().is_empty() { + dirs.push(part); + } + } + } + + dirs +} + +fn which_dir(program: &str) -> Option { + let path = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path) { + let candidate = dir.join(program); + if candidate.is_file() { + return Some(dir); + } + if Path::new(program).extension().is_none() { + for ext in [".exe", ".cmd", ".bat"] { + let with_ext = dir.join(format!("{program}{ext}")); + if with_ext.is_file() { + return Some(dir); + } + } + } + } + None +} diff --git a/src/wslc/types.rs b/src/wslc/types.rs new file mode 100644 index 0000000..c843bc6 --- /dev/null +++ b/src/wslc/types.rs @@ -0,0 +1,137 @@ +//! Public data types for the WSLc client API. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] // `Live` is constructed on Windows SDK builds. +pub enum BackendKind { + Live, + Unavailable, +} + +#[derive(Debug, Clone)] +pub struct EngineStatus { + pub backend: BackendKind, + /// Engine label shown in the status bar (`wslcsdk` when live). + pub binary: String, + pub version: String, + pub message: String, + /// Session CPU count requested at start. + pub cpu_count: u32, + /// Session memory (MiB) requested at start. + pub memory_mb: u32, +} + +impl EngineStatus { + pub fn unavailable(message: impl Into) -> Self { + Self { + backend: BackendKind::Unavailable, + binary: "—".into(), + version: String::new(), + message: message.into(), + cpu_count: 0, + memory_mb: 0, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContainerInfo { + pub id: String, + pub name: String, + pub image: String, + pub status: String, + pub ports: String, + pub volumes: String, + pub auto_remove: bool, + pub created: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PortMapping { + pub host: u16, + pub container: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VolumeMapping { + /// Host path (bind) or session volume name (named). + pub host: String, + pub container: String, + pub read_only: bool, + /// When true, `host` is a session VHD volume name. + pub named: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateContainerRequest { + pub image: String, + pub name: Option, + pub ports: Vec, + pub volumes: Vec, + pub auto_remove: bool, + /// Extra init command line (quoted tokens OK). Empty → image ENTRYPOINT/CMD. + pub command: Option, +} + +impl ContainerInfo { + pub fn is_running(&self) -> bool { + let s = self.status.to_ascii_lowercase(); + s.contains("up") || s.contains("running") + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImageInfo { + pub repository: String, + pub tag: String, + pub id: String, + pub size: String, + pub created: String, +} + +impl ImageInfo { + pub fn reference(&self) -> String { + if self.tag.is_empty() || self.tag == "" { + self.repository.clone() + } else { + format!("{}:{}", self.repository, self.tag) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct VolumeInfo { + pub name: String, + pub driver: String, + pub mountpoint: String, + pub created: String, + pub size: String, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] // Variants are produced by the Windows live worker. +pub enum PullEvent { + Status(String), + Progress(f32), + Log(String), + Finished(Result<(), String>), +} + +/// Progress / log stream while installing or connecting the SDK. +pub enum SdkSetupEvent { + Status(String), + Progress(f32), + Log(String), + 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/vendor/wslc-rs/.github/workflows/ci.yml b/vendor/wslc-rs/.github/workflows/ci.yml new file mode 100644 index 0000000..c075e95 --- /dev/null +++ b/vendor/wslc-rs/.github/workflows/ci.yml @@ -0,0 +1,72 @@ +name: CI + +on: + pull_request: + paths: + - ".github/workflows/ci.yml" + - "Cargo.toml" + - "Cargo.lock" + - "crates/**" + - "fuzz/**" + push: + branches: + - master + paths: + - ".github/workflows/ci.yml" + - "Cargo.toml" + - "Cargo.lock" + - "crates/**" + - "fuzz/**" + +jobs: + fmt: + name: Format + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Check formatting + run: cargo fmt --all -- --check + + build: + name: Build + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Build workspace + run: cargo build --workspace --all-targets + + - name: Run tests + run: cargo test --workspace + + clippy: + name: Clippy + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Run clippy + run: cargo clippy --workspace --all-targets -- -D warnings diff --git a/vendor/wslc-rs/.github/workflows/wslc-network-e2e.yml b/vendor/wslc-rs/.github/workflows/wslc-network-e2e.yml new file mode 100644 index 0000000..839587d --- /dev/null +++ b/vendor/wslc-rs/.github/workflows/wslc-network-e2e.yml @@ -0,0 +1,183 @@ +name: WSLC network E2E + +on: + pull_request: + paths: + - ".github/workflows/wslc-network-e2e.yml" + - "crates/**" + - "Cargo.toml" + - "Cargo.lock" + push: + branches: + - master + paths: + - ".github/workflows/wslc-network-e2e.yml" + - "crates/**" + - "Cargo.toml" + - "Cargo.lock" + workflow_dispatch: + inputs: + runner_labels: + description: 'JSON runner labels, for example ["self-hosted","Windows","X64"] or ["windows-latest"]' + required: false + default: '["windows-latest"]' + package_version: + description: "Microsoft.WSL.Containers NuGet package version" + required: false + default: "2.9.3" + registry_mirror: + description: "Optional Docker Hub registry mirror host used by the real pull/container E2E" + required: false + default: "" + +jobs: + hosted-preflight: + name: Hosted Windows preflight + if: github.event_name != 'workflow_dispatch' + runs-on: windows-latest + timeout-minutes: 20 + env: + WSLC_SDK_PACKAGE_VERSION: "2.9.3" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Install Microsoft WSL Containers SDK + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $version = "$env:WSLC_SDK_PACKAGE_VERSION" + $sdkRoot = Join-Path $env:RUNNER_TEMP "wslc-sdk" + New-Item -ItemType Directory -Force -Path $sdkRoot | Out-Null + nuget install Microsoft.WSL.Containers -Version $version -OutputDirectory $sdkRoot -DirectDownload -NonInteractive + $packageDir = Join-Path $sdkRoot "Microsoft.WSL.Containers.$version" + $nativeDir = Join-Path $packageDir "runtimes\win-x64\native" + if (-not (Test-Path (Join-Path $nativeDir "wslcsdk.dll"))) { + throw "wslcsdk.dll was not found under $nativeDir" + } + "WSLC_NATIVE_DIR=$nativeDir" >> $env:GITHUB_ENV + "$nativeDir" >> $env:GITHUB_PATH + + - name: Probe host registry access + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + try { + Invoke-WebRequest -Uri "https://registry-1.docker.io/v2/" -Method Head -TimeoutSec 30 | Out-Null + } catch { + $response = $_.Exception.Response + if ($null -eq $response -or [int]$response.StatusCode -ne 401) { + throw + } + Write-Host "Docker Hub registry probe returned 401, which confirms network reachability." + } + + - name: Show WSLC prerequisites + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + Write-Host "WSLC native dir: $env:WSLC_NATIVE_DIR" + if (Get-Command wslc -ErrorAction SilentlyContinue) { + wslc version + } else { + Write-Host "wslc.exe is not on PATH; Rust integration tests will load wslcsdk.dll directly." + } + if (Get-Command wsl -ErrorAction SilentlyContinue) { + wsl --status + } else { + Write-Host "wsl.exe is not available on this runner." + } + + - name: Compile integration tests + shell: pwsh + run: cargo test -p wslc --features integration --test integration_smoke --no-run + + - name: Run host-safe validation test + shell: pwsh + run: cargo test -p wslc --features integration --test integration_smoke rust_side_validation_runs_before_wslc_calls + + - name: Explain hosted runner limitation + shell: pwsh + run: | + Write-Host "GitHub-hosted Windows runners can install the preview WSLC SDK, but they may not have the WSL package required by the real WSLC runtime." + Write-Host "Run this workflow manually on a self-hosted Windows runner with WSL + WSLC installed to execute the full Alpine pull/container E2E." + + alpine-echo-manual: + name: Pull Alpine and run echo + if: github.event_name == 'workflow_dispatch' + runs-on: ${{ fromJSON(inputs.runner_labels) }} + timeout-minutes: 45 + env: + WSLC_SDK_PACKAGE_VERSION: ${{ inputs.package_version }} + WSLC_REGISTRY_MIRROR: ${{ inputs.registry_mirror }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Install Microsoft WSL Containers SDK + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $version = "$env:WSLC_SDK_PACKAGE_VERSION" + $sdkRoot = Join-Path $env:RUNNER_TEMP "wslc-sdk" + New-Item -ItemType Directory -Force -Path $sdkRoot | Out-Null + nuget install Microsoft.WSL.Containers -Version $version -OutputDirectory $sdkRoot -DirectDownload -NonInteractive + $packageDir = Join-Path $sdkRoot "Microsoft.WSL.Containers.$version" + $nativeDir = Join-Path $packageDir "runtimes\win-x64\native" + if (-not (Test-Path (Join-Path $nativeDir "wslcsdk.dll"))) { + throw "wslcsdk.dll was not found under $nativeDir" + } + "WSLC_NATIVE_DIR=$nativeDir" >> $env:GITHUB_ENV + "$nativeDir" >> $env:GITHUB_PATH + + - name: Probe host registry access + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + try { + Invoke-WebRequest -Uri "https://registry-1.docker.io/v2/" -Method Head -TimeoutSec 30 | Out-Null + } catch { + $response = $_.Exception.Response + if ($null -eq $response -or [int]$response.StatusCode -ne 401) { + throw + } + Write-Host "Docker Hub registry probe returned 401, which confirms network reachability." + } + + - name: Show WSLC prerequisites + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + Write-Host "WSLC native dir: $env:WSLC_NATIVE_DIR" + if (Get-Command wslc -ErrorAction SilentlyContinue) { + wslc version + } else { + Write-Host "wslc.exe is not on PATH; Rust integration tests will load wslcsdk.dll directly." + } + if (Get-Command wsl -ErrorAction SilentlyContinue) { + wsl --status + } else { + Write-Host "wsl.exe is not available on this runner." + } + + - name: Run default WSLC integration tests + shell: pwsh + run: cargo test -p wslc --features integration --test integration_smoke + + - name: Run Docker Hub Alpine E2E + shell: pwsh + run: cargo test -p wslc --features integration --test integration_smoke -- --ignored --nocapture diff --git a/vendor/wslc-rs/.gitignore b/vendor/wslc-rs/.gitignore new file mode 100644 index 0000000..655c7f1 --- /dev/null +++ b/vendor/wslc-rs/.gitignore @@ -0,0 +1,9 @@ +/target/ +/.cargo-home-temp/ +/.cargo-home-publish/ +/.wslc-sdk/ +/.worktrees/ +**/*.rs.bk +*.pdb +*.ilk +*.exe diff --git a/vendor/wslc-rs/Cargo.lock b/vendor/wslc-rs/Cargo.lock new file mode 100644 index 0000000..689abeb --- /dev/null +++ b/vendor/wslc-rs/Cargo.lock @@ -0,0 +1,14 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "wslc" +version = "0.1.9" +dependencies = [ + "wslc-sys", +] + +[[package]] +name = "wslc-sys" +version = "0.1.2" diff --git a/vendor/wslc-rs/Cargo.toml b/vendor/wslc-rs/Cargo.toml new file mode 100644 index 0000000..e62d654 --- /dev/null +++ b/vendor/wslc-rs/Cargo.toml @@ -0,0 +1,21 @@ +[workspace] +members = [ + "crates/wslc", + "crates/wslc-sys", +] +exclude = ["fuzz"] +resolver = "2" + +[workspace.package] +version = "0.1.2" +edition = "2021" +license = "MIT OR Apache-2.0" +repository = "https://github.com/Ivanbeethoven/wslc-rs" +homepage = "https://github.com/Ivanbeethoven/wslc-rs" +readme = "README.md" +rust-version = "1.80" +keywords = ["wsl", "containers", "windows", "ffi"] +categories = ["api-bindings", "os::windows-apis"] + +[workspace.dependencies] +wslc-sys = { version = "0.1.2", path = "crates/wslc-sys" } diff --git a/vendor/wslc-rs/LICENSE-APACHE b/vendor/wslc-rs/LICENSE-APACHE new file mode 100644 index 0000000..0dc0e54 --- /dev/null +++ b/vendor/wslc-rs/LICENSE-APACHE @@ -0,0 +1,13 @@ +Copyright 2026 wslc-rs contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/vendor/wslc-rs/LICENSE-MIT b/vendor/wslc-rs/LICENSE-MIT new file mode 100644 index 0000000..78d2356 --- /dev/null +++ b/vendor/wslc-rs/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 wslc-rs contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/wslc-rs/README.md b/vendor/wslc-rs/README.md new file mode 100644 index 0000000..f02c086 --- /dev/null +++ b/vendor/wslc-rs/README.md @@ -0,0 +1,7 @@ +# Vendored `wslc` / `wslc-sys` + +Snapshot of [timeroute/wslc-rs](https://github.com/timeroute/wslc-rs) (OpenContainer + logs wrappers) +plus a local patch that exposes `ContainerBuilder::named_volume` / +`named_volume_read_only` via `WslcSetContainerSettingsNamedVolumes`. + +Replace with a crates.io release when upstream merges and publishes. diff --git a/vendor/wslc-rs/crates/wslc-sys/Cargo.toml b/vendor/wslc-rs/crates/wslc-sys/Cargo.toml new file mode 100644 index 0000000..79c1bdb --- /dev/null +++ b/vendor/wslc-rs/crates/wslc-sys/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "wslc-sys" +version = "0.1.2" +edition = "2021" +license = "MIT OR Apache-2.0" +description = "Raw Rust FFI types for Microsoft WSL Container SDK" +readme = "README.md" +exclude = ["tests/*"] + +[lib] +name = "wslc_sys" +path = "src/lib.rs" + +[features] +default = [] diff --git a/vendor/wslc-rs/crates/wslc-sys/README.md b/vendor/wslc-rs/crates/wslc-sys/README.md new file mode 100644 index 0000000..7b27d30 --- /dev/null +++ b/vendor/wslc-rs/crates/wslc-sys/README.md @@ -0,0 +1,20 @@ +# wslc-sys + +Raw Rust ABI definitions and runtime FFI boundary helpers for the preview +Microsoft WSL Container SDK (`wslcsdk.h` / `wslcsdk.dll`). + +Policy: + +- ABI types, constants, callback signatures, and function pointer signatures. +- Runtime loading for `wslcsdk.dll` plus checked wrappers around raw SDK calls. +- Callback trampolines, COM/CoTaskMem helpers, and Windows wait helpers stay in + this crate so the safe `wslc` crate does not contain `unsafe` blocks. +- No SDK binary redistribution. +- Safe users should depend on `wslc`. + +This crate includes layout tests for the opaque settings blobs and image info +struct, plus the low-level runtime boundary used by `wslc`. + +It also mirrors `WSLCLogsFlags` bit values from the WSLC shared IDL so safe +wrappers can share the same flag constants even though the public C header does +not yet export a logs entry point. diff --git a/vendor/wslc-rs/crates/wslc-sys/src/lib.rs b/vendor/wslc-rs/crates/wslc-sys/src/lib.rs new file mode 100644 index 0000000..3a1f7bd --- /dev/null +++ b/vendor/wslc-rs/crates/wslc-sys/src/lib.rs @@ -0,0 +1,923 @@ +//! Raw Rust ABI definitions for Microsoft WSL Container SDK. +//! +//! This crate mirrors the public `wslcsdk.h` surface closely, but it does not +//! link to or load `wslcsdk.dll`. Safe wrappers can use these definitions with +//! either static linking or runtime loading. + +#![deny(unsafe_op_in_unsafe_fn)] +#![deny(missing_docs)] +#![allow(non_camel_case_types)] +#![allow(non_snake_case)] + +pub mod runtime; + +use core::ffi::{c_char, c_void}; + +/// HRESULT-compatible return value. +pub type HRESULT = i32; +/// Windows BOOL-compatible value. +pub type BOOL = i32; +/// Windows HANDLE-compatible value. +pub type HANDLE = *mut c_void; +/// UTF-16 string pointer. +pub type PCWSTR = *const u16; +/// UTF-16 mutable string pointer. +pub type PWSTR = *mut u16; +/// ANSI/UTF-8 string pointer used by the WSLC SDK. +pub type PCSTR = *const c_char; +/// ANSI/UTF-8 mutable string pointer used by the WSLC SDK. +pub type PSTR = *mut c_char; +/// Generic SDK callback context pointer. +pub type PVOID = *mut c_void; + +/// `S_OK`. +pub const S_OK: HRESULT = 0; + +/// Base offset used by WSLC-specific HRESULT values. +pub const WSLC_E_BASE: u32 = 0x0600; +/// Image not found. +pub const WSLC_E_IMAGE_NOT_FOUND: u32 = 0x8004_0601; +/// Container prefix ambiguous. +pub const WSLC_E_CONTAINER_PREFIX_AMBIGUOUS: u32 = 0x8004_0602; +/// Container not found. +pub const WSLC_E_CONTAINER_NOT_FOUND: u32 = 0x8004_0603; +/// Volume not found. +pub const WSLC_E_VOLUME_NOT_FOUND: u32 = 0x8004_0604; +/// Container not running. +pub const WSLC_E_CONTAINER_NOT_RUNNING: u32 = 0x8004_0605; +/// Container is already running. +pub const WSLC_E_CONTAINER_IS_RUNNING: u32 = 0x8004_0606; +/// Session name is reserved. +pub const WSLC_E_SESSION_RESERVED: u32 = 0x8004_0607; +/// Session name is invalid. +pub const WSLC_E_INVALID_SESSION_NAME: u32 = 0x8004_0608; +/// Network not found. +pub const WSLC_E_NETWORK_NOT_FOUND: u32 = 0x8004_0609; +/// Windows Update search failed. +pub const WSLC_E_WU_SEARCH_FAILED: u32 = 0x8004_060A; +/// SDK update is needed. +pub const WSLC_E_SDK_UPDATE_NEEDED: u32 = 0x8004_060B; +/// Container support is disabled. +pub const WSLC_E_CONTAINER_DISABLED: u32 = 0x8004_060C; +/// Registry is blocked by policy. +pub const WSLC_E_REGISTRY_BLOCKED_BY_POLICY: u32 = 0x8004_060D; +/// Volume is not available. +pub const WSLC_E_VOLUME_NOT_AVAILABLE: u32 = 0x8004_060E; +/// Session not found. +pub const WSLC_E_SESSION_NOT_FOUND: u32 = 0x8004_060F; + +/// `WslcSessionSettings` size from `wslcsdk.h`. +pub const WSLC_SESSION_OPTIONS_SIZE: usize = 72; +/// `WslcSessionSettings` alignment from `wslcsdk.h`. +pub const WSLC_SESSION_OPTIONS_ALIGNMENT: usize = 8; +/// `WslcContainerSettings` size from `wslcsdk.h`. +pub const WSLC_CONTAINER_OPTIONS_SIZE: usize = 104; +/// `WslcContainerSettings` alignment from `wslcsdk.h`. +pub const WSLC_CONTAINER_OPTIONS_ALIGNMENT: usize = 8; +/// `WslcProcessSettings` size from `wslcsdk.h`. +pub const WSLC_CONTAINER_PROCESS_OPTIONS_SIZE: usize = 72; +/// `WslcProcessSettings` alignment from `wslcsdk.h`. +pub const WSLC_CONTAINER_PROCESS_OPTIONS_ALIGNMENT: usize = 8; +/// Container ID buffer size: 64 hex chars plus NUL terminator. +pub const WSLC_CONTAINER_ID_BUFFER_SIZE: usize = 65; +/// Image name buffer size. +pub const WSLC_IMAGE_NAME_LENGTH: usize = 256; + +/// Opaque session settings blob. +#[repr(C, align(8))] +#[derive(Clone, Copy)] +pub struct WslcSessionSettings { + /// Opaque SDK-owned settings bytes. + pub _opaque: [u8; WSLC_SESSION_OPTIONS_SIZE], +} + +impl Default for WslcSessionSettings { + fn default() -> Self { + Self { + _opaque: [0; WSLC_SESSION_OPTIONS_SIZE], + } + } +} + +/// Opaque container settings blob. +#[repr(C, align(8))] +#[derive(Clone, Copy)] +pub struct WslcContainerSettings { + /// Opaque SDK-owned settings bytes. + pub _opaque: [u8; WSLC_CONTAINER_OPTIONS_SIZE], +} + +impl Default for WslcContainerSettings { + fn default() -> Self { + Self { + _opaque: [0; WSLC_CONTAINER_OPTIONS_SIZE], + } + } +} + +/// Opaque process settings blob. +#[repr(C, align(8))] +#[derive(Clone, Copy)] +pub struct WslcProcessSettings { + /// Opaque SDK-owned settings bytes. + pub _opaque: [u8; WSLC_CONTAINER_PROCESS_OPTIONS_SIZE], +} + +impl Default for WslcProcessSettings { + fn default() -> Self { + Self { + _opaque: [0; WSLC_CONTAINER_PROCESS_OPTIONS_SIZE], + } + } +} + +/// Raw WSLC session handle. +pub type WslcSession = *mut c_void; +/// Raw WSLC container handle. +pub type WslcContainer = *mut c_void; +/// Raw WSLC process handle. +pub type WslcProcess = *mut c_void; +/// Raw WSLC crash dump subscription handle. +pub type WslcCrashDumpSubscription = *mut c_void; + +/// Container networking mode. +#[repr(i32)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WslcContainerNetworkingMode { + /// No networking / isolated. + WSLC_CONTAINER_NETWORKING_MODE_NONE = 0, + /// Bridged networking. + WSLC_CONTAINER_NETWORKING_MODE_BRIDGED = 1, +} + +/// VHD volume type. +#[repr(i32)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WslcVhdType { + /// Expanding VHDX. + WSLC_VHD_TYPE_DYNAMIC = 0, + /// Fixed-allocation VHDX. + WSLC_VHD_TYPE_FIXED = 1, +} + +/// VHD requirement flags. +pub type WslcVhdRequirementsFlags = u32; +/// No VHD requirement flags. +pub const WSLC_VHD_REQ_FLAG_NONE: WslcVhdRequirementsFlags = 0; +/// Honor owner UID/GID. +pub const WSLC_VHD_REQ_FLAG_OWNER: WslcVhdRequirementsFlags = 0x0000_0001; + +/// VHD requirements. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct WslcVhdRequirements { + /// Volume name. + pub name: PCSTR, + /// Desired size. + pub sizeBytes: u64, + /// Volume type. + pub type_: WslcVhdType, + /// Flags controlling optional fields. + pub flags: WslcVhdRequirementsFlags, + /// Linux UID. + pub uid: u32, + /// Linux GID. + pub gid: u32, +} + +/// Session feature flags. +pub type WslcSessionFeatureFlags = u32; +/// No session feature flags. +pub const WSLC_SESSION_FEATURE_FLAG_NONE: WslcSessionFeatureFlags = 0; +/// Enable GPU support. +pub const WSLC_SESSION_FEATURE_FLAG_ENABLE_GPU: WslcSessionFeatureFlags = 0x0000_0004; + +/// Session termination reason. +#[repr(i32)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WslcSessionTerminationReason { + /// Unknown reason. + WSLC_SESSION_TERMINATION_REASON_UNKNOWN = 0, + /// Session was shut down. + WSLC_SESSION_TERMINATION_REASON_SHUTDOWN = 1, + /// Session crashed. + WSLC_SESSION_TERMINATION_REASON_CRASHED = 2, +} + +/// Session crash dump information. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct WslcSessionCrashDumpInfo { + /// Dump file path. + pub dumpPath: PCWSTR, + /// Process name. + pub processName: PCSTR, + /// Process ID. + pub pid: u32, + /// Signal value. + pub signal: u32, + /// Timestamp. + pub timestamp: u64, +} + +/// Session crash dump callback. +pub type WslcSessionCrashDumpCallback = + Option; + +/// Port protocol. +#[repr(i32)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WslcPortProtocol { + /// TCP protocol. + WSLC_PORT_PROTOCOL_TCP = 0, + /// UDP protocol. + WSLC_PORT_PROTOCOL_UDP = 1, +} + +/// Large enough storage for IPv4/IPv6 socket addresses. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct sockaddr_storage { + /// Platform socket storage bytes. + pub storage: [u8; 128], +} + +/// Container port mapping. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct WslcContainerPortMapping { + /// Port on Windows host. + pub windowsPort: u16, + /// Port inside the container. + pub containerPort: u16, + /// Transport protocol. + pub protocol: WslcPortProtocol, + /// Optional Windows bind address. + pub windowsAddress: *mut sockaddr_storage, +} + +/// Container bind mount volume. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct WslcContainerVolume { + /// Windows host path. + pub windowsPath: PCWSTR, + /// Absolute path in the container. + pub containerPath: PCSTR, + /// Non-zero for read-only. + pub readOnly: BOOL, +} + +/// Named VHD volume mount. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct WslcContainerNamedVolume { + /// Session volume name. + pub name: PCSTR, + /// Absolute path in the container. + pub containerPath: PCSTR, + /// Non-zero for read-only. + pub readOnly: BOOL, +} + +/// Container flags. +pub type WslcContainerFlags = u32; +/// No container flags. +pub const WSLC_CONTAINER_FLAG_NONE: WslcContainerFlags = 0; +/// Auto-remove the container. +pub const WSLC_CONTAINER_FLAG_AUTO_REMOVE: WslcContainerFlags = 0x0000_0001; +/// Enable GPU for the container. +pub const WSLC_CONTAINER_FLAG_ENABLE_GPU: WslcContainerFlags = 0x0000_0002; +/// Run the container as privileged. +pub const WSLC_CONTAINER_FLAG_PRIVILEGED: WslcContainerFlags = 0x0000_0004; + +/// Container start flags. +pub type WslcContainerStartFlags = u32; +/// No container start flags. +pub const WSLC_CONTAINER_START_FLAG_NONE: WslcContainerStartFlags = 0; +/// Attach to the init process. +pub const WSLC_CONTAINER_START_FLAG_ATTACH: WslcContainerStartFlags = 0x0000_0001; + +/// Container logs flags from the WSLC shared IDL (`WSLCLogsFlags`). +/// +/// The public C SDK header does not yet expose a logs entry point; these flags +/// mirror the service/CLI surface so safe wrappers can share the same bit values. +pub type WslcLogsFlags = u32; +/// No logs flags. +pub const WSLC_LOGS_FLAG_NONE: WslcLogsFlags = 0; +/// Follow log output. +pub const WSLC_LOGS_FLAG_FOLLOW: WslcLogsFlags = 0x0000_0001; +/// Include timestamps in log output. +pub const WSLC_LOGS_FLAG_TIMESTAMPS: WslcLogsFlags = 0x0000_0002; +/// Include extra log details. +pub const WSLC_LOGS_FLAG_DETAILS: WslcLogsFlags = 0x0000_0004; +/// Valid combination mask for logs flags. +pub const WSLC_LOGS_FLAGS_VALID: WslcLogsFlags = + WSLC_LOGS_FLAG_FOLLOW | WSLC_LOGS_FLAG_TIMESTAMPS | WSLC_LOGS_FLAG_DETAILS; + +/// Container state. +#[repr(i32)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WslcContainerState { + /// Invalid state. + WSLC_CONTAINER_STATE_INVALID = 0, + /// Created. + WSLC_CONTAINER_STATE_CREATED = 1, + /// Running. + WSLC_CONTAINER_STATE_RUNNING = 2, + /// Exited. + WSLC_CONTAINER_STATE_EXITED = 3, + /// Deleted. + WSLC_CONTAINER_STATE_DELETED = 4, +} + +/// Linux signal values supported by WSLC. +#[repr(i32)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WslcSignal { + /// No signal. + WSLC_SIGNAL_NONE = 0, + /// SIGHUP. + WSLC_SIGNAL_SIGHUP = 1, + /// SIGINT. + WSLC_SIGNAL_SIGINT = 2, + /// SIGQUIT. + WSLC_SIGNAL_SIGQUIT = 3, + /// SIGKILL. + WSLC_SIGNAL_SIGKILL = 9, + /// SIGTERM. + WSLC_SIGNAL_SIGTERM = 15, +} + +/// Delete container flags. +pub type WslcDeleteContainerFlags = u32; +/// No delete flags. +pub const WSLC_DELETE_CONTAINER_FLAG_NONE: WslcDeleteContainerFlags = 0; +/// Force deletion. +pub const WSLC_DELETE_CONTAINER_FLAG_FORCE: WslcDeleteContainerFlags = 0x01; + +/// Process IO handle. +#[repr(i32)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WslcProcessIOHandle { + /// Standard input. + WSLC_PROCESS_IO_HANDLE_STDIN = 0, + /// Standard output. + WSLC_PROCESS_IO_HANDLE_STDOUT = 1, + /// Standard error. + WSLC_PROCESS_IO_HANDLE_STDERR = 2, +} + +/// STDIO callback. +pub type WslcStdIOCallback = Option< + unsafe extern "system" fn( + ioHandle: WslcProcessIOHandle, + data: *const u8, + dataBytes: u32, + context: PVOID, + ), +>; + +/// Process exit callback. +pub type WslcProcessExitCallback = Option; + +/// Process callback set. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct WslcProcessCallbacks { + /// Standard output callback. + pub onStdOut: WslcStdIOCallback, + /// Standard error callback. + pub onStdErr: WslcStdIOCallback, + /// Process exit callback. + pub onExit: WslcProcessExitCallback, +} + +/// Process state. +#[repr(i32)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WslcProcessState { + /// Unknown. + WSLC_PROCESS_STATE_UNKNOWN = 0, + /// Running. + WSLC_PROCESS_STATE_RUNNING = 1, + /// Exited. + WSLC_PROCESS_STATE_EXITED = 2, + /// Signalled. + WSLC_PROCESS_STATE_SIGNALLED = 3, +} + +/// Image progress detail. +#[repr(C)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WslcImageProgressDetail { + /// Downloaded bytes. + pub currentBytes: u64, + /// Expected bytes. + pub totalBytes: u64, +} + +/// Image progress status. +#[repr(i32)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WslcImageProgressStatus { + /// Unknown. + WSLC_IMAGE_PROGRESS_STATUS_UNKNOWN = 0, + /// Pulling. + WSLC_IMAGE_PROGRESS_STATUS_PULLING = 1, + /// Waiting. + WSLC_IMAGE_PROGRESS_STATUS_WAITING = 2, + /// Downloading. + WSLC_IMAGE_PROGRESS_STATUS_DOWNLOADING = 3, + /// Verifying. + WSLC_IMAGE_PROGRESS_STATUS_VERIFYING = 4, + /// Extracting. + WSLC_IMAGE_PROGRESS_STATUS_EXTRACTING = 5, + /// Complete. + WSLC_IMAGE_PROGRESS_STATUS_COMPLETE = 6, +} + +/// Image progress message. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct WslcImageProgressMessage { + /// Layer ID or digest. + pub id: PCSTR, + /// Status. + pub status: WslcImageProgressStatus, + /// Detail. + pub detail: WslcImageProgressDetail, +} + +/// Container image progress callback. +pub type WslcContainerImageProgressCallback = Option< + unsafe extern "system" fn(progress: *const WslcImageProgressMessage, context: PVOID) -> HRESULT, +>; + +/// Pull image options. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct WslcPullImageOptions { + /// Image URI. + pub uri: PCSTR, + /// Progress callback. + pub progressCallback: WslcContainerImageProgressCallback, + /// Progress callback context. + pub progressCallbackContext: PVOID, + /// Optional registry auth. + pub registryAuth: PCSTR, +} + +/// Import image options. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct WslcImportImageOptions { + /// Progress callback. + pub progressCallback: WslcContainerImageProgressCallback, + /// Progress callback context. + pub progressCallbackContext: PVOID, +} + +/// Load image options. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct WslcLoadImageOptions { + /// Progress callback. + pub progressCallback: WslcContainerImageProgressCallback, + /// Progress callback context. + pub progressCallbackContext: PVOID, +} + +/// Image information. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct WslcImageInfo { + /// Image name buffer. + pub name: [c_char; WSLC_IMAGE_NAME_LENGTH], + /// SHA-256 digest bytes. + pub sha256: [u8; 32], + /// Image size in bytes. + pub sizeBytes: i64, + /// Creation time as Unix timestamp. + pub createdUnixTime: u64, +} + +/// Tag image options. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct WslcTagImageOptions { + /// Source image. + pub image: PCSTR, + /// Target repository. + pub repo: PCSTR, + /// Target tag. + pub tag: PCSTR, +} + +/// Push image options. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct WslcPushImageOptions { + /// Image name. + pub image: PCSTR, + /// Registry auth token. + pub registryAuth: PCSTR, + /// Progress callback. + pub progressCallback: WslcContainerImageProgressCallback, + /// Progress callback context. + pub progressCallbackContext: PVOID, +} + +/// Component flags. +pub type WslcComponentFlags = u32; +/// No missing components. +pub const WSLC_COMPONENT_FLAG_NONE: WslcComponentFlags = 0; +/// Virtual Machine Platform optional feature is missing. +pub const WSLC_COMPONENT_FLAG_VIRTUAL_MACHINE_PLATFORM: WslcComponentFlags = 1; +/// WSL runtime package is missing. +pub const WSLC_COMPONENT_FLAG_WSL_PACKAGE: WslcComponentFlags = 2; +/// WSLC SDK needs an update. +pub const WSLC_COMPONENT_FLAG_SDK_NEEDS_UPDATE: WslcComponentFlags = 4; + +/// WSLC version. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct WslcVersion { + /// Major version. + pub major: u32, + /// Minor version. + pub minor: u32, + /// Revision version. + pub revision: u32, +} + +/// Install progress callback. +pub type WslcInstallCallback = Option< + unsafe extern "system" fn( + component: WslcComponentFlags, + progressSteps: u32, + totalSteps: u32, + context: PVOID, + ), +>; + +/// `WslcInitSessionSettings`. +pub type WslcInitSessionSettingsFn = unsafe extern "system" fn( + name: PCWSTR, + storagePath: PCWSTR, + sessionSettings: *mut WslcSessionSettings, +) -> HRESULT; +/// `WslcCreateSession`. +pub type WslcCreateSessionFn = unsafe extern "system" fn( + sessionSettings: *mut WslcSessionSettings, + session: *mut WslcSession, + errorMessage: *mut PWSTR, +) -> HRESULT; +/// `WslcSetSessionSettingsCpuCount`. +pub type WslcSetSessionSettingsCpuCountFn = + unsafe extern "system" fn(sessionSettings: *mut WslcSessionSettings, cpuCount: u32) -> HRESULT; +/// `WslcSetSessionSettingsMemory`. +pub type WslcSetSessionSettingsMemoryFn = + unsafe extern "system" fn(sessionSettings: *mut WslcSessionSettings, memoryMB: u32) -> HRESULT; +/// `WslcSetSessionSettingsTimeout`. +pub type WslcSetSessionSettingsTimeoutFn = + unsafe extern "system" fn(sessionSettings: *mut WslcSessionSettings, timeoutMS: u32) -> HRESULT; +/// `WslcSetSessionSettingsVhd`. +pub type WslcSetSessionSettingsVhdFn = unsafe extern "system" fn( + sessionSettings: *mut WslcSessionSettings, + vhdRequirements: *const WslcVhdRequirements, +) -> HRESULT; +/// `WslcSetSessionSettingsFeatureFlags`. +pub type WslcSetSessionSettingsFeatureFlagsFn = unsafe extern "system" fn( + sessionSettings: *mut WslcSessionSettings, + flags: WslcSessionFeatureFlags, +) -> HRESULT; +/// `WslcGetSessionTerminationEvent`. +pub type WslcGetSessionTerminationEventFn = + unsafe extern "system" fn(session: WslcSession, terminationEvent: *mut HANDLE) -> HRESULT; +/// `WslcGetSessionTerminationReason`. +pub type WslcGetSessionTerminationReasonFn = unsafe extern "system" fn( + session: WslcSession, + reason: *mut WslcSessionTerminationReason, +) -> HRESULT; +/// `WslcTerminateSession`. +pub type WslcTerminateSessionFn = unsafe extern "system" fn(session: WslcSession) -> HRESULT; +/// `WslcReleaseSession`. +pub type WslcReleaseSessionFn = unsafe extern "system" fn(session: WslcSession) -> HRESULT; +/// `WslcRegisterSessionCrashDumpCallback`. +pub type WslcRegisterSessionCrashDumpCallbackFn = unsafe extern "system" fn( + session: WslcSession, + crashDumpCallback: WslcSessionCrashDumpCallback, + crashDumpContext: PVOID, + subscription: *mut WslcCrashDumpSubscription, + errorMessage: *mut PWSTR, +) -> HRESULT; +/// `WslcReleaseCrashDumpSubscription`. +pub type WslcReleaseCrashDumpSubscriptionFn = + unsafe extern "system" fn(subscription: WslcCrashDumpSubscription) -> HRESULT; + +/// `WslcInitContainerSettings`. +pub type WslcInitContainerSettingsFn = unsafe extern "system" fn( + imageName: PCSTR, + containerSettings: *mut WslcContainerSettings, +) -> HRESULT; +/// `WslcCreateContainer`. +pub type WslcCreateContainerFn = unsafe extern "system" fn( + session: WslcSession, + containerSettings: *const WslcContainerSettings, + container: *mut WslcContainer, + errorMessage: *mut PWSTR, +) -> HRESULT; +/// `WslcOpenContainer`. +pub type WslcOpenContainerFn = unsafe extern "system" fn( + session: WslcSession, + nameOrId: PCSTR, + container: *mut WslcContainer, + errorMessage: *mut PWSTR, +) -> HRESULT; +/// `WslcStartContainer`. +pub type WslcStartContainerFn = unsafe extern "system" fn( + container: WslcContainer, + flags: WslcContainerStartFlags, + errorMessage: *mut PWSTR, +) -> HRESULT; +/// `WslcSetContainerSettingsName`. +pub type WslcSetContainerSettingsNameFn = unsafe extern "system" fn( + containerSettings: *mut WslcContainerSettings, + name: PCSTR, +) -> HRESULT; +/// `WslcSetContainerSettingsInitProcess`. +pub type WslcSetContainerSettingsInitProcessFn = unsafe extern "system" fn( + containerSettings: *mut WslcContainerSettings, + initProcess: *mut WslcProcessSettings, +) -> HRESULT; +/// `WslcSetContainerSettingsNetworkingMode`. +pub type WslcSetContainerSettingsNetworkingModeFn = unsafe extern "system" fn( + containerSettings: *mut WslcContainerSettings, + networkingMode: WslcContainerNetworkingMode, +) -> HRESULT; +/// `WslcSetContainerSettingsHostName`. +pub type WslcSetContainerSettingsHostNameFn = unsafe extern "system" fn( + containerSettings: *mut WslcContainerSettings, + hostName: PCSTR, +) -> HRESULT; +/// `WslcSetContainerSettingsDomainName`. +pub type WslcSetContainerSettingsDomainNameFn = unsafe extern "system" fn( + containerSettings: *mut WslcContainerSettings, + domainName: PCSTR, +) -> HRESULT; +/// `WslcSetContainerSettingsFlags`. +pub type WslcSetContainerSettingsFlagsFn = unsafe extern "system" fn( + containerSettings: *mut WslcContainerSettings, + flags: WslcContainerFlags, +) -> HRESULT; +/// `WslcSetContainerSettingsPortMappings`. +pub type WslcSetContainerSettingsPortMappingsFn = unsafe extern "system" fn( + containerSettings: *mut WslcContainerSettings, + portMappings: *const WslcContainerPortMapping, + portMappingCount: u32, +) -> HRESULT; +/// `WslcSetContainerSettingsVolumes`. +pub type WslcSetContainerSettingsVolumesFn = unsafe extern "system" fn( + containerSettings: *mut WslcContainerSettings, + volumes: *const WslcContainerVolume, + volumeCount: u32, +) -> HRESULT; +/// `WslcSetContainerSettingsNamedVolumes`. +pub type WslcSetContainerSettingsNamedVolumesFn = unsafe extern "system" fn( + containerSettings: *mut WslcContainerSettings, + namedVolumes: *const WslcContainerNamedVolume, + namedVolumeCount: u32, +) -> HRESULT; +/// `WslcCreateContainerProcess`. +pub type WslcCreateContainerProcessFn = unsafe extern "system" fn( + container: WslcContainer, + newProcessSettings: *mut WslcProcessSettings, + newProcess: *mut WslcProcess, + errorMessage: *mut PWSTR, +) -> HRESULT; +/// `WslcReleaseContainer`. +pub type WslcReleaseContainerFn = unsafe extern "system" fn(container: WslcContainer) -> HRESULT; +/// `WslcGetContainerID`. +pub type WslcGetContainerIDFn = + unsafe extern "system" fn(container: WslcContainer, containerID: *mut c_char) -> HRESULT; +/// `WslcGetContainerInitProcess`. +pub type WslcGetContainerInitProcessFn = + unsafe extern "system" fn(container: WslcContainer, initProcess: *mut WslcProcess) -> HRESULT; +/// `WslcInspectContainer`. +pub type WslcInspectContainerFn = + unsafe extern "system" fn(container: WslcContainer, inspectData: *mut PSTR) -> HRESULT; +/// `WslcGetContainerState`. +pub type WslcGetContainerStateFn = + unsafe extern "system" fn(container: WslcContainer, state: *mut WslcContainerState) -> HRESULT; +/// `WslcStopContainer`. +pub type WslcStopContainerFn = unsafe extern "system" fn( + container: WslcContainer, + signal: WslcSignal, + timeoutSeconds: u32, + errorMessage: *mut PWSTR, +) -> HRESULT; +/// `WslcDeleteContainer`. +pub type WslcDeleteContainerFn = unsafe extern "system" fn( + container: WslcContainer, + flags: WslcDeleteContainerFlags, + errorMessage: *mut PWSTR, +) -> HRESULT; + +/// `WslcInitProcessSettings`. +pub type WslcInitProcessSettingsFn = + unsafe extern "system" fn(processSettings: *mut WslcProcessSettings) -> HRESULT; +/// `WslcSetProcessSettingsWorkingDirectory`. +pub type WslcSetProcessSettingsWorkingDirectoryFn = unsafe extern "system" fn( + processSettings: *mut WslcProcessSettings, + workingDirectory: PCSTR, +) -> HRESULT; +/// `WslcSetProcessSettingsCmdLine`. +pub type WslcSetProcessSettingsCmdLineFn = unsafe extern "system" fn( + processSettings: *mut WslcProcessSettings, + argv: *const PCSTR, + argc: usize, +) -> HRESULT; +/// `WslcSetProcessSettingsEnvVariables`. +pub type WslcSetProcessSettingsEnvVariablesFn = unsafe extern "system" fn( + processSettings: *mut WslcProcessSettings, + key_value: *const PCSTR, + argc: usize, +) -> HRESULT; +/// `WslcSetProcessSettingsCallbacks`. +pub type WslcSetProcessSettingsCallbacksFn = unsafe extern "system" fn( + processSettings: *mut WslcProcessSettings, + callbacks: *const WslcProcessCallbacks, + context: PVOID, +) -> HRESULT; +/// `WslcGetProcessPid`. +pub type WslcGetProcessPidFn = + unsafe extern "system" fn(process: WslcProcess, pid: *mut u32) -> HRESULT; +/// `WslcGetProcessExitEvent`. +pub type WslcGetProcessExitEventFn = + unsafe extern "system" fn(process: WslcProcess, exitEvent: *mut HANDLE) -> HRESULT; +/// `WslcGetProcessState`. +pub type WslcGetProcessStateFn = + unsafe extern "system" fn(process: WslcProcess, state: *mut WslcProcessState) -> HRESULT; +/// `WslcGetProcessExitCode`. +pub type WslcGetProcessExitCodeFn = + unsafe extern "system" fn(process: WslcProcess, exitCode: *mut i32) -> HRESULT; +/// `WslcSignalProcess`. +pub type WslcSignalProcessFn = + unsafe extern "system" fn(process: WslcProcess, signal: WslcSignal) -> HRESULT; +/// `WslcGetProcessIOHandle`. +pub type WslcGetProcessIOHandleFn = unsafe extern "system" fn( + process: WslcProcess, + ioHandle: WslcProcessIOHandle, + handle: *mut HANDLE, +) -> HRESULT; +/// `WslcReleaseProcess`. +pub type WslcReleaseProcessFn = unsafe extern "system" fn(process: WslcProcess) -> HRESULT; + +/// `WslcPullSessionImage`. +pub type WslcPullSessionImageFn = unsafe extern "system" fn( + session: WslcSession, + options: *const WslcPullImageOptions, + errorMessage: *mut PWSTR, +) -> HRESULT; +/// `WslcImportSessionImage`. +pub type WslcImportSessionImageFn = unsafe extern "system" fn( + session: WslcSession, + imageName: PCSTR, + imageContent: HANDLE, + imageContentBytes: u64, + options: *const WslcImportImageOptions, + errorMessage: *mut PWSTR, +) -> HRESULT; +/// `WslcImportSessionImageFromFile`. +pub type WslcImportSessionImageFromFileFn = unsafe extern "system" fn( + session: WslcSession, + imageName: PCSTR, + path: PCWSTR, + options: *const WslcImportImageOptions, + errorMessage: *mut PWSTR, +) -> HRESULT; +/// `WslcLoadSessionImage`. +pub type WslcLoadSessionImageFn = unsafe extern "system" fn( + session: WslcSession, + imageContent: HANDLE, + imageContentBytes: u64, + options: *const WslcLoadImageOptions, +) -> HRESULT; +/// `WslcLoadSessionImageFromFile`. +pub type WslcLoadSessionImageFromFileFn = unsafe extern "system" fn( + session: WslcSession, + path: PCWSTR, + options: *const WslcLoadImageOptions, +) -> HRESULT; +/// `WslcDeleteSessionImage`. +pub type WslcDeleteSessionImageFn = unsafe extern "system" fn( + session: WslcSession, + nameOrID: PCSTR, + errorMessage: *mut PWSTR, +) -> HRESULT; +/// `WslcTagSessionImage`. +pub type WslcTagSessionImageFn = unsafe extern "system" fn( + session: WslcSession, + options: *const WslcTagImageOptions, + errorMessage: *mut PWSTR, +) -> HRESULT; +/// `WslcPushSessionImage`. +pub type WslcPushSessionImageFn = unsafe extern "system" fn( + session: WslcSession, + options: *const WslcPushImageOptions, + errorMessage: *mut PWSTR, +) -> HRESULT; +/// `WslcSessionAuthenticate`. +pub type WslcSessionAuthenticateFn = unsafe extern "system" fn( + session: WslcSession, + serverAddress: PCSTR, + username: PCSTR, + password: PCSTR, + identityToken: *mut PSTR, + errorMessage: *mut PWSTR, +) -> HRESULT; +/// `WslcListSessionImages`. +pub type WslcListSessionImagesFn = unsafe extern "system" fn( + session: WslcSession, + images: *mut *mut WslcImageInfo, + count: *mut u32, +) -> HRESULT; + +/// `WslcCreateSessionVhdVolume`. +pub type WslcCreateSessionVhdVolumeFn = unsafe extern "system" fn( + session: WslcSession, + options: *const WslcVhdRequirements, + errorMessage: *mut PWSTR, +) -> HRESULT; +/// `WslcDeleteSessionVhdVolume`. +pub type WslcDeleteSessionVhdVolumeFn = unsafe extern "system" fn( + session: WslcSession, + name: PCSTR, + errorMessage: *mut PWSTR, +) -> HRESULT; + +/// `WslcGetMissingComponents`. +pub type WslcGetMissingComponentsFn = + unsafe extern "system" fn(missingComponents: *mut WslcComponentFlags) -> HRESULT; +/// `WslcGetVersion`. +pub type WslcGetVersionFn = unsafe extern "system" fn(version: *mut WslcVersion) -> HRESULT; +/// `WslcInstallWithDependencies`. +pub type WslcInstallWithDependenciesFn = + unsafe extern "system" fn(progressCallback: WslcInstallCallback, context: PVOID) -> HRESULT; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn opaque_settings_layout_matches_header() { + assert_eq!( + core::mem::size_of::(), + WSLC_SESSION_OPTIONS_SIZE + ); + assert_eq!( + core::mem::align_of::(), + WSLC_SESSION_OPTIONS_ALIGNMENT + ); + assert_eq!( + core::mem::size_of::(), + WSLC_CONTAINER_OPTIONS_SIZE + ); + assert_eq!( + core::mem::align_of::(), + WSLC_CONTAINER_OPTIONS_ALIGNMENT + ); + assert_eq!( + core::mem::size_of::(), + WSLC_CONTAINER_PROCESS_OPTIONS_SIZE + ); + assert_eq!( + core::mem::align_of::(), + WSLC_CONTAINER_PROCESS_OPTIONS_ALIGNMENT + ); + } + + #[test] + fn image_info_layout_is_stable() { + assert_eq!(core::mem::size_of::(), 304); + assert_eq!(core::mem::align_of::(), 8); + } + + #[test] + fn privileged_flag_matches_the_public_sdk_abi() { + assert_eq!(WSLC_CONTAINER_FLAG_PRIVILEGED, 0x0000_0004); + } + + #[test] + fn logs_flags_match_shared_idl_bits() { + assert_eq!(WSLC_LOGS_FLAG_NONE, 0); + assert_eq!(WSLC_LOGS_FLAG_FOLLOW, 1); + assert_eq!(WSLC_LOGS_FLAG_TIMESTAMPS, 2); + assert_eq!(WSLC_LOGS_FLAG_DETAILS, 4); + assert_eq!( + WSLC_LOGS_FLAGS_VALID, + WSLC_LOGS_FLAG_FOLLOW | WSLC_LOGS_FLAG_TIMESTAMPS | WSLC_LOGS_FLAG_DETAILS + ); + } +} diff --git a/vendor/wslc-rs/crates/wslc-sys/src/runtime.rs b/vendor/wslc-rs/crates/wslc-sys/src/runtime.rs new file mode 100644 index 0000000..db532ae --- /dev/null +++ b/vendor/wslc-rs/crates/wslc-sys/src/runtime.rs @@ -0,0 +1,1033 @@ +//! Runtime loading and low-level WSLC SDK call boundary. + +#![allow(clippy::not_unsafe_ptr_arg_deref)] + +use std::ffi::{c_char, c_void, CStr, OsStr}; +use std::ptr::NonNull; +use std::sync::{Arc, Condvar, Mutex, OnceLock}; + +use crate::*; + +#[cfg(windows)] +#[link(name = "kernel32")] +extern "system" { + fn LoadLibraryW(lpLibFileName: *const u16) -> *mut c_void; + fn GetProcAddress(hModule: *mut c_void, lpProcName: *const c_char) -> *mut c_void; + fn FreeLibrary(hLibModule: *mut c_void) -> i32; + fn WaitForSingleObject(hHandle: *mut c_void, dwMilliseconds: u32) -> u32; +} + +#[cfg(windows)] +#[link(name = "ole32")] +extern "system" { + fn CoInitializeEx(pvReserved: *mut c_void, dwCoInit: u32) -> HRESULT; + fn CoUninitialize(); + fn CoTaskMemFree(pv: *mut c_void); +} + +/// Result type for runtime loading and SDK calls. +pub type Result = std::result::Result; + +/// Error type returned by runtime loading and low-level SDK calls. +#[derive(Debug)] +pub enum Error { + /// The current platform cannot host WSLC. + UnsupportedPlatform(&'static str), + /// `wslcsdk.dll` or one of its exported functions could not be loaded. + SdkNotFound(String), + /// COM initialization failed. + ComInitialization { + /// The HRESULT returned by COM. + code: HRESULT, + /// Human-readable context. + message: String, + }, + /// A WSLC SDK call returned a failing HRESULT. + HResult { + /// The HRESULT returned by WSLC. + code: HRESULT, + /// SDK-provided or synthesized message. + message: String, + }, + /// The caller passed data that cannot be represented for the SDK call. + InvalidInput(String), + /// A SDK-provided UTF-8 string was invalid. + Utf8(std::string::FromUtf8Error), +} + +/// Progress message emitted while transferring a container image. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ImageProgress { + /// Layer ID or digest. + pub id: String, + /// Raw SDK progress status. + pub status: WslcImageProgressStatus, + /// Downloaded or processed bytes. + pub current_bytes: u64, + /// Expected total bytes. + pub total_bytes: u64, +} + +/// Captured process output from SDK callbacks. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CapturedOutput { + /// Process exit status. + pub status: i32, + /// Captured stdout. + pub stdout: Vec, + /// Captured stderr. + pub stderr: Vec, +} + +#[derive(Default)] +struct CaptureState { + stdout: Vec, + stderr: Vec, + status: Option, +} + +/// Registration object that owns process callback state. +#[derive(Clone)] +pub struct CaptureRegistration { + state: Arc<(Mutex, Condvar)>, +} + +impl CaptureRegistration { + /// Creates a new process callback registration. + pub fn new() -> Self { + Self { + state: Arc::new((Mutex::new(CaptureState::default()), Condvar::new())), + } + } + + fn context(&self) -> PVOID { + Arc::as_ptr(&self.state).cast_mut().cast() + } + + /// Waits until the process exit callback arrives and returns captured output. + pub fn wait_output(&self) -> CapturedOutput { + let (lock, cvar) = &*self.state; + let mut state = lock.lock().expect("capture lock poisoned"); + while state.status.is_none() { + state = cvar.wait(state).expect("capture lock poisoned"); + } + CapturedOutput { + status: state.status.unwrap_or_default(), + stdout: state.stdout.clone(), + stderr: state.stderr.clone(), + } + } +} + +impl Default for CaptureRegistration { + fn default() -> Self { + Self::new() + } +} + +struct Library { + #[cfg_attr(not(windows), allow(dead_code))] + handle: NonNull, +} + +impl Library { + #[cfg(windows)] + fn load(name: &OsStr) -> Result { + let wide = wide_os(name); + let handle = unsafe { LoadLibraryW(wide.as_ptr()) }; + let handle = NonNull::new(handle).ok_or_else(|| { + Error::SdkNotFound( + "could not load wslcsdk.dll; install Microsoft.WSL.Containers or put the SDK native directory on PATH" + .to_owned(), + ) + })?; + Ok(Self { handle }) + } + + #[cfg(not(windows))] + fn load(_name: &OsStr) -> Result { + Err(Error::UnsupportedPlatform( + "WSLC requires a Windows host target", + )) + } + + #[cfg(windows)] + unsafe fn get(&self, symbol: &'static [u8]) -> Result { + let ptr = unsafe { GetProcAddress(self.handle.as_ptr(), symbol.as_ptr().cast()) }; + if ptr.is_null() { + let name = std::str::from_utf8(symbol) + .unwrap_or("") + .trim_end_matches('\0'); + return Err(Error::SdkNotFound(format!( + "wslcsdk.dll does not export {name}" + ))); + } + + Ok(unsafe { std::mem::transmute_copy::<*mut c_void, T>(&ptr) }) + } + + #[cfg(not(windows))] + unsafe fn get(&self, _symbol: &'static [u8]) -> Result { + let _ = self; + Err(Error::UnsupportedPlatform( + "WSLC requires a Windows host target", + )) + } +} + +unsafe impl Send for Library {} +unsafe impl Sync for Library {} + +impl Drop for Library { + fn drop(&mut self) { + #[cfg(windows)] + unsafe { + let _ = FreeLibrary(self.handle.as_ptr()); + } + } +} + +/// Loaded `wslcsdk.dll` function table with checked call helpers. +#[allow(non_snake_case, dead_code)] +pub struct Sdk { + _library: Library, + WslcInitSessionSettings: WslcInitSessionSettingsFn, + WslcCreateSession: WslcCreateSessionFn, + WslcSetSessionSettingsCpuCount: WslcSetSessionSettingsCpuCountFn, + WslcSetSessionSettingsMemory: WslcSetSessionSettingsMemoryFn, + WslcSetSessionSettingsTimeout: WslcSetSessionSettingsTimeoutFn, + WslcSetSessionSettingsVhd: WslcSetSessionSettingsVhdFn, + WslcSetSessionSettingsFeatureFlags: WslcSetSessionSettingsFeatureFlagsFn, + WslcTerminateSession: WslcTerminateSessionFn, + WslcReleaseSession: WslcReleaseSessionFn, + WslcInitContainerSettings: WslcInitContainerSettingsFn, + WslcCreateContainer: WslcCreateContainerFn, + WslcOpenContainer: WslcOpenContainerFn, + WslcStartContainer: WslcStartContainerFn, + WslcSetContainerSettingsName: WslcSetContainerSettingsNameFn, + WslcSetContainerSettingsInitProcess: WslcSetContainerSettingsInitProcessFn, + WslcSetContainerSettingsNetworkingMode: WslcSetContainerSettingsNetworkingModeFn, + WslcSetContainerSettingsHostName: WslcSetContainerSettingsHostNameFn, + WslcSetContainerSettingsDomainName: WslcSetContainerSettingsDomainNameFn, + WslcSetContainerSettingsFlags: WslcSetContainerSettingsFlagsFn, + WslcSetContainerSettingsPortMappings: WslcSetContainerSettingsPortMappingsFn, + WslcSetContainerSettingsVolumes: WslcSetContainerSettingsVolumesFn, + WslcSetContainerSettingsNamedVolumes: WslcSetContainerSettingsNamedVolumesFn, + WslcCreateContainerProcess: WslcCreateContainerProcessFn, + WslcReleaseContainer: WslcReleaseContainerFn, + WslcGetContainerID: WslcGetContainerIDFn, + WslcGetContainerInitProcess: WslcGetContainerInitProcessFn, + WslcInspectContainer: WslcInspectContainerFn, + WslcGetContainerState: WslcGetContainerStateFn, + WslcStopContainer: WslcStopContainerFn, + WslcDeleteContainer: WslcDeleteContainerFn, + WslcInitProcessSettings: WslcInitProcessSettingsFn, + WslcSetProcessSettingsWorkingDirectory: WslcSetProcessSettingsWorkingDirectoryFn, + WslcSetProcessSettingsCmdLine: WslcSetProcessSettingsCmdLineFn, + WslcSetProcessSettingsEnvVariables: WslcSetProcessSettingsEnvVariablesFn, + WslcSetProcessSettingsCallbacks: WslcSetProcessSettingsCallbacksFn, + WslcGetProcessPid: WslcGetProcessPidFn, + WslcGetProcessExitEvent: WslcGetProcessExitEventFn, + WslcGetProcessState: WslcGetProcessStateFn, + WslcGetProcessExitCode: WslcGetProcessExitCodeFn, + WslcSignalProcess: WslcSignalProcessFn, + WslcReleaseProcess: WslcReleaseProcessFn, + WslcPullSessionImage: WslcPullSessionImageFn, + WslcDeleteSessionImage: WslcDeleteSessionImageFn, + WslcTagSessionImage: WslcTagSessionImageFn, + WslcListSessionImages: WslcListSessionImagesFn, + WslcCreateSessionVhdVolume: WslcCreateSessionVhdVolumeFn, + WslcDeleteSessionVhdVolume: WslcDeleteSessionVhdVolumeFn, + WslcGetMissingComponents: WslcGetMissingComponentsFn, + WslcGetVersion: WslcGetVersionFn, + WslcInstallWithDependencies: WslcInstallWithDependenciesFn, +} + +impl Sdk { + fn load() -> Result { + let dll_path = std::env::var_os("WSLC_SDK_PATH") + .unwrap_or_else(|| std::ffi::OsString::from("wslcsdk.dll")); + let library = Library::load(OsStr::new(&dll_path))?; + macro_rules! sym { + ($field:ident) => { + unsafe { library.get(concat!(stringify!($field), "\0").as_bytes())? } + }; + } + + Ok(Self { + WslcInitSessionSettings: sym!(WslcInitSessionSettings), + WslcCreateSession: sym!(WslcCreateSession), + WslcSetSessionSettingsCpuCount: sym!(WslcSetSessionSettingsCpuCount), + WslcSetSessionSettingsMemory: sym!(WslcSetSessionSettingsMemory), + WslcSetSessionSettingsTimeout: sym!(WslcSetSessionSettingsTimeout), + WslcSetSessionSettingsVhd: sym!(WslcSetSessionSettingsVhd), + WslcSetSessionSettingsFeatureFlags: sym!(WslcSetSessionSettingsFeatureFlags), + WslcTerminateSession: sym!(WslcTerminateSession), + WslcReleaseSession: sym!(WslcReleaseSession), + WslcInitContainerSettings: sym!(WslcInitContainerSettings), + WslcCreateContainer: sym!(WslcCreateContainer), + WslcOpenContainer: sym!(WslcOpenContainer), + WslcStartContainer: sym!(WslcStartContainer), + WslcSetContainerSettingsName: sym!(WslcSetContainerSettingsName), + WslcSetContainerSettingsInitProcess: sym!(WslcSetContainerSettingsInitProcess), + WslcSetContainerSettingsNetworkingMode: sym!(WslcSetContainerSettingsNetworkingMode), + WslcSetContainerSettingsHostName: sym!(WslcSetContainerSettingsHostName), + WslcSetContainerSettingsDomainName: sym!(WslcSetContainerSettingsDomainName), + WslcSetContainerSettingsFlags: sym!(WslcSetContainerSettingsFlags), + WslcSetContainerSettingsPortMappings: sym!(WslcSetContainerSettingsPortMappings), + WslcSetContainerSettingsVolumes: sym!(WslcSetContainerSettingsVolumes), + WslcSetContainerSettingsNamedVolumes: sym!(WslcSetContainerSettingsNamedVolumes), + WslcCreateContainerProcess: sym!(WslcCreateContainerProcess), + WslcReleaseContainer: sym!(WslcReleaseContainer), + WslcGetContainerID: sym!(WslcGetContainerID), + WslcGetContainerInitProcess: sym!(WslcGetContainerInitProcess), + WslcInspectContainer: sym!(WslcInspectContainer), + WslcGetContainerState: sym!(WslcGetContainerState), + WslcStopContainer: sym!(WslcStopContainer), + WslcDeleteContainer: sym!(WslcDeleteContainer), + WslcInitProcessSettings: sym!(WslcInitProcessSettings), + WslcSetProcessSettingsWorkingDirectory: sym!(WslcSetProcessSettingsWorkingDirectory), + WslcSetProcessSettingsCmdLine: sym!(WslcSetProcessSettingsCmdLine), + WslcSetProcessSettingsEnvVariables: sym!(WslcSetProcessSettingsEnvVariables), + WslcSetProcessSettingsCallbacks: sym!(WslcSetProcessSettingsCallbacks), + WslcGetProcessPid: sym!(WslcGetProcessPid), + WslcGetProcessExitEvent: sym!(WslcGetProcessExitEvent), + WslcGetProcessState: sym!(WslcGetProcessState), + WslcGetProcessExitCode: sym!(WslcGetProcessExitCode), + WslcSignalProcess: sym!(WslcSignalProcess), + WslcReleaseProcess: sym!(WslcReleaseProcess), + WslcPullSessionImage: sym!(WslcPullSessionImage), + WslcDeleteSessionImage: sym!(WslcDeleteSessionImage), + WslcTagSessionImage: sym!(WslcTagSessionImage), + WslcListSessionImages: sym!(WslcListSessionImages), + WslcCreateSessionVhdVolume: sym!(WslcCreateSessionVhdVolume), + WslcDeleteSessionVhdVolume: sym!(WslcDeleteSessionVhdVolume), + WslcGetMissingComponents: sym!(WslcGetMissingComponents), + WslcGetVersion: sym!(WslcGetVersion), + WslcInstallWithDependencies: sym!(WslcInstallWithDependencies), + _library: library, + }) + } + + /// Returns missing WSLC component flags. + pub fn missing_components(&self) -> Result { + let mut flags = 0; + let hr = unsafe { (self.WslcGetMissingComponents)(&mut flags) }; + check_hr(hr, std::ptr::null_mut())?; + Ok(flags) + } + + /// Returns the installed WSLC runtime version. + pub fn version(&self) -> Result { + let mut version = WslcVersion::default(); + let hr = unsafe { (self.WslcGetVersion)(&mut version) }; + check_hr(hr, std::ptr::null_mut())?; + Ok(version) + } + + /// Installs WSLC dependencies and reports raw progress values. + pub fn install_with_dependencies(&self, progress: &mut F) -> Result<()> + where + F: FnMut(WslcComponentFlags, u32, u32), + { + let context = progress as *mut F as PVOID; + let hr = + unsafe { (self.WslcInstallWithDependencies)(Some(install_trampoline::), context) }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Initializes session settings. + pub fn init_session_settings( + &self, + name: PCWSTR, + storage_path: PCWSTR, + settings: &mut WslcSessionSettings, + ) -> Result<()> { + let hr = unsafe { (self.WslcInitSessionSettings)(name, storage_path, settings) }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Sets session CPU count. + pub fn set_session_cpu_count( + &self, + settings: &mut WslcSessionSettings, + cpu_count: u32, + ) -> Result<()> { + let hr = unsafe { (self.WslcSetSessionSettingsCpuCount)(settings, cpu_count) }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Sets session memory in MB. + pub fn set_session_memory( + &self, + settings: &mut WslcSessionSettings, + memory_mb: u32, + ) -> Result<()> { + let hr = unsafe { (self.WslcSetSessionSettingsMemory)(settings, memory_mb) }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Sets session creation timeout in milliseconds. + pub fn set_session_timeout( + &self, + settings: &mut WslcSessionSettings, + timeout_ms: u32, + ) -> Result<()> { + let hr = unsafe { (self.WslcSetSessionSettingsTimeout)(settings, timeout_ms) }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Sets session feature flags. + pub fn set_session_feature_flags( + &self, + settings: &mut WslcSessionSettings, + flags: WslcSessionFeatureFlags, + ) -> Result<()> { + let hr = unsafe { (self.WslcSetSessionSettingsFeatureFlags)(settings, flags) }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Sets session VHD requirements. + pub fn set_session_vhd( + &self, + settings: &mut WslcSessionSettings, + vhd: &WslcVhdRequirements, + ) -> Result<()> { + let hr = unsafe { (self.WslcSetSessionSettingsVhd)(settings, vhd) }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Creates a session handle. + pub fn create_session(&self, settings: &mut WslcSessionSettings) -> Result { + let mut session = std::ptr::null_mut(); + let mut error_message = std::ptr::null_mut(); + let hr = unsafe { (self.WslcCreateSession)(settings, &mut session, &mut error_message) }; + check_hr(hr, error_message)?; + Ok(session) + } + + /// Terminates a session handle. + pub fn terminate_session(&self, session: WslcSession) -> Result<()> { + let hr = unsafe { (self.WslcTerminateSession)(session) }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Releases a session handle. + pub fn release_session(&self, session: WslcSession) -> Result<()> { + let hr = unsafe { (self.WslcReleaseSession)(session) }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Lists images in a session. + pub fn list_session_images(&self, session: WslcSession) -> Result> { + let mut raw_images = std::ptr::null_mut(); + let mut count = 0; + let hr = unsafe { (self.WslcListSessionImages)(session, &mut raw_images, &mut count) }; + check_hr(hr, std::ptr::null_mut())?; + if raw_images.is_null() || count == 0 { + return Ok(Vec::new()); + } + + let images = unsafe { std::slice::from_raw_parts(raw_images, count as usize) }.to_vec(); + free_cotaskmem(raw_images.cast()); + Ok(images) + } + + /// Deletes an image from a session. + pub fn delete_session_image(&self, session: WslcSession, name_or_id: PCSTR) -> Result<()> { + let mut error_message = std::ptr::null_mut(); + let hr = unsafe { (self.WslcDeleteSessionImage)(session, name_or_id, &mut error_message) }; + check_hr(hr, error_message) + } + + /// Tags an image in a session. + pub fn tag_session_image( + &self, + session: WslcSession, + options: &WslcTagImageOptions, + ) -> Result<()> { + let mut error_message = std::ptr::null_mut(); + let hr = unsafe { (self.WslcTagSessionImage)(session, options, &mut error_message) }; + check_hr(hr, error_message) + } + + /// Creates a VHD-backed session volume. + pub fn create_session_vhd_volume( + &self, + session: WslcSession, + options: &WslcVhdRequirements, + ) -> Result<()> { + let mut error_message = std::ptr::null_mut(); + let hr = unsafe { (self.WslcCreateSessionVhdVolume)(session, options, &mut error_message) }; + check_hr(hr, error_message) + } + + /// Deletes a VHD-backed session volume. + pub fn delete_session_vhd_volume(&self, session: WslcSession, name: PCSTR) -> Result<()> { + let mut error_message = std::ptr::null_mut(); + let hr = unsafe { (self.WslcDeleteSessionVhdVolume)(session, name, &mut error_message) }; + check_hr(hr, error_message) + } + + /// Pulls a session image without progress callbacks. + pub fn pull_session_image( + &self, + session: WslcSession, + uri: PCSTR, + registry_auth: PCSTR, + ) -> Result<()> { + let options = WslcPullImageOptions { + uri, + progressCallback: None, + progressCallbackContext: std::ptr::null_mut(), + registryAuth: registry_auth, + }; + let mut error_message = std::ptr::null_mut(); + let hr = unsafe { (self.WslcPullSessionImage)(session, &options, &mut error_message) }; + check_hr(hr, error_message) + } + + /// Pulls a session image and reports progress callbacks. + pub fn pull_session_image_with_progress( + &self, + session: WslcSession, + uri: PCSTR, + registry_auth: PCSTR, + progress: &mut F, + ) -> Result<()> + where + F: FnMut(ImageProgress), + { + let context = progress as *mut F as PVOID; + let options = WslcPullImageOptions { + uri, + progressCallback: Some(progress_trampoline::), + progressCallbackContext: context, + registryAuth: registry_auth, + }; + let mut error_message = std::ptr::null_mut(); + let hr = unsafe { (self.WslcPullSessionImage)(session, &options, &mut error_message) }; + check_hr(hr, error_message) + } + + /// Initializes container settings. + pub fn init_container_settings( + &self, + image: PCSTR, + settings: &mut WslcContainerSettings, + ) -> Result<()> { + let hr = unsafe { (self.WslcInitContainerSettings)(image, settings) }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Sets the container name. + pub fn set_container_name( + &self, + settings: &mut WslcContainerSettings, + name: PCSTR, + ) -> Result<()> { + let hr = unsafe { (self.WslcSetContainerSettingsName)(settings, name) }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Sets the container host name. + pub fn set_container_host_name( + &self, + settings: &mut WslcContainerSettings, + host_name: PCSTR, + ) -> Result<()> { + let hr = unsafe { (self.WslcSetContainerSettingsHostName)(settings, host_name) }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Sets the container domain name. + pub fn set_container_domain_name( + &self, + settings: &mut WslcContainerSettings, + domain_name: PCSTR, + ) -> Result<()> { + let hr = unsafe { (self.WslcSetContainerSettingsDomainName)(settings, domain_name) }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Sets the container networking mode. + pub fn set_container_networking_mode( + &self, + settings: &mut WslcContainerSettings, + mode: WslcContainerNetworkingMode, + ) -> Result<()> { + let hr = unsafe { (self.WslcSetContainerSettingsNetworkingMode)(settings, mode) }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Sets container flags. + pub fn set_container_flags( + &self, + settings: &mut WslcContainerSettings, + flags: WslcContainerFlags, + ) -> Result<()> { + let hr = unsafe { (self.WslcSetContainerSettingsFlags)(settings, flags) }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Sets container init process settings. + pub fn set_container_init_process( + &self, + settings: &mut WslcContainerSettings, + process: &mut WslcProcessSettings, + ) -> Result<()> { + let hr = unsafe { (self.WslcSetContainerSettingsInitProcess)(settings, process) }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Sets container port mappings. + pub fn set_container_port_mappings( + &self, + settings: &mut WslcContainerSettings, + ports: &[WslcContainerPortMapping], + ) -> Result<()> { + let hr = unsafe { + (self.WslcSetContainerSettingsPortMappings)( + settings, + ports.as_ptr(), + ports.len() as u32, + ) + }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Sets container bind mount volumes. + pub fn set_container_volumes( + &self, + settings: &mut WslcContainerSettings, + volumes: &[WslcContainerVolume], + ) -> Result<()> { + let hr = unsafe { + (self.WslcSetContainerSettingsVolumes)(settings, volumes.as_ptr(), volumes.len() as u32) + }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Sets container named (session VHD) volume mounts. + pub fn set_container_named_volumes( + &self, + settings: &mut WslcContainerSettings, + volumes: &[WslcContainerNamedVolume], + ) -> Result<()> { + let hr = unsafe { + (self.WslcSetContainerSettingsNamedVolumes)( + settings, + volumes.as_ptr(), + volumes.len() as u32, + ) + }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Creates a container handle. + pub fn create_container( + &self, + session: WslcSession, + settings: &WslcContainerSettings, + ) -> Result { + let mut container = std::ptr::null_mut(); + let mut error_message = std::ptr::null_mut(); + let hr = unsafe { + (self.WslcCreateContainer)(session, settings, &mut container, &mut error_message) + }; + check_hr(hr, error_message)?; + Ok(container) + } + + /// Opens an existing container by name, full ID, or unambiguous ID prefix. + pub fn open_container(&self, session: WslcSession, name_or_id: PCSTR) -> Result { + let mut container = std::ptr::null_mut(); + let mut error_message = std::ptr::null_mut(); + let hr = unsafe { + (self.WslcOpenContainer)(session, name_or_id, &mut container, &mut error_message) + }; + check_hr(hr, error_message)?; + Ok(container) + } + + /// Releases a container handle. + pub fn release_container(&self, container: WslcContainer) -> Result<()> { + let hr = unsafe { (self.WslcReleaseContainer)(container) }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Returns the container ID string. + pub fn container_id(&self, container: WslcContainer) -> Result { + let mut id = [0i8; WSLC_CONTAINER_ID_BUFFER_SIZE]; + let hr = unsafe { (self.WslcGetContainerID)(container, id.as_mut_ptr()) }; + check_hr(hr, std::ptr::null_mut())?; + c_ptr_to_string(id.as_ptr()) + } + + /// Starts a container. + pub fn start_container( + &self, + container: WslcContainer, + flags: WslcContainerStartFlags, + ) -> Result<()> { + let mut error_message = std::ptr::null_mut(); + let hr = unsafe { (self.WslcStartContainer)(container, flags, &mut error_message) }; + check_hr(hr, error_message) + } + + /// Returns raw container inspect JSON. + pub fn inspect_container(&self, container: WslcContainer) -> Result { + let mut inspect = std::ptr::null_mut(); + let hr = unsafe { (self.WslcInspectContainer)(container, &mut inspect) }; + check_hr(hr, std::ptr::null_mut())?; + let value = c_ptr_to_string(inspect)?; + free_cotaskmem(inspect.cast()); + Ok(value) + } + + /// Returns the container state. + pub fn container_state(&self, container: WslcContainer) -> Result { + let mut state = WslcContainerState::WSLC_CONTAINER_STATE_INVALID; + let hr = unsafe { (self.WslcGetContainerState)(container, &mut state) }; + check_hr(hr, std::ptr::null_mut())?; + Ok(state) + } + + /// Creates a process in a container. + pub fn create_container_process( + &self, + container: WslcContainer, + settings: &mut WslcProcessSettings, + ) -> Result { + let mut process = std::ptr::null_mut(); + let mut error_message = std::ptr::null_mut(); + let hr = unsafe { + (self.WslcCreateContainerProcess)(container, settings, &mut process, &mut error_message) + }; + check_hr(hr, error_message)?; + Ok(process) + } + + /// Stops a container. + pub fn stop_container( + &self, + container: WslcContainer, + signal: WslcSignal, + timeout_seconds: u32, + ) -> Result<()> { + let mut error_message = std::ptr::null_mut(); + let hr = unsafe { + (self.WslcStopContainer)(container, signal, timeout_seconds, &mut error_message) + }; + check_hr(hr, error_message) + } + + /// Deletes a container. + pub fn delete_container( + &self, + container: WslcContainer, + flags: WslcDeleteContainerFlags, + ) -> Result<()> { + let mut error_message = std::ptr::null_mut(); + let hr = unsafe { (self.WslcDeleteContainer)(container, flags, &mut error_message) }; + check_hr(hr, error_message) + } + + /// Initializes process settings. + pub fn init_process_settings(&self, settings: &mut WslcProcessSettings) -> Result<()> { + let hr = unsafe { (self.WslcInitProcessSettings)(settings) }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Sets process command line. + pub fn set_process_cmdline( + &self, + settings: &mut WslcProcessSettings, + argv: &[PCSTR], + ) -> Result<()> { + let hr = unsafe { + (self.WslcSetProcessSettingsCmdLine)(settings, argv.as_ptr().cast(), argv.len()) + }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Sets process working directory. + pub fn set_process_working_directory( + &self, + settings: &mut WslcProcessSettings, + working_dir: PCSTR, + ) -> Result<()> { + let hr = unsafe { (self.WslcSetProcessSettingsWorkingDirectory)(settings, working_dir) }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Sets process environment variables. + pub fn set_process_env_variables( + &self, + settings: &mut WslcProcessSettings, + env: &[PCSTR], + ) -> Result<()> { + let hr = unsafe { + (self.WslcSetProcessSettingsEnvVariables)(settings, env.as_ptr().cast(), env.len()) + }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Sets process output callbacks using the provided capture registration. + pub fn set_process_capture_callbacks( + &self, + settings: &mut WslcProcessSettings, + capture: &CaptureRegistration, + ) -> Result<()> { + let callbacks = WslcProcessCallbacks { + onStdOut: Some(stdio_trampoline), + onStdErr: Some(stdio_trampoline), + onExit: Some(exit_trampoline), + }; + let hr = unsafe { + (self.WslcSetProcessSettingsCallbacks)(settings, &callbacks, capture.context()) + }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Returns process PID. + pub fn process_pid(&self, process: WslcProcess) -> Result { + let mut pid = 0; + let hr = unsafe { (self.WslcGetProcessPid)(process, &mut pid) }; + check_hr(hr, std::ptr::null_mut())?; + Ok(pid) + } + + /// Returns process state. + pub fn process_state(&self, process: WslcProcess) -> Result { + let mut state = WslcProcessState::WSLC_PROCESS_STATE_UNKNOWN; + let hr = unsafe { (self.WslcGetProcessState)(process, &mut state) }; + check_hr(hr, std::ptr::null_mut())?; + Ok(state) + } + + /// Sends a signal to a process. + pub fn signal_process(&self, process: WslcProcess, signal: WslcSignal) -> Result<()> { + let hr = unsafe { (self.WslcSignalProcess)(process, signal) }; + check_hr(hr, std::ptr::null_mut()) + } + + /// Returns process exit code. + pub fn process_exit_code(&self, process: WslcProcess) -> Result { + let mut exit_code = 0; + let hr = unsafe { (self.WslcGetProcessExitCode)(process, &mut exit_code) }; + check_hr(hr, std::ptr::null_mut())?; + Ok(exit_code) + } + + /// Returns process exit event handle. + pub fn process_exit_event(&self, process: WslcProcess) -> Result { + let mut event = std::ptr::null_mut(); + let hr = unsafe { (self.WslcGetProcessExitEvent)(process, &mut event) }; + check_hr(hr, std::ptr::null_mut())?; + Ok(event) + } + + /// Releases a process handle. + pub fn release_process(&self, process: WslcProcess) -> Result<()> { + let hr = unsafe { (self.WslcReleaseProcess)(process) }; + check_hr(hr, std::ptr::null_mut()) + } +} + +/// Returns the process-global loaded SDK function table. +/// +/// Successful loads are cached. Failures are **not** — Retry after fixing / +/// redownloading the DLL must be able to call `Sdk::load` again. +pub fn sdk() -> Result<&'static Sdk> { + static SDK: OnceLock = OnceLock::new(); + if let Some(sdk) = SDK.get() { + return Ok(sdk); + } + static LOAD: Mutex<()> = Mutex::new(()); + let _guard = LOAD.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(sdk) = SDK.get() { + return Ok(sdk); + } + let loaded = Sdk::load()?; + Ok(SDK.get_or_init(|| loaded)) +} + +/// Initializes COM on the current thread. +pub fn co_initialize_ex(reserved: *mut c_void, coinit: u32) -> HRESULT { + #[cfg(windows)] + unsafe { + CoInitializeEx(reserved, coinit) + } + + #[cfg(not(windows))] + { + let _ = (reserved, coinit); + 0x8000_4001_u32 as i32 + } +} + +/// Uninitializes COM on the current thread. +pub fn co_uninitialize() { + #[cfg(windows)] + unsafe { + CoUninitialize(); + } +} + +/// Waits for a Windows kernel object. +pub fn wait_for_single_object(handle: HANDLE, timeout_ms: u32) -> u32 { + #[cfg(windows)] + unsafe { + WaitForSingleObject(handle, timeout_ms) + } + + #[cfg(not(windows))] + { + let _ = (handle, timeout_ms); + 0xffff_ffff + } +} + +/// Converts a SDK-owned C string pointer into an owned Rust string. +pub fn c_ptr_to_string(ptr: *const c_char) -> Result { + if ptr.is_null() { + return Ok(String::new()); + } + + let bytes = unsafe { CStr::from_ptr(ptr) }.to_bytes().to_vec(); + String::from_utf8(bytes).map_err(Error::Utf8) +} + +/// Converts an image info name buffer into an owned Rust string. +pub fn image_name_to_string(name: &[c_char; WSLC_IMAGE_NAME_LENGTH]) -> Result { + c_ptr_to_string(name.as_ptr()) +} + +fn check_hr(hr: HRESULT, error_message: PWSTR) -> Result<()> { + if succeeded(hr) { + free_cotaskmem(error_message.cast()); + return Ok(()); + } + + let message = utf16_ptr_to_string(error_message); + free_cotaskmem(error_message.cast()); + Err(Error::HResult { code: hr, message }) +} + +fn succeeded(hr: HRESULT) -> bool { + hr >= 0 +} + +fn free_cotaskmem(ptr: *mut c_void) { + if ptr.is_null() { + return; + } + + #[cfg(windows)] + unsafe { + CoTaskMemFree(ptr); + } + + #[cfg(not(windows))] + { + let _ = ptr; + } +} + +fn utf16_ptr_to_string(ptr: *const u16) -> String { + if ptr.is_null() { + return String::new(); + } + + let mut len = 0usize; + while unsafe { *ptr.add(len) } != 0 { + len += 1; + } + + let slice = unsafe { std::slice::from_raw_parts(ptr, len) }; + String::from_utf16_lossy(slice) +} + +#[cfg(windows)] +fn wide_os(value: &OsStr) -> Vec { + use std::os::windows::ffi::OsStrExt; + + value.encode_wide().chain(std::iter::once(0)).collect() +} + +unsafe extern "system" fn install_trampoline( + component: WslcComponentFlags, + progress_steps: u32, + total_steps: u32, + context: PVOID, +) where + F: FnMut(WslcComponentFlags, u32, u32), +{ + if context.is_null() { + return; + } + + let callback = unsafe { &mut *(context as *mut F) }; + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + callback(component, progress_steps, total_steps); + })); +} + +unsafe extern "system" fn progress_trampoline( + progress: *const WslcImageProgressMessage, + context: PVOID, +) -> HRESULT +where + F: FnMut(ImageProgress), +{ + if progress.is_null() || context.is_null() { + return S_OK; + } + + let progress = unsafe { &*progress }; + let event = ImageProgress { + id: c_ptr_to_string(progress.id).unwrap_or_default(), + status: progress.status, + current_bytes: progress.detail.currentBytes, + total_bytes: progress.detail.totalBytes, + }; + let callback = unsafe { &mut *(context as *mut F) }; + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| callback(event))); + S_OK +} + +unsafe extern "system" fn stdio_trampoline( + io_handle: WslcProcessIOHandle, + data: *const u8, + data_bytes: u32, + context: PVOID, +) { + if context.is_null() || data.is_null() { + return; + } + let state = unsafe { &*(context as *const (Mutex, Condvar)) }; + let bytes = unsafe { std::slice::from_raw_parts(data, data_bytes as usize) }; + if let Ok(mut capture) = state.0.lock() { + match io_handle { + WslcProcessIOHandle::WSLC_PROCESS_IO_HANDLE_STDOUT => { + capture.stdout.extend_from_slice(bytes) + } + WslcProcessIOHandle::WSLC_PROCESS_IO_HANDLE_STDERR => { + capture.stderr.extend_from_slice(bytes) + } + _ => {} + } + } +} + +unsafe extern "system" fn exit_trampoline(exit_code: i32, context: PVOID) { + if context.is_null() { + return; + } + let state = unsafe { &*(context as *const (Mutex, Condvar)) }; + if let Ok(mut capture) = state.0.lock() { + capture.status = Some(exit_code); + state.1.notify_all(); + } +} diff --git a/vendor/wslc-rs/crates/wslc/Cargo.toml b/vendor/wslc-rs/crates/wslc/Cargo.toml new file mode 100644 index 0000000..490e301 --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "wslc" +description = "Safe Rust wrapper for Microsoft WSL Containers" +version = "0.1.9" +edition = "2021" +license = "MIT OR Apache-2.0" +readme = "README.md" + +[lib] +name = "wslc" +path = "src/lib.rs" + +[dependencies] +wslc-sys = { path = "../wslc-sys", version = "0.1.2" } + +[features] +default = [] +integration = [] diff --git a/vendor/wslc-rs/crates/wslc/README.md b/vendor/wslc-rs/crates/wslc/README.md new file mode 100644 index 0000000..766f88d --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/README.md @@ -0,0 +1,98 @@ +# wslc + +Safe Rust wrapper for the preview Microsoft WSL Containers SDK. + +The crate loads `wslcsdk.dll` at runtime, validates common inputs before calling +the SDK, maps failing HRESULT values into `wslc::Error`, and releases SDK +handles with RAII. It does not redistribute Microsoft SDK files. + +```rust +use std::path::PathBuf; + +use wslc::{ContainerOptions, ImagePullOptions, ProcessOptions, Service, Session}; + +fn main() -> wslc::Result<()> { + Service::ensure_available()?; + + let session = Session::builder("hello-wslc-rs", PathBuf::from(r"C:\WslcData\hello-wslc-rs")) + .cpu_count(2) + .memory_mb(2048) + .start()?; + + session + .pull_image(ImagePullOptions::new("docker.io/library/alpine:latest")) + .run()?; + + let output = session + .container(ContainerOptions::new("alpine:latest")) + .init_process(ProcessOptions::new(["/bin/echo", "hello from wslc-rs"]).capture_stdout()) + .auto_remove(true) + .create()? + .start_and_wait()?; + + println!("{}", String::from_utf8_lossy(&output.stdout)); + session.terminate()?; + Ok(()) +} +``` + +## Links + +- [WSL developer documentation](https://wsl.dev/) +- [WSL development loop](https://wsl.dev/dev-loop/) +- [WSL container API reference](https://wsl.dev/api-reference/) +- [C++ API end-to-end example](https://wsl.dev/api-reference/cpp/end-to-end-example/) +- [WSL container overview](https://learn.microsoft.com/en-us/windows/wsl/wsl-container) +- [Microsoft.WSL.Containers NuGet package](https://www.nuget.org/packages/Microsoft.WSL.Containers) +- [SDK installation guide](https://github.com/Ivanbeethoven/wslc-rs/blob/master/docs/sdk-installation.md) +- [Rust examples](https://github.com/Ivanbeethoven/wslc-rs/tree/master/crates/wslc/examples) + +## SDK Loading + +The crate dynamically loads `wslcsdk.dll`; normal builds and unit tests can run +without the preview SDK installed. Real calls return `Error::SdkNotFound` when +the DLL or a required export is unavailable. + +Install `Microsoft.WSL.Containers` from NuGet and put its +`runtimes\win-x64\native` directory on `PATH` before running real WSLC +operations. See the SDK installation guide linked above for the full setup. + +## Registry Mirrors + +Image references are passed through unchanged by default. Set +`WSLC_REGISTRY_MIRROR` to rewrite Docker Hub references before they reach WSLC: + +```powershell +$env:WSLC_REGISTRY_MIRROR = "" +``` + +For a specific registry, use a variable such as +`WSLC_REGISTRY_MIRROR_GHCR_IO`. + +## Container Logs + +The public C SDK does not yet export container logs. This crate still exposes a +Rust API that matches the CLI / service surface: + +```rust +use wslc::LogsOptions; + +let logs = container.logs(LogsOptions::new().timestamps(true).tail(100))?; +println!("{}", logs.stdout_lossy()); +``` + +Under the hood this invokes `wslc --session container logs ...`. Override +the executable path with `WSLC_CLI_PATH` when `wslc` is not on `PATH`. + +## Testing + +```powershell +cargo test -p wslc +cargo test -p wslc --features integration --test integration_smoke +``` + +The ignored Alpine E2E test pulls an image and starts a real container: + +```powershell +cargo test -p wslc --features integration --test integration_smoke -- --ignored --nocapture +``` diff --git a/vendor/wslc-rs/crates/wslc/examples/common/mod.rs b/vendor/wslc-rs/crates/wslc/examples/common/mod.rs new file mode 100644 index 0000000..06da69b --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/examples/common/mod.rs @@ -0,0 +1,14 @@ +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub fn unique_name(prefix: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + format!("{prefix}-{nanos}-{}", std::process::id()) +} + +pub fn storage_path(name: &str) -> PathBuf { + PathBuf::from(format!(r"C:\WslcData\{name}")) +} diff --git a/vendor/wslc-rs/crates/wslc/examples/container_inspect.rs b/vendor/wslc-rs/crates/wslc/examples/container_inspect.rs new file mode 100644 index 0000000..7206250 --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/examples/container_inspect.rs @@ -0,0 +1,38 @@ +use wslc::{ContainerOptions, ImagePullOptions, ProcessOptions, Service, Session}; + +mod common; + +const ALPINE_IMAGE: &str = "docker.io/library/alpine:latest"; + +fn main() -> wslc::Result<()> { + Service::ensure_available()?; + + let name = common::unique_name("wslc-inspect-demo"); + let session = Session::builder(&name, common::storage_path(&name)) + .cpu_count(2) + .memory_mb(2048) + .terminate_on_drop(true) + .start()?; + + session + .pull_image(ImagePullOptions::new(ALPINE_IMAGE)) + .run()?; + + let container = session + .container(ContainerOptions::new(ALPINE_IMAGE)) + .name(&name) + .init_process(ProcessOptions::new(["/bin/echo", "inspect-ok"]).capture_stdout()) + .auto_remove(true) + .create()?; + + println!("container id: {}", container.id()?); + println!("state before start: {:?}", container.state()?); + println!("inspect bytes: {}", container.inspect()?.len()); + + let output = container.start_and_wait()?; + println!("status: {}", output.status); + println!("stdout: {}", String::from_utf8_lossy(&output.stdout)); + + session.terminate()?; + Ok(()) +} diff --git a/vendor/wslc-rs/crates/wslc/examples/container_logs.rs b/vendor/wslc-rs/crates/wslc/examples/container_logs.rs new file mode 100644 index 0000000..58a597c --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/examples/container_logs.rs @@ -0,0 +1,37 @@ +use wslc::{ContainerOptions, ImagePullOptions, LogsOptions, ProcessOptions, Service, Session}; + +#[path = "common/mod.rs"] +mod common; + +fn main() -> wslc::Result<()> { + Service::ensure_available()?; + + let name = common::unique_name("wslc-logs-demo"); + let session = Session::builder(&name, common::storage_path(&name)) + .cpu_count(2) + .memory_mb(2048) + .terminate_on_drop(true) + .start()?; + + session + .pull_image(ImagePullOptions::new("docker.io/library/alpine:latest")) + .run()?; + + let container = session + .container(ContainerOptions::new("alpine:latest")) + .name("wslc-logs-demo") + .init_process( + ProcessOptions::new(["/bin/sh", "-c", "echo line1 && echo line2"]).capture_stdout(), + ) + .create()?; + let _ = container.start_and_wait()?; + + let logs = container.logs(LogsOptions::new().tail(1))?; + print!("{}", logs.stdout_lossy()); + + // Equivalent session-scoped helper using the container ID / name. + let logs = session.logs("wslc-logs-demo", LogsOptions::new().timestamps(true))?; + print!("{}", logs.stdout_lossy()); + + Ok(()) +} diff --git a/vendor/wslc-rs/crates/wslc/examples/hello.rs b/vendor/wslc-rs/crates/wslc/examples/hello.rs new file mode 100644 index 0000000..6c980b3 --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/examples/hello.rs @@ -0,0 +1,51 @@ +use std::time::Duration; + +use wslc::{ + ContainerOptions, DeleteContainerOptions, ImagePullOptions, ProcessOptions, Service, Session, + Signal, +}; + +mod common; + +const ALPINE_IMAGE: &str = "docker.io/library/alpine:latest"; + +fn main() -> wslc::Result<()> { + Service::ensure_available()?; + + let name = common::unique_name("hello-wslc-rs"); + let session = Session::builder(&name, common::storage_path(&name)) + .cpu_count(2) + .memory_mb(2048) + .start()?; + + session + .pull_image(ImagePullOptions::new(ALPINE_IMAGE)) + .on_progress(|p| { + println!("pull: {:?} {}/{}", p.status, p.current_bytes, p.total_bytes); + }) + .run()?; + + let process = ProcessOptions::new(["/bin/echo", "hello from wslc-rs"]) + .capture_stdout() + .capture_stderr(); + + let container = session + .container(ContainerOptions::new(ALPINE_IMAGE)) + .name(name) + .init_process(process) + .auto_remove(true) + .create()?; + + let output = container.start_and_wait()?; + println!("status: {}", output.status); + println!("stdout: {}", String::from_utf8_lossy(&output.stdout)); + eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr)); + + container + .stop(Signal::Sigterm, Duration::from_secs(10)) + .ok(); + container.delete(DeleteContainerOptions::default()).ok(); + session.terminate()?; + + Ok(()) +} diff --git a/vendor/wslc-rs/crates/wslc/examples/list_images.rs b/vendor/wslc-rs/crates/wslc/examples/list_images.rs new file mode 100644 index 0000000..fe0b0a9 --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/examples/list_images.rs @@ -0,0 +1,33 @@ +use wslc::{ImagePullOptions, Service, Session}; + +mod common; + +const ALPINE_IMAGE: &str = "docker.io/library/alpine:latest"; + +fn main() -> wslc::Result<()> { + Service::ensure_available()?; + + let name = common::unique_name("wslc-list-images"); + let session = Session::builder(&name, common::storage_path(&name)) + .cpu_count(2) + .memory_mb(2048) + .terminate_on_drop(true) + .start()?; + + println!("images before pull: {:?}", session.list_images()?); + + session + .pull_image(ImagePullOptions::new(ALPINE_IMAGE)) + .on_progress(|p| println!("pull: {:?} {}", p.status, p.id)) + .run()?; + + for image in session.list_images()? { + println!( + "image: {} size={} created={}", + image.name, image.size_bytes, image.created_unix_time + ); + } + + session.terminate()?; + Ok(()) +} diff --git a/vendor/wslc-rs/crates/wslc/examples/service_info.rs b/vendor/wslc-rs/crates/wslc/examples/service_info.rs new file mode 100644 index 0000000..3fb734c --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/examples/service_info.rs @@ -0,0 +1,24 @@ +use wslc::{Service, WslcErrorKind}; + +fn main() -> wslc::Result<()> { + let missing = Service::missing_components()?; + println!("missing components: {missing:?}"); + + if missing.is_empty() { + let version = Service::version()?; + println!( + "WSLC version: {}.{}.{}", + version.major, version.minor, version.revision + ); + } else if missing.contains(wslc::ComponentFlags::WSL_PACKAGE) { + println!("WSL package is missing; run `wsl --install --no-distribution`."); + } + + let known_error = wslc::Error::from_hresult(0x8004_0603_u32 as i32, "container not found"); + assert_eq!( + known_error.wslc_kind(), + Some(WslcErrorKind::ContainerNotFound) + ); + + Ok(()) +} diff --git a/vendor/wslc-rs/crates/wslc/examples/vhd_volume.rs b/vendor/wslc-rs/crates/wslc/examples/vhd_volume.rs new file mode 100644 index 0000000..a018d00 --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/examples/vhd_volume.rs @@ -0,0 +1,28 @@ +use wslc::{Service, Session, VhdOptions, VhdType}; + +mod common; + +fn main() -> wslc::Result<()> { + Service::ensure_available()?; + + let name = common::unique_name("wslc-vhd-demo"); + let volume = common::unique_name("demo-volume"); + let session = Session::builder(&name, common::storage_path(&name)) + .cpu_count(2) + .memory_mb(2048) + .terminate_on_drop(true) + .start()?; + + session.create_vhd_volume( + VhdOptions::new(&volume, 64 * 1024 * 1024) + .vhd_type(VhdType::Dynamic) + .owner(0, 0), + )?; + println!("created VHD volume: {volume}"); + + session.delete_vhd_volume(&volume)?; + println!("deleted VHD volume: {volume}"); + + session.terminate()?; + Ok(()) +} diff --git a/vendor/wslc-rs/crates/wslc/src/com.rs b/vendor/wslc-rs/crates/wslc/src/com.rs new file mode 100644 index 0000000..21bd05f --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/src/com.rs @@ -0,0 +1,54 @@ +use crate::{raw, Error, Result}; + +/// Guard returned by [`initialize_mta`]. +#[derive(Debug)] +pub struct ComGuard { + should_uninitialize: bool, +} + +impl Drop for ComGuard { + fn drop(&mut self) { + if self.should_uninitialize { + raw::co_uninitialize(); + } + } +} + +/// Initializes COM as MTA on the current thread. +#[cfg(not(windows))] +pub fn initialize_mta() -> Result { + Err(Error::UnsupportedPlatform( + "WSLC requires a Windows host target", + )) +} + +/// Initializes COM as MTA on the current thread. +#[cfg(windows)] +pub fn initialize_mta() -> Result { + const COINIT_MULTITHREADED: u32 = 0; + const RPC_E_CHANGED_MODE: i32 = 0x8001_0106_u32 as i32; + + let hr = raw::co_initialize_ex(std::ptr::null_mut(), COINIT_MULTITHREADED); + match hr { + 0 | 1 => Ok(ComGuard { + should_uninitialize: hr == 0, + }), + RPC_E_CHANGED_MODE => Err(Error::ComInitialization { + code: hr, + message: "current thread is already initialized with an incompatible COM apartment" + .to_owned(), + }), + code => Err(Error::ComInitialization { + code, + message: "CoInitializeEx failed".to_owned(), + }), + } +} + +pub(crate) fn try_initialize_mta() -> Result> { + match initialize_mta() { + Ok(guard) => Ok(Some(guard)), + Err(Error::ComInitialization { code, .. }) if code == 0x8001_0106_u32 as i32 => Ok(None), + Err(error) => Err(error), + } +} diff --git a/vendor/wslc-rs/crates/wslc/src/container.rs b/vendor/wslc-rs/crates/wslc/src/container.rs new file mode 100644 index 0000000..34ec5e3 --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/src/container.rs @@ -0,0 +1,649 @@ +use std::path::PathBuf; +use std::ptr::NonNull; +use std::rc::Rc; +use std::time::Duration; + +use crate::logs::{self, ContainerLogs, LogsOptions}; +use crate::process::{CaptureRegistration, Output, Process, ProcessInner, ProcessOptions}; +use crate::{raw, registry, strings, Error, Result, Session}; + +/// Container creation options. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContainerOptions { + /// Image name or ID. + pub image: String, +} + +impl ContainerOptions { + /// Creates container options. + pub fn new(image: impl Into) -> Self { + Self { + image: image.into(), + } + } + + /// Validates container options. + pub fn validate(&self) -> Result<()> { + if self.image.trim().is_empty() { + return Err(Error::InvalidInput( + "container image cannot be empty".to_owned(), + )); + } + if self.image.contains('\0') { + return Err(Error::Nul("container image".to_owned())); + } + Ok(()) + } +} + +/// Container networking mode. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum NetworkingMode { + /// No networking. + None, + /// Bridged networking. + #[default] + Bridged, +} + +impl NetworkingMode { + fn as_raw(self) -> wslc_sys::WslcContainerNetworkingMode { + match self { + Self::None => { + wslc_sys::WslcContainerNetworkingMode::WSLC_CONTAINER_NETWORKING_MODE_NONE + } + Self::Bridged => { + wslc_sys::WslcContainerNetworkingMode::WSLC_CONTAINER_NETWORKING_MODE_BRIDGED + } + } + } +} + +/// Linux signal. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Signal { + /// SIGHUP. + Sighup, + /// SIGINT. + Sigint, + /// SIGQUIT. + Sigquit, + /// SIGKILL. + Sigkill, + /// SIGTERM. + Sigterm, +} + +impl Signal { + /// Returns the Linux signal number. + pub const fn as_raw(self) -> i32 { + match self { + Self::Sighup => 1, + Self::Sigint => 2, + Self::Sigquit => 3, + Self::Sigkill => 9, + Self::Sigterm => 15, + } + } + + pub(crate) fn as_raw_signal(self) -> wslc_sys::WslcSignal { + match self { + Self::Sighup => wslc_sys::WslcSignal::WSLC_SIGNAL_SIGHUP, + Self::Sigint => wslc_sys::WslcSignal::WSLC_SIGNAL_SIGINT, + Self::Sigquit => wslc_sys::WslcSignal::WSLC_SIGNAL_SIGQUIT, + Self::Sigkill => wslc_sys::WslcSignal::WSLC_SIGNAL_SIGKILL, + Self::Sigterm => wslc_sys::WslcSignal::WSLC_SIGNAL_SIGTERM, + } + } +} + +/// Options for deleting a container. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct DeleteContainerOptions { + /// Force deletion. + pub force: bool, +} + +impl DeleteContainerOptions { + /// Enables force deletion. + pub fn force(mut self, force: bool) -> Self { + self.force = force; + self + } + + fn as_raw(self) -> wslc_sys::WslcDeleteContainerFlags { + if self.force { + wslc_sys::WSLC_DELETE_CONTAINER_FLAG_FORCE + } else { + wslc_sys::WSLC_DELETE_CONTAINER_FLAG_NONE + } + } +} + +#[derive(Clone, Debug)] +struct PortMapping { + windows_port: u16, + container_port: u16, +} + +#[derive(Clone, Debug)] +struct VolumeMount { + windows_path: PathBuf, + container_path: String, + read_only: bool, +} + +#[derive(Clone, Debug)] +struct NamedVolumeMount { + name: String, + container_path: String, + read_only: bool, +} + +/// Container builder. +pub struct ContainerBuilder { + session: Session, + options: ContainerOptions, + name: Option, + hostname: Option, + domain_name: Option, + networking: NetworkingMode, + flags: u32, + init_process: Option, + ports: Vec, + volumes: Vec, + named_volumes: Vec, +} + +impl ContainerBuilder { + pub(crate) fn new(session: Session, options: ContainerOptions) -> Self { + Self { + session, + options, + name: None, + hostname: None, + domain_name: None, + networking: NetworkingMode::default(), + flags: wslc_sys::WSLC_CONTAINER_FLAG_NONE, + init_process: None, + ports: Vec::new(), + volumes: Vec::new(), + named_volumes: Vec::new(), + } + } + + /// Sets the container name. + pub fn name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + + /// Sets the hostname. + pub fn hostname(mut self, hostname: impl Into) -> Self { + self.hostname = Some(hostname.into()); + self + } + + /// Sets the domain name. + pub fn domain_name(mut self, domain: impl Into) -> Self { + self.domain_name = Some(domain.into()); + self + } + + /// Sets networking mode. + pub fn networking(mut self, mode: NetworkingMode) -> Self { + self.networking = mode; + self + } + + /// Enables auto-remove. + pub fn auto_remove(mut self, enable: bool) -> Self { + set_flag( + &mut self.flags, + wslc_sys::WSLC_CONTAINER_FLAG_AUTO_REMOVE, + enable, + ); + self + } + + /// Enables privileged mode. + pub fn privileged(mut self, enable: bool) -> Self { + set_flag( + &mut self.flags, + wslc_sys::WSLC_CONTAINER_FLAG_PRIVILEGED, + enable, + ); + self + } + + /// Enables GPU. + pub fn enable_gpu(mut self, enable: bool) -> Self { + set_flag( + &mut self.flags, + wslc_sys::WSLC_CONTAINER_FLAG_ENABLE_GPU, + enable, + ); + self + } + + /// Sets the init process. + pub fn init_process(mut self, process: ProcessOptions) -> Self { + self.init_process = Some(process); + self + } + + /// Adds a TCP port mapping. + pub fn port(mut self, windows_port: u16, container_port: u16) -> Self { + self.ports.push(PortMapping { + windows_port, + container_port, + }); + self + } + + /// Adds a volume mount. + pub fn volume( + mut self, + windows_path: impl Into, + container_path: impl Into, + ) -> Self { + self.volumes.push(VolumeMount { + windows_path: windows_path.into(), + container_path: container_path.into(), + read_only: false, + }); + self + } + + /// Adds a read-only volume mount. + pub fn volume_read_only( + mut self, + windows_path: impl Into, + container_path: impl Into, + ) -> Self { + self.volumes.push(VolumeMount { + windows_path: windows_path.into(), + container_path: container_path.into(), + read_only: true, + }); + self + } + + /// Mounts a session-named VHD volume into the container. + pub fn named_volume( + mut self, + name: impl Into, + container_path: impl Into, + ) -> Self { + self.named_volumes.push(NamedVolumeMount { + name: name.into(), + container_path: container_path.into(), + read_only: false, + }); + self + } + + /// Mounts a session-named VHD volume read-only into the container. + pub fn named_volume_read_only( + mut self, + name: impl Into, + container_path: impl Into, + ) -> Self { + self.named_volumes.push(NamedVolumeMount { + name: name.into(), + container_path: container_path.into(), + read_only: true, + }); + self + } + + fn validate(&self) -> Result<()> { + self.options.validate()?; + for port in &self.ports { + if port.windows_port == 0 || port.container_port == 0 { + return Err(Error::InvalidInput( + "port mappings cannot use port 0".to_owned(), + )); + } + } + for volume in &self.volumes { + if volume.container_path.is_empty() || !volume.container_path.starts_with('/') { + return Err(Error::InvalidInput( + "container volume path must be absolute".to_owned(), + )); + } + if volume.windows_path.as_os_str().is_empty() { + return Err(Error::InvalidInput( + "windows volume path cannot be empty".to_owned(), + )); + } + } + for volume in &self.named_volumes { + if volume.name.trim().is_empty() { + return Err(Error::InvalidInput( + "named volume name cannot be empty".to_owned(), + )); + } + if volume.name.contains('\0') { + return Err(Error::Nul("named volume name".to_owned())); + } + if volume.container_path.is_empty() || !volume.container_path.starts_with('/') { + return Err(Error::InvalidInput( + "container volume path must be absolute".to_owned(), + )); + } + } + if let Some(process) = &self.init_process { + process.validate()?; + } + Ok(()) + } + + /// Creates the container. + pub fn create(self) -> Result { + self.validate()?; + let sdk = raw::sdk()?; + let resolved_image = if self.options.image.contains('/') { + registry::resolve_image_reference(&self.options.image)? + } else { + self.options.image.clone() + }; + let image = strings::cstring(&resolved_image, "image")?; + let mut settings = wslc_sys::WslcContainerSettings::default(); + raw::map_result(sdk.init_container_settings(image.as_ptr(), &mut settings))?; + + let name; + if let Some(value) = &self.name { + name = strings::cstring(value, "container name")?; + raw::map_result(sdk.set_container_name(&mut settings, name.as_ptr()))?; + } + let hostname; + if let Some(value) = &self.hostname { + hostname = strings::cstring(value, "hostname")?; + raw::map_result(sdk.set_container_host_name(&mut settings, hostname.as_ptr()))?; + } + let domain_name; + if let Some(value) = &self.domain_name { + domain_name = strings::cstring(value, "domain_name")?; + raw::map_result(sdk.set_container_domain_name(&mut settings, domain_name.as_ptr()))?; + } + + raw::map_result( + sdk.set_container_networking_mode(&mut settings, self.networking.as_raw()), + )?; + + raw::map_result(sdk.set_container_flags(&mut settings, self.flags))?; + + let capture = if self + .init_process + .as_ref() + .is_some_and(ProcessOptions::captures_output) + { + Some(CaptureRegistration::new()) + } else { + None + }; + let mut init_settings; + if let Some(process) = &self.init_process { + init_settings = process.to_raw(sdk, capture.as_ref())?; + raw::map_result( + sdk.set_container_init_process(&mut settings, &mut init_settings.settings), + )?; + } + + let raw_ports: Vec<_> = self + .ports + .iter() + .map(|port| wslc_sys::WslcContainerPortMapping { + windowsPort: port.windows_port, + containerPort: port.container_port, + protocol: wslc_sys::WslcPortProtocol::WSLC_PORT_PROTOCOL_TCP, + windowsAddress: std::ptr::null_mut(), + }) + .collect(); + if !raw_ports.is_empty() { + raw::map_result(sdk.set_container_port_mappings(&mut settings, &raw_ports))?; + } + + let windows_paths: Vec<_> = self + .volumes + .iter() + .map(|volume| strings::wide_path(&volume.windows_path)) + .collect(); + let container_paths = strings::cstrings( + self.volumes + .iter() + .map(|volume| volume.container_path.as_str()), + "container volume path", + )?; + let raw_volumes: Vec<_> = self + .volumes + .iter() + .enumerate() + .map(|(index, volume)| wslc_sys::WslcContainerVolume { + windowsPath: windows_paths[index].as_ptr(), + containerPath: container_paths[index].as_ptr(), + readOnly: i32::from(volume.read_only), + }) + .collect(); + if !raw_volumes.is_empty() { + raw::map_result(sdk.set_container_volumes(&mut settings, &raw_volumes))?; + } + + let named_names = strings::cstrings( + self.named_volumes.iter().map(|volume| volume.name.as_str()), + "named volume name", + )?; + let named_paths = strings::cstrings( + self.named_volumes + .iter() + .map(|volume| volume.container_path.as_str()), + "named volume container path", + )?; + let raw_named: Vec<_> = self + .named_volumes + .iter() + .enumerate() + .map(|(index, volume)| wslc_sys::WslcContainerNamedVolume { + name: named_names[index].as_ptr(), + containerPath: named_paths[index].as_ptr(), + readOnly: i32::from(volume.read_only), + }) + .collect(); + if !raw_named.is_empty() { + raw::map_result(sdk.set_container_named_volumes(&mut settings, &raw_named))?; + } + + let raw_container = raw::map_result(sdk.create_container(self.session.raw(), &settings))?; + let raw = NonNull::new(raw_container).ok_or_else(|| { + Error::from_hresult( + wslc_sys::S_OK, + "WslcCreateContainer returned a null container", + ) + })?; + Ok(Container { + inner: Rc::new(ContainerInner { + raw, + session: self.session.inner.clone(), + init_capture: capture, + }), + }) + } +} + +fn set_flag(flags: &mut u32, flag: u32, enable: bool) { + if enable { + *flags |= flag; + } else { + *flags &= !flag; + } +} + +/// Container state. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ContainerState { + /// Invalid state. + Invalid, + /// Created. + Created, + /// Running. + Running, + /// Exited. + Exited, + /// Deleted. + Deleted, +} + +impl From for ContainerState { + fn from(value: wslc_sys::WslcContainerState) -> Self { + match value { + wslc_sys::WslcContainerState::WSLC_CONTAINER_STATE_CREATED => Self::Created, + wslc_sys::WslcContainerState::WSLC_CONTAINER_STATE_RUNNING => Self::Running, + wslc_sys::WslcContainerState::WSLC_CONTAINER_STATE_EXITED => Self::Exited, + wslc_sys::WslcContainerState::WSLC_CONTAINER_STATE_DELETED => Self::Deleted, + _ => Self::Invalid, + } + } +} + +pub(crate) struct ContainerInner { + raw: NonNull, + session: Rc, + init_capture: Option, +} + +/// WSLC container handle. +pub struct Container { + pub(crate) inner: Rc, +} + +impl std::fmt::Debug for Container { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Container").finish_non_exhaustive() + } +} + +impl Container { + pub(crate) fn from_opened( + raw: NonNull, + session: Rc, + ) -> Self { + Self { + inner: Rc::new(ContainerInner { + raw, + session, + init_capture: None, + }), + } + } + + fn raw(&self) -> wslc_sys::WslcContainer { + self.inner.raw.as_ptr() + } + + /// Returns the container ID. + pub fn id(&self) -> Result { + let sdk = raw::sdk()?; + raw::map_result(sdk.container_id(self.raw())) + } + + /// Starts the container. + pub fn start(&self) -> Result<()> { + let sdk = raw::sdk()?; + raw::map_result(sdk.start_container(self.raw(), wslc_sys::WSLC_CONTAINER_START_FLAG_NONE)) + } + + /// Starts the container attached. + pub fn start_attached(&self) -> Result<()> { + let sdk = raw::sdk()?; + raw::map_result(sdk.start_container(self.raw(), wslc_sys::WSLC_CONTAINER_START_FLAG_ATTACH)) + } + + /// Starts the container and returns captured output. + pub fn start_and_wait(&self) -> Result { + self.start_attached()?; + let output = self + .inner + .init_capture + .as_ref() + .map(CaptureRegistration::wait_output) + .unwrap_or_default(); + Ok(Output { + status: output.status, + stdout: output.stdout, + stderr: output.stderr, + }) + } + + /// Returns raw inspect JSON. + pub fn inspect(&self) -> Result { + let sdk = raw::sdk()?; + raw::map_result(sdk.inspect_container(self.raw())) + } + + /// Returns container state. + pub fn state(&self) -> Result { + let sdk = raw::sdk()?; + let state = raw::map_result(sdk.container_state(self.raw()))?; + Ok(state.into()) + } + + /// Creates an additional process in the container. + pub fn exec(&self, options: ProcessOptions) -> Result { + let sdk = raw::sdk()?; + let capture = if options.captures_output() { + Some(CaptureRegistration::new()) + } else { + None + }; + let mut process_settings = options.to_raw(sdk, capture.as_ref())?; + let raw_process = raw::map_result( + sdk.create_container_process(self.raw(), &mut process_settings.settings), + )?; + let raw = NonNull::new(raw_process).ok_or_else(|| { + Error::from_hresult( + wslc_sys::S_OK, + "WslcCreateContainerProcess returned a null process", + ) + })?; + Ok(Process { + inner: Rc::new(ProcessInner { + raw: raw.as_ptr(), + capture: capture.map(Rc::new), + }), + }) + } + + /// Stops the container. + pub fn stop(&self, signal: Signal, timeout: Duration) -> Result<()> { + let timeout_seconds = u32::try_from(timeout.as_secs()) + .map_err(|_| Error::InvalidInput("timeout does not fit in u32 seconds".to_owned()))?; + let sdk = raw::sdk()?; + raw::map_result(sdk.stop_container(self.raw(), signal.as_raw_signal(), timeout_seconds)) + } + + /// Deletes the container. + pub fn delete(&self, options: DeleteContainerOptions) -> Result<()> { + let sdk = raw::sdk()?; + raw::map_result(sdk.delete_container(self.raw(), options.as_raw())) + } + + /// Fetches logs for this container. + /// + /// The public C SDK does not yet expose container logs, so this helper uses + /// the `wslc container logs` CLI against the owning session name. Override + /// the executable with `WSLC_CLI_PATH` when needed. + pub fn logs(&self, options: LogsOptions) -> Result { + let id = self.id()?; + logs::fetch_container_logs(&self.inner.session.name, &id, &options) + } +} + +impl Drop for ContainerInner { + fn drop(&mut self) { + if let Ok(sdk) = raw::sdk() { + let _com = crate::com::try_initialize_mta().ok().flatten(); + let _ = sdk.release_container(self.raw.as_ptr()); + } + } +} diff --git a/vendor/wslc-rs/crates/wslc/src/error.rs b/vendor/wslc-rs/crates/wslc/src/error.rs new file mode 100644 index 0000000..07f597a --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/src/error.rs @@ -0,0 +1,170 @@ +use std::fmt; + +/// Crate result type. +pub type Result = std::result::Result; + +/// Error type used by the safe WSLC wrapper. +#[derive(Debug)] +pub enum Error { + /// The current target platform cannot run WSLC. + UnsupportedPlatform(&'static str), + /// `wslcsdk.dll` or one of its required exports could not be loaded. + SdkNotFound(String), + /// Required Windows/WSL components are not present or need updates. + MissingComponents(crate::ComponentFlags), + /// COM initialization failed on the current thread. + ComInitialization { code: i32, message: String }, + /// A WSLC SDK call returned a failing HRESULT. + HResult { code: i32, message: String }, + /// User input failed Rust-side validation. + InvalidInput(String), + /// A string passed to a C API contains an interior NUL byte. + Nul(String), + /// Standard I/O error. + Io(std::io::Error), + /// UTF-8 conversion error. + Utf8(std::string::FromUtf8Error), + /// An external helper program (for example `wslc.exe`) was not found. + CommandNotFound(String), + /// An external helper program exited unsuccessfully. + CommandFailed { + /// Process exit code when available. + status: Option, + /// Human-readable failure details. + message: String, + }, +} + +impl Error { + /// Creates an HRESULT-backed error. + pub fn from_hresult(code: i32, message: impl Into) -> Self { + Self::HResult { + code, + message: message.into(), + } + } + + /// Returns the original HRESULT when this error has one. + pub fn hresult(&self) -> Option { + match self { + Self::ComInitialization { code, .. } | Self::HResult { code, .. } => Some(*code), + _ => None, + } + } + + /// Returns a typed WSLC error kind for known WSLC-specific HRESULT values. + pub fn wslc_kind(&self) -> Option { + let code = self.hresult()?; + Some(match code as u32 { + wslc_sys::WSLC_E_IMAGE_NOT_FOUND => WslcErrorKind::ImageNotFound, + wslc_sys::WSLC_E_CONTAINER_PREFIX_AMBIGUOUS => WslcErrorKind::ContainerPrefixAmbiguous, + wslc_sys::WSLC_E_CONTAINER_NOT_FOUND => WslcErrorKind::ContainerNotFound, + wslc_sys::WSLC_E_VOLUME_NOT_FOUND => WslcErrorKind::VolumeNotFound, + wslc_sys::WSLC_E_CONTAINER_NOT_RUNNING => WslcErrorKind::ContainerNotRunning, + wslc_sys::WSLC_E_CONTAINER_IS_RUNNING => WslcErrorKind::ContainerIsRunning, + wslc_sys::WSLC_E_SESSION_RESERVED => WslcErrorKind::SessionReserved, + wslc_sys::WSLC_E_INVALID_SESSION_NAME => WslcErrorKind::InvalidSessionName, + wslc_sys::WSLC_E_NETWORK_NOT_FOUND => WslcErrorKind::NetworkNotFound, + wslc_sys::WSLC_E_WU_SEARCH_FAILED => WslcErrorKind::WindowsUpdateSearchFailed, + wslc_sys::WSLC_E_SDK_UPDATE_NEEDED => WslcErrorKind::SdkUpdateNeeded, + wslc_sys::WSLC_E_CONTAINER_DISABLED => WslcErrorKind::ContainerDisabled, + wslc_sys::WSLC_E_REGISTRY_BLOCKED_BY_POLICY => WslcErrorKind::RegistryBlockedByPolicy, + wslc_sys::WSLC_E_VOLUME_NOT_AVAILABLE => WslcErrorKind::VolumeNotAvailable, + wslc_sys::WSLC_E_SESSION_NOT_FOUND => WslcErrorKind::SessionNotFound, + other if (other & 0xffff_0000) == 0x8004_0000 => WslcErrorKind::Unknown(code), + _ => return None, + }) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedPlatform(platform) => write!(f, "unsupported platform: {platform}"), + Self::SdkNotFound(message) => write!(f, "WSLC SDK not found: {message}"), + Self::MissingComponents(flags) => write!(f, "missing WSL components: {flags:?}"), + Self::ComInitialization { code, message } => { + write!( + f, + "COM initialization failed: HRESULT=0x{:08x}, {message}", + *code as u32 + ) + } + Self::HResult { code, message } => { + write!( + f, + "WSLC call failed: HRESULT=0x{:08x}, {message}", + *code as u32 + ) + } + Self::InvalidInput(message) => write!(f, "invalid input: {message}"), + Self::Nul(field) => write!(f, "string contains interior NUL: {field}"), + Self::Io(error) => write!(f, "I/O error: {error}"), + Self::Utf8(error) => write!(f, "UTF-8 conversion error: {error}"), + Self::CommandNotFound(message) => write!(f, "command not found: {message}"), + Self::CommandFailed { status, message } => match status { + Some(code) => write!(f, "command failed (exit {code}): {message}"), + None => write!(f, "command failed: {message}"), + }, + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(error) => Some(error), + Self::Utf8(error) => Some(error), + _ => None, + } + } +} + +impl From for Error { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +impl From for Error { + fn from(value: std::string::FromUtf8Error) -> Self { + Self::Utf8(value) + } +} + +/// Matchable WSLC-specific error classes. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WslcErrorKind { + /// Image name or ID was not found. + ImageNotFound, + /// Container prefix matched multiple containers. + ContainerPrefixAmbiguous, + /// Container was not found. + ContainerNotFound, + /// Volume was not found. + VolumeNotFound, + /// Container was not running. + ContainerNotRunning, + /// Container was already running. + ContainerIsRunning, + /// Session name is reserved. + SessionReserved, + /// Session name is invalid. + InvalidSessionName, + /// Network was not found. + NetworkNotFound, + /// Windows Update search failed. + WindowsUpdateSearchFailed, + /// SDK needs to be updated. + SdkUpdateNeeded, + /// Container support is disabled. + ContainerDisabled, + /// Registry access is blocked by policy. + RegistryBlockedByPolicy, + /// Volume is not available. + VolumeNotAvailable, + /// Session was not found. + SessionNotFound, + /// Unknown WSLC-specific HRESULT. + Unknown(i32), +} diff --git a/vendor/wslc-rs/crates/wslc/src/image.rs b/vendor/wslc-rs/crates/wslc/src/image.rs new file mode 100644 index 0000000..02bcbb4 --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/src/image.rs @@ -0,0 +1,180 @@ +use crate::{raw, registry, strings, Error, Result, Session}; + +/// Options for pulling an image. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ImagePullOptions { + /// Image URI. + pub uri: String, + /// Optional registry auth. + pub registry_auth: Option, +} + +impl ImagePullOptions { + /// Creates pull options. + pub fn new(uri: impl Into) -> Self { + Self { + uri: uri.into(), + registry_auth: None, + } + } + + /// Sets a registry auth token. + pub fn registry_auth(mut self, registry_auth: impl Into) -> Self { + self.registry_auth = Some(registry_auth.into()); + self + } + + /// Validates the options. + pub fn validate(&self) -> Result<()> { + if self.uri.trim().is_empty() { + return Err(Error::InvalidInput("image uri cannot be empty".to_owned())); + } + if self.uri.contains('\0') { + return Err(Error::Nul("image uri".to_owned())); + } + if self + .registry_auth + .as_ref() + .is_some_and(|value| value.contains('\0')) + { + return Err(Error::Nul("registry_auth".to_owned())); + } + Ok(()) + } +} + +/// Pull operation builder. +pub struct ImagePullOperation<'a> { + pub(crate) session: &'a Session, + pub(crate) options: ImagePullOptions, + pub(crate) progress: Option>, +} + +impl ImagePullOperation<'_> { + /// Registers a progress callback. + pub fn on_progress(mut self, f: F) -> Self + where + F: FnMut(ImageProgress) + Send + 'static, + { + self.progress = Some(Box::new(f)); + self + } + + /// Runs the pull operation. + pub fn run(mut self) -> Result<()> { + self.options.validate()?; + let sdk = raw::sdk()?; + let resolved_uri = registry::resolve_image_reference(&self.options.uri)?; + let uri = strings::cstring(&resolved_uri, "image uri")?; + let registry_auth; + let registry_auth_ptr = if let Some(auth) = &self.options.registry_auth { + registry_auth = strings::cstring(auth, "registry_auth")?; + registry_auth.as_ptr() + } else { + std::ptr::null() + }; + + if let Some(mut callback) = self.progress.take() { + raw::map_result(sdk.pull_session_image_with_progress( + self.session.raw(), + uri.as_ptr(), + registry_auth_ptr, + &mut |progress| { + callback(ImageProgress { + id: progress.id, + status: progress.status.into(), + current_bytes: progress.current_bytes, + total_bytes: progress.total_bytes, + }); + }, + )) + } else { + raw::map_result(sdk.pull_session_image( + self.session.raw(), + uri.as_ptr(), + registry_auth_ptr, + )) + } + } +} + +/// Image progress event. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ImageProgress { + /// Layer ID or digest. + pub id: String, + /// Progress status. + pub status: ImageProgressStatus, + /// Current bytes. + pub current_bytes: u64, + /// Total bytes. + pub total_bytes: u64, +} + +/// Image progress status. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ImageProgressStatus { + /// Unknown. + Unknown, + /// Pulling. + Pulling, + /// Waiting. + Waiting, + /// Downloading. + Downloading, + /// Verifying. + Verifying, + /// Extracting. + Extracting, + /// Complete. + Complete, +} + +impl From for ImageProgressStatus { + fn from(value: wslc_sys::WslcImageProgressStatus) -> Self { + match value { + wslc_sys::WslcImageProgressStatus::WSLC_IMAGE_PROGRESS_STATUS_PULLING => Self::Pulling, + wslc_sys::WslcImageProgressStatus::WSLC_IMAGE_PROGRESS_STATUS_WAITING => Self::Waiting, + wslc_sys::WslcImageProgressStatus::WSLC_IMAGE_PROGRESS_STATUS_DOWNLOADING => { + Self::Downloading + } + wslc_sys::WslcImageProgressStatus::WSLC_IMAGE_PROGRESS_STATUS_VERIFYING => { + Self::Verifying + } + wslc_sys::WslcImageProgressStatus::WSLC_IMAGE_PROGRESS_STATUS_EXTRACTING => { + Self::Extracting + } + wslc_sys::WslcImageProgressStatus::WSLC_IMAGE_PROGRESS_STATUS_COMPLETE => { + Self::Complete + } + _ => Self::Unknown, + } + } +} + +/// Image metadata. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ImageInfo { + /// Image name. + pub name: String, + /// SHA-256 digest bytes. + pub sha256: [u8; 32], + /// Size in bytes. + pub size_bytes: i64, + /// Creation time as Unix timestamp. + pub created_unix_time: u64, +} + +impl TryFrom for ImageInfo { + type Error = crate::Error; + + fn try_from(value: wslc_sys::WslcImageInfo) -> Result { + let name = raw::image_name_to_string(&value.name)?; + Ok(Self { + name, + sha256: value.sha256, + size_bytes: value.sizeBytes, + created_unix_time: value.createdUnixTime, + }) + } +} diff --git a/vendor/wslc-rs/crates/wslc/src/lib.rs b/vendor/wslc-rs/crates/wslc/src/lib.rs new file mode 100644 index 0000000..f7ca131 --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/src/lib.rs @@ -0,0 +1,33 @@ +//! Safe Rust wrapper for Microsoft WSL Containers. + +mod container; +mod image; +mod logs; +mod process; +mod raw; +mod registry; +mod service; +mod session; +mod storage; +mod strings; + +pub mod com; +pub mod error; + +/// Version of the preview WSLC SDK API shape this crate was written against. +pub const TESTED_WSLC_SDK_VERSION: &str = "preview"; + +pub use container::{ + Container, ContainerBuilder, ContainerOptions, ContainerState, DeleteContainerOptions, + NetworkingMode, Signal, +}; +pub use error::WslcErrorKind; +pub use error::{Error, Result}; +pub use image::{ + ImageInfo, ImageProgress, ImageProgressStatus, ImagePullOperation, ImagePullOptions, +}; +pub use logs::{ContainerLogs, LogsOptions}; +pub use process::{Output, OutputMode, Process, ProcessOptions, ProcessState}; +pub use service::{ComponentFlags, InstallProgress, Service, Version}; +pub use session::{Session, SessionBuilder}; +pub use storage::{VhdOptions, VhdType}; diff --git a/vendor/wslc-rs/crates/wslc/src/logs.rs b/vendor/wslc-rs/crates/wslc/src/logs.rs new file mode 100644 index 0000000..f4e364b --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/src/logs.rs @@ -0,0 +1,287 @@ +use std::ffi::OsString; +use std::process::Command; + +use crate::{Error, Result}; + +/// Options for reading container logs. +/// +/// These mirror the WSLC CLI / service `Logs` surface (`follow`, `timestamps`, +/// `details`, `since`, `until`, `tail`). The public C SDK does not yet export a +/// logs entry point, so the safe wrapper retrieves logs through `wslc.exe`. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct LogsOptions { + /// Keep streaming new log lines until the stream closes. + pub follow: bool, + /// Prefix each line with a timestamp. + pub timestamps: bool, + /// Include extra log details when supported by the runtime. + pub details: bool, + /// Only return logs at or after this Unix timestamp (seconds). + pub since: Option, + /// Only return logs at or before this Unix timestamp (seconds). + pub until: Option, + /// Return only the last `n` lines. `None` means no tail limit. + pub tail: Option, +} + +impl LogsOptions { + /// Creates default logs options (no follow, no timestamps, full history). + pub fn new() -> Self { + Self::default() + } + + /// Enables follow mode. + pub fn follow(mut self, enable: bool) -> Self { + self.follow = enable; + self + } + + /// Enables timestamps. + pub fn timestamps(mut self, enable: bool) -> Self { + self.timestamps = enable; + self + } + + /// Enables detailed log output. + pub fn details(mut self, enable: bool) -> Self { + self.details = enable; + self + } + + /// Sets the lower bound timestamp (Unix seconds). Use `0` for "from the beginning". + pub fn since(mut self, since: i64) -> Self { + self.since = Some(since); + self + } + + /// Sets the upper bound timestamp (Unix seconds). + pub fn until(mut self, until: i64) -> Self { + self.until = Some(until); + self + } + + /// Limits output to the last `n` lines. `n` must be greater than zero. + pub fn tail(mut self, n: u64) -> Self { + self.tail = Some(n); + self + } + + /// Validates options before invoking the logs backend. + pub fn validate(&self) -> Result<()> { + if let Some(0) = self.tail { + return Err(Error::InvalidInput( + "logs tail must be greater than zero".to_owned(), + )); + } + if let (Some(since), Some(until)) = (self.since, self.until) { + if since > until { + return Err(Error::InvalidInput( + "logs since cannot be greater than until".to_owned(), + )); + } + } + Ok(()) + } + + /// Builds the bitflags representation matching `WSLCLogsFlags`. + pub fn as_flags(&self) -> u32 { + let mut flags = wslc_sys::WSLC_LOGS_FLAG_NONE; + if self.follow { + flags |= wslc_sys::WSLC_LOGS_FLAG_FOLLOW; + } + if self.timestamps { + flags |= wslc_sys::WSLC_LOGS_FLAG_TIMESTAMPS; + } + if self.details { + flags |= wslc_sys::WSLC_LOGS_FLAG_DETAILS; + } + flags + } + + /// Builds `wslc` CLI arguments for this options set. + /// + /// `--session` is global and must appear before the `container logs` subcommand. + pub fn to_cli_args(&self, session_name: &str, container_id: &str) -> Result> { + self.validate()?; + if session_name.trim().is_empty() { + return Err(Error::InvalidInput( + "session name cannot be empty".to_owned(), + )); + } + if session_name.contains('\0') { + return Err(Error::Nul("session name".to_owned())); + } + if container_id.trim().is_empty() { + return Err(Error::InvalidInput( + "container id cannot be empty".to_owned(), + )); + } + if container_id.contains('\0') { + return Err(Error::Nul("container id".to_owned())); + } + + let mut args = vec![ + OsString::from("--session"), + OsString::from(session_name), + OsString::from("container"), + OsString::from("logs"), + ]; + if self.follow { + args.push(OsString::from("--follow")); + } + if self.timestamps { + args.push(OsString::from("--timestamps")); + } + if self.details { + args.push(OsString::from("--details")); + } + if let Some(since) = self.since { + args.push(OsString::from("--since")); + args.push(OsString::from(since.to_string())); + } + if let Some(until) = self.until { + args.push(OsString::from("--until")); + args.push(OsString::from(until.to_string())); + } + if let Some(tail) = self.tail { + args.push(OsString::from("--tail")); + args.push(OsString::from(tail.to_string())); + } + args.push(OsString::from(container_id)); + Ok(args) + } +} + +/// Captured container log output. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ContainerLogs { + /// Log bytes written to stdout by `wslc container logs`. + pub stdout: Vec, + /// Log bytes written to stderr by `wslc container logs`. + pub stderr: Vec, +} + +impl ContainerLogs { + /// Interprets stdout as UTF-8 lossily. + pub fn stdout_lossy(&self) -> std::borrow::Cow<'_, str> { + String::from_utf8_lossy(&self.stdout) + } + + /// Interprets stderr as UTF-8 lossily. + pub fn stderr_lossy(&self) -> std::borrow::Cow<'_, str> { + String::from_utf8_lossy(&self.stderr) + } +} + +pub(crate) fn fetch_container_logs( + session_name: &str, + container_id: &str, + options: &LogsOptions, +) -> Result { + let args = options.to_cli_args(session_name, container_id)?; + let program = std::env::var_os("WSLC_CLI_PATH").unwrap_or_else(|| OsString::from("wslc")); + + let output = Command::new(&program).args(&args).output().map_err(|err| { + if err.kind() == std::io::ErrorKind::NotFound { + Error::CommandNotFound(format!( + "could not find `{}`; install the WSL container CLI or set WSLC_CLI_PATH", + PathDisplay(&program) + )) + } else { + Error::Io(err) + } + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + let message = if stderr.is_empty() { + format!( + "`{}` exited with {}", + PathDisplay(&program), + status_code(&output.status) + ) + } else { + format!( + "`{}` exited with {}: {stderr}", + PathDisplay(&program), + status_code(&output.status) + ) + }; + return Err(Error::CommandFailed { + status: output.status.code(), + message, + }); + } + + Ok(ContainerLogs { + stdout: output.stdout, + stderr: output.stderr, + }) +} + +fn status_code(status: &std::process::ExitStatus) -> String { + status + .code() + .map(|code| code.to_string()) + .unwrap_or_else(|| "signal".to_owned()) +} + +struct PathDisplay<'a>(&'a OsString); + +impl std::fmt::Display for PathDisplay<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0.to_string_lossy()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cli_args_put_session_before_subcommand() { + let args = LogsOptions::new() + .timestamps(true) + .tail(2) + .to_cli_args("demo-session", "abc123") + .expect("args"); + let rendered: Vec<_> = args + .iter() + .map(|value| value.to_string_lossy().into_owned()) + .collect(); + assert_eq!( + rendered, + vec![ + "--session", + "demo-session", + "container", + "logs", + "--timestamps", + "--tail", + "2", + "abc123", + ] + ); + } + + #[test] + fn flags_match_shared_idl_bits() { + let options = LogsOptions::new() + .follow(true) + .timestamps(true) + .details(true); + assert_eq!( + options.as_flags(), + wslc_sys::WSLC_LOGS_FLAG_FOLLOW + | wslc_sys::WSLC_LOGS_FLAG_TIMESTAMPS + | wslc_sys::WSLC_LOGS_FLAG_DETAILS + ); + assert_eq!(options.as_flags() & !wslc_sys::WSLC_LOGS_FLAGS_VALID, 0); + } + + #[test] + fn rejects_zero_tail() { + let err = LogsOptions::new().tail(0).validate().unwrap_err(); + assert!(matches!(err, Error::InvalidInput(message) if message.contains("tail"))); + } +} diff --git a/vendor/wslc-rs/crates/wslc/src/process.rs b/vendor/wslc-rs/crates/wslc/src/process.rs new file mode 100644 index 0000000..21ed41a --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/src/process.rs @@ -0,0 +1,376 @@ +use crate::{raw, strings, Error, Result}; +use std::ffi::CString; +use std::rc::Rc; + +/// Output capture mode for a process. +#[derive(Default)] +pub enum OutputMode { + /// Discard process output. + #[default] + Null, + /// Capture stdout and stderr. + Capture, + /// Stream stdout and stderr to callbacks. + Streaming { + /// Stdout callback. + stdout: Option, + /// Stderr callback. + stderr: Option, + }, +} + +type OutputCallback = Box; + +impl std::fmt::Debug for OutputMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Null => f.write_str("Null"), + Self::Capture => f.write_str("Capture"), + Self::Streaming { .. } => f.write_str("Streaming"), + } + } +} + +/// Options for a container process. +#[derive(Debug)] +pub struct ProcessOptions { + /// Command and arguments. + pub cmdline: Vec, + /// Working directory inside the container. + pub working_dir: Option, + /// Environment variables. + pub env: Vec<(String, String)>, + /// Output mode. + pub output_mode: OutputMode, +} + +impl ProcessOptions { + /// Creates process options from command arguments. + pub fn new(args: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self { + cmdline: args.into_iter().map(Into::into).collect(), + working_dir: None, + env: Vec::new(), + output_mode: OutputMode::Null, + } + } + + /// Sets the working directory. + pub fn working_dir(mut self, dir: impl Into) -> Self { + self.working_dir = Some(dir.into()); + self + } + + /// Adds an environment variable. + pub fn env(mut self, key: impl Into, value: impl Into) -> Self { + self.env.push((key.into(), value.into())); + self + } + + /// Captures stdout and stderr. + pub fn capture_stdout(mut self) -> Self { + self.output_mode = OutputMode::Capture; + self + } + + /// Captures stdout and stderr. + pub fn capture_stderr(mut self) -> Self { + self.output_mode = OutputMode::Capture; + self + } + + /// Inherits output behavior from the SDK/runtime. + pub fn inherit_output(mut self) -> Self { + self.output_mode = OutputMode::Null; + self + } + + /// Enables streaming mode without callbacks yet. + pub fn streaming(mut self) -> Self { + self.output_mode = OutputMode::Streaming { + stdout: None, + stderr: None, + }; + self + } + + pub(crate) fn captures_output(&self) -> bool { + matches!( + self.output_mode, + OutputMode::Capture | OutputMode::Streaming { .. } + ) + } + + /// Validates process options. + pub fn validate(&self) -> Result<()> { + if self.cmdline.is_empty() { + return Err(Error::InvalidInput( + "process command cannot be empty".to_owned(), + )); + } + if self.cmdline.iter().any(|arg| arg.is_empty()) { + return Err(Error::InvalidInput( + "process command arguments cannot be empty".to_owned(), + )); + } + if self.cmdline.iter().any(|arg| arg.contains('\0')) { + return Err(Error::Nul("process arg".to_owned())); + } + if let Some(dir) = &self.working_dir { + if dir.is_empty() { + return Err(Error::InvalidInput( + "working directory cannot be empty".to_owned(), + )); + } + if dir.contains('\0') { + return Err(Error::Nul("working_dir".to_owned())); + } + } + for (key, value) in &self.env { + if key.is_empty() || key.contains('=') { + return Err(Error::InvalidInput( + "env variable key cannot be empty or contain '='".to_owned(), + )); + } + if key.contains('\0') || value.contains('\0') { + return Err(Error::Nul("env".to_owned())); + } + } + Ok(()) + } + + pub(crate) fn to_raw( + &self, + sdk: &raw::Sdk, + capture: Option<&CaptureRegistration>, + ) -> Result { + self.validate()?; + let mut settings = wslc_sys::WslcProcessSettings::default(); + raw::map_result(sdk.init_process_settings(&mut settings))?; + + let argv = RetainedCStringArray::new(strings::cstrings( + self.cmdline.iter().map(String::as_str), + "process arg", + )?); + raw::map_result(sdk.set_process_cmdline(&mut settings, argv.pointers()))?; + + let working_dir = self + .working_dir + .as_deref() + .map(|dir| strings::cstring(dir, "working_dir")) + .transpose()?; + if let Some(working_dir) = &working_dir { + raw::map_result( + sdk.set_process_working_directory(&mut settings, working_dir.as_ptr()), + )?; + } + + let env_values: Vec = self + .env + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect(); + let env = RetainedCStringArray::new(strings::cstrings( + env_values.iter().map(String::as_str), + "env", + )?); + if !env.pointers().is_empty() { + raw::map_result(sdk.set_process_env_variables(&mut settings, env.pointers()))?; + } + + if let Some(capture) = capture { + raw::map_result(sdk.set_process_capture_callbacks(&mut settings, capture))?; + } + + Ok(RawProcessSettings { + settings, + _argv: argv, + _working_dir: working_dir, + _env: env, + }) + } +} + +struct RetainedCStringArray { + _values: Vec, + pointers: Vec, +} + +impl RetainedCStringArray { + fn new(values: Vec) -> Self { + let pointers = values.iter().map(|value| value.as_ptr()).collect(); + Self { + _values: values, + pointers, + } + } + + fn pointers(&self) -> &[wslc_sys::PCSTR] { + &self.pointers + } +} + +pub(crate) struct RawProcessSettings { + pub settings: wslc_sys::WslcProcessSettings, + _argv: RetainedCStringArray, + _working_dir: Option, + _env: RetainedCStringArray, +} + +/// Process output. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Output { + /// Exit status. + pub status: i32, + /// Captured stdout. + pub stdout: Vec, + /// Captured stderr. + pub stderr: Vec, +} + +/// Process state. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProcessState { + /// Unknown. + Unknown, + /// Running. + Running, + /// Exited. + Exited, + /// Signalled. + Signalled, +} + +impl From for ProcessState { + fn from(value: wslc_sys::WslcProcessState) -> Self { + match value { + wslc_sys::WslcProcessState::WSLC_PROCESS_STATE_RUNNING => Self::Running, + wslc_sys::WslcProcessState::WSLC_PROCESS_STATE_EXITED => Self::Exited, + wslc_sys::WslcProcessState::WSLC_PROCESS_STATE_SIGNALLED => Self::Signalled, + _ => Self::Unknown, + } + } +} + +pub(crate) struct ProcessInner { + pub raw: wslc_sys::WslcProcess, + pub capture: Option>, +} + +/// A process running inside a WSLC container. +pub struct Process { + pub(crate) inner: Rc, +} + +impl std::fmt::Debug for Process { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Process").finish_non_exhaustive() + } +} + +impl Process { + /// Returns the process ID. + pub fn pid(&self) -> Result { + let sdk = raw::sdk()?; + raw::map_result(sdk.process_pid(self.inner.raw)) + } + + /// Returns the process state. + pub fn state(&self) -> Result { + let sdk = raw::sdk()?; + let state = raw::map_result(sdk.process_state(self.inner.raw))?; + Ok(state.into()) + } + + /// Sends a signal to the process. + pub fn signal(&self, signal: crate::Signal) -> Result<()> { + let sdk = raw::sdk()?; + raw::map_result(sdk.signal_process(self.inner.raw, signal.as_raw_signal())) + } + + /// Returns the exit code. + pub fn exit_code(&self) -> Result> { + let sdk = raw::sdk()?; + let exit_code = raw::map_result(sdk.process_exit_code(self.inner.raw))?; + Ok(Some(exit_code)) + } + + /// Waits for the process to finish. + pub fn wait(&self) -> Result { + let sdk = raw::sdk()?; + let event = raw::map_result(sdk.process_exit_event(self.inner.raw))?; + const INFINITE: u32 = 0xffff_ffff; + const WAIT_OBJECT_0: u32 = 0; + let wait = raw::wait_for_single_object(event, INFINITE); + if wait != WAIT_OBJECT_0 { + return Err(Error::InvalidInput(format!( + "waiting for process exit failed with WAIT result 0x{wait:08x}" + ))); + } + self.exit_code().map(|code| code.unwrap_or_default()) + } + + /// Waits for the process and returns captured output. + pub fn wait_with_output(&self) -> Result { + self.wait()?; + let captured = self + .inner + .capture + .as_ref() + .map(|c| c.wait_output()) + .unwrap_or_default(); + Ok(Output { + status: captured.status, + stdout: captured.stdout, + stderr: captured.stderr, + }) + } +} + +impl Drop for ProcessInner { + fn drop(&mut self) { + if let Ok(sdk) = raw::sdk() { + let _com = crate::com::try_initialize_mta().ok().flatten(); + let _ = sdk.release_process(self.raw); + } + } +} + +pub(crate) type CaptureRegistration = raw::CaptureRegistration; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn process_options_only_capture_when_requested() { + assert!(!ProcessOptions::new(["/bin/sleep", "30"]).captures_output()); + assert!(ProcessOptions::new(["/bin/echo", "hello"]) + .capture_stdout() + .captures_output()); + assert!(ProcessOptions::new(["/bin/echo", "hello"]) + .streaming() + .captures_output()); + assert!(!ProcessOptions::new(["/bin/echo", "hello"]) + .capture_stdout() + .inherit_output() + .captures_output()); + } + + #[test] + fn retained_cstring_array_keeps_values_and_pointer_storage_alive() { + let array = RetainedCStringArray::new(vec![ + CString::new("first").unwrap(), + CString::new("second=value").unwrap(), + ]); + + assert_eq!(array._values.len(), 2); + assert_eq!(array.pointers().len(), 2); + assert_eq!(array.pointers()[0], array._values[0].as_ptr()); + assert_eq!(array.pointers()[1], array._values[1].as_ptr()); + } +} diff --git a/vendor/wslc-rs/crates/wslc/src/raw.rs b/vendor/wslc-rs/crates/wslc/src/raw.rs new file mode 100644 index 0000000..edf28ff --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/src/raw.rs @@ -0,0 +1,45 @@ +use wslc_sys::runtime; + +use crate::{Error, Result}; + +pub(crate) use runtime::{CaptureRegistration, Sdk}; + +pub(crate) fn sdk() -> Result<&'static Sdk> { + runtime::sdk().map_err(map_error) +} + +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn co_initialize_ex(reserved: *mut std::ffi::c_void, coinit: u32) -> wslc_sys::HRESULT { + runtime::co_initialize_ex(reserved, coinit) +} + +pub(crate) fn co_uninitialize() { + runtime::co_uninitialize(); +} + +pub(crate) fn wait_for_single_object(handle: wslc_sys::HANDLE, timeout_ms: u32) -> u32 { + runtime::wait_for_single_object(handle, timeout_ms) +} + +pub(crate) fn image_name_to_string( + name: &[std::ffi::c_char; wslc_sys::WSLC_IMAGE_NAME_LENGTH], +) -> Result { + runtime::image_name_to_string(name).map_err(map_error) +} + +pub(crate) fn map_result(result: runtime::Result) -> Result { + result.map_err(map_error) +} + +fn map_error(error: runtime::Error) -> Error { + match error { + runtime::Error::UnsupportedPlatform(value) => Error::UnsupportedPlatform(value), + runtime::Error::SdkNotFound(value) => Error::SdkNotFound(value), + runtime::Error::ComInitialization { code, message } => { + Error::ComInitialization { code, message } + } + runtime::Error::HResult { code, message } => Error::HResult { code, message }, + runtime::Error::InvalidInput(value) => Error::InvalidInput(value), + runtime::Error::Utf8(value) => Error::Utf8(value), + } +} diff --git a/vendor/wslc-rs/crates/wslc/src/registry.rs b/vendor/wslc-rs/crates/wslc/src/registry.rs new file mode 100644 index 0000000..9f04ea2 --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/src/registry.rs @@ -0,0 +1,156 @@ +use crate::{Error, Result}; + +const DEFAULT_REGISTRY: &str = "docker.io"; +const DEFAULT_MIRROR_ENV: &str = "WSLC_REGISTRY_MIRROR"; +const MIRROR_ENV_PREFIX: &str = "WSLC_REGISTRY_MIRROR_"; + +pub(crate) fn resolve_image_reference(image: &str) -> Result { + resolve_image_reference_with(image, |key| std::env::var(key).ok()) +} + +pub(crate) fn resolve_image_reference_with(image: &str, mut env: F) -> Result +where + F: FnMut(&str) -> Option, +{ + let parsed = ImageReference::parse(image); + let registry = parsed.registry.unwrap_or(DEFAULT_REGISTRY); + let Some(mirror) = mirror_for_registry(registry, &mut env)? else { + return Ok(image.to_owned()); + }; + + Ok(format!( + "{}/{}", + mirror.trim_end_matches('/'), + parsed.repository_with_tag + )) +} + +fn mirror_for_registry(registry: &str, env: &mut F) -> Result> +where + F: FnMut(&str) -> Option, +{ + let registry_key = registry + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_uppercase() + } else { + '_' + } + }) + .collect::(); + let exact_key = format!("{MIRROR_ENV_PREFIX}{registry_key}"); + + let mirror = env(&exact_key).or_else(|| { + if registry == DEFAULT_REGISTRY { + env(DEFAULT_MIRROR_ENV) + } else { + None + } + }); + + match mirror.map(|value| value.trim().to_owned()) { + Some(value) if value.is_empty() => Err(Error::InvalidInput(format!( + "registry mirror env var cannot be empty for {registry}" + ))), + Some(value) if value.contains('\0') => Err(Error::Nul("registry mirror".to_owned())), + Some(value) if value.contains("://") => Err(Error::InvalidInput( + "registry mirror must be an image registry host, not a URL".to_owned(), + )), + Some(value) => Ok(Some(value)), + None => Ok(None), + } +} + +struct ImageReference<'a> { + registry: Option<&'a str>, + repository_with_tag: &'a str, +} + +impl<'a> ImageReference<'a> { + fn parse(image: &'a str) -> Self { + let (first, rest) = image.split_once('/').unwrap_or((image, "")); + let has_registry = first == "localhost" || first.contains('.') || first.contains(':'); + if has_registry && !rest.is_empty() { + Self { + registry: Some(first), + repository_with_tag: rest, + } + } else { + Self { + registry: None, + repository_with_tag: image, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl FnMut(&str) -> Option + 'a { + move |key| { + pairs + .iter() + .find_map(|(name, value)| (*name == key).then(|| (*value).to_owned())) + } + } + + #[test] + fn leaves_images_unchanged_without_mirror_env() { + assert_eq!( + resolve_image_reference_with("docker.io/library/alpine:latest", env(&[])).unwrap(), + "docker.io/library/alpine:latest" + ); + assert_eq!( + resolve_image_reference_with("alpine:latest", env(&[])).unwrap(), + "alpine:latest" + ); + } + + #[test] + fn default_mirror_rewrites_docker_hub_images_only() { + let vars = [("WSLC_REGISTRY_MIRROR", "mirror.example.com")]; + assert_eq!( + resolve_image_reference_with("docker.io/library/alpine:latest", env(&vars)).unwrap(), + "mirror.example.com/library/alpine:latest" + ); + assert_eq!( + resolve_image_reference_with("alpine:latest", env(&vars)).unwrap(), + "mirror.example.com/alpine:latest" + ); + assert_eq!( + resolve_image_reference_with("ghcr.io/org/app:latest", env(&vars)).unwrap(), + "ghcr.io/org/app:latest" + ); + } + + #[test] + fn per_registry_mirror_rewrites_matching_registry() { + let vars = [ + ("WSLC_REGISTRY_MIRROR_GHCR_IO", "ghcr-mirror.example.com"), + ( + "WSLC_REGISTRY_MIRROR_REGISTRY_EXAMPLE_COM_5000", + "local-mirror", + ), + ]; + assert_eq!( + resolve_image_reference_with("ghcr.io/org/app:latest", env(&vars)).unwrap(), + "ghcr-mirror.example.com/org/app:latest" + ); + assert_eq!( + resolve_image_reference_with("registry.example.com:5000/ns/app:v1", env(&vars)) + .unwrap(), + "local-mirror/ns/app:v1" + ); + } + + #[test] + fn rejects_url_style_mirror_values() { + let vars = [("WSLC_REGISTRY_MIRROR", "https://mirror.example.com")]; + let err = resolve_image_reference_with("docker.io/library/alpine:latest", env(&vars)) + .unwrap_err(); + assert!(matches!(err, Error::InvalidInput(_))); + } +} diff --git a/vendor/wslc-rs/crates/wslc/src/service.rs b/vendor/wslc-rs/crates/wslc/src/service.rs new file mode 100644 index 0000000..45ff76d --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/src/service.rs @@ -0,0 +1,138 @@ +use crate::{com, raw, Error, Result}; + +/// Flags describing missing WSLC prerequisites. +#[derive(Clone, Copy, Default, Eq, PartialEq)] +pub struct ComponentFlags(u32); + +impl ComponentFlags { + /// No missing components. + pub const NONE: Self = Self(wslc_sys::WSLC_COMPONENT_FLAG_NONE); + /// Virtual Machine Platform optional feature is missing. + pub const VIRTUAL_MACHINE_PLATFORM: Self = + Self(wslc_sys::WSLC_COMPONENT_FLAG_VIRTUAL_MACHINE_PLATFORM); + /// WSL runtime package is missing. + pub const WSL_PACKAGE: Self = Self(wslc_sys::WSLC_COMPONENT_FLAG_WSL_PACKAGE); + /// WSLC SDK needs an update. + pub const SDK_NEEDS_UPDATE: Self = Self(wslc_sys::WSLC_COMPONENT_FLAG_SDK_NEEDS_UPDATE); + + /// Creates flags from raw SDK bits. + pub const fn from_bits_retain(bits: u32) -> Self { + Self(bits) + } + + /// Returns the raw SDK bits. + pub const fn bits(self) -> u32 { + self.0 + } + + /// Returns true when no bits are set. + pub const fn is_empty(self) -> bool { + self.0 == 0 + } + + /// Returns true when all bits from `other` are present. + pub const fn contains(self, other: Self) -> bool { + (self.0 & other.0) == other.0 + } +} + +impl std::fmt::Debug for ComponentFlags { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut parts = Vec::new(); + if self.contains(Self::VIRTUAL_MACHINE_PLATFORM) { + parts.push("VIRTUAL_MACHINE_PLATFORM"); + } + if self.contains(Self::WSL_PACKAGE) { + parts.push("WSL_PACKAGE"); + } + if self.contains(Self::SDK_NEEDS_UPDATE) { + parts.push("SDK_NEEDS_UPDATE"); + } + if parts.is_empty() { + parts.push("NONE"); + } + write!(f, "ComponentFlags({})", parts.join(" | ")) + } +} + +/// WSLC runtime version. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct Version { + /// Major version. + pub major: u32, + /// Minor version. + pub minor: u32, + /// Revision version. + pub revision: u32, +} + +impl From for Version { + fn from(value: wslc_sys::WslcVersion) -> Self { + Self { + major: value.major, + minor: value.minor, + revision: value.revision, + } + } +} + +/// Installation progress event. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct InstallProgress { + /// Component being installed. + pub component: ComponentFlags, + /// Completed progress steps. + pub progress_steps: u32, + /// Total progress steps. + pub total_steps: u32, +} + +/// Service-level WSLC API. +pub struct Service; + +impl Service { + /// Returns missing WSLC components. + pub fn missing_components() -> Result { + let _com = com::try_initialize_mta()?; + let sdk = raw::sdk()?; + let flags = raw::map_result(sdk.missing_components())?; + Ok(ComponentFlags::from_bits_retain(flags)) + } + + /// Returns the WSLC runtime version. + pub fn version() -> Result { + let _com = com::try_initialize_mta()?; + let sdk = raw::sdk()?; + let version = raw::map_result(sdk.version())?; + Ok(version.into()) + } + + /// Ensures WSLC is available and the runtime can report its version. + pub fn ensure_available() -> Result<()> { + let missing = Self::missing_components()?; + if !missing.is_empty() { + return Err(Error::MissingComponents(missing)); + } + Self::version()?; + Ok(()) + } + + /// Installs missing WSLC dependencies, reporting progress through a callback. + pub fn install_with_dependencies(progress: F) -> Result<()> + where + F: FnMut(InstallProgress) + Send + 'static, + { + let _com = com::try_initialize_mta()?; + let sdk = raw::sdk()?; + let mut callback = Box::new(progress); + raw::map_result(sdk.install_with_dependencies( + &mut |component, progress_steps, total_steps| { + callback(InstallProgress { + component: ComponentFlags::from_bits_retain(component), + progress_steps, + total_steps, + }); + }, + )) + } +} diff --git a/vendor/wslc-rs/crates/wslc/src/session.rs b/vendor/wslc-rs/crates/wslc/src/session.rs new file mode 100644 index 0000000..b2c9d5f --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/src/session.rs @@ -0,0 +1,354 @@ +use std::path::PathBuf; +use std::ptr::NonNull; +use std::rc::Rc; +use std::time::Duration; + +use crate::container::{Container, ContainerBuilder, ContainerOptions}; +use crate::image::{ImageInfo, ImagePullOperation, ImagePullOptions}; +use crate::logs::{self, ContainerLogs, LogsOptions}; +use crate::storage::VhdOptions; +use crate::{com, raw, strings, Error, Result}; + +/// WSLC session handle. +#[derive(Clone)] +pub struct Session { + pub(crate) inner: Rc, +} + +impl std::fmt::Debug for Session { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Session").finish_non_exhaustive() + } +} + +pub(crate) struct SessionInner { + pub raw: NonNull, + pub name: String, + terminate_on_drop: bool, + _com: Option, +} + +impl Session { + /// Creates a session builder. + pub fn builder(name: impl Into, storage_path: impl Into) -> SessionBuilder { + SessionBuilder { + name: name.into(), + storage_path: storage_path.into(), + cpu_count: None, + memory_mb: None, + timeout_ms: None, + enable_gpu: false, + vhd: None, + terminate_on_drop: false, + } + } + + pub(crate) fn raw(&self) -> wslc_sys::WslcSession { + self.inner.raw.as_ptr() + } + + /// Returns the session display name used when the session was created. + pub fn name(&self) -> &str { + &self.inner.name + } + + /// Starts an image pull operation. + pub fn pull_image(&self, options: ImagePullOptions) -> ImagePullOperation<'_> { + ImagePullOperation { + session: self, + options, + progress: None, + } + } + + /// Lists images in the session. + pub fn list_images(&self) -> Result> { + let sdk = raw::sdk()?; + raw::map_result(sdk.list_session_images(self.raw()))? + .into_iter() + .map(ImageInfo::try_from) + .collect::>>() + } + + /// Deletes an image by name or ID. + pub fn delete_image(&self, name_or_id: impl AsRef) -> Result<()> { + let name_or_id = name_or_id.as_ref(); + if name_or_id.trim().is_empty() { + return Err(Error::InvalidInput( + "image name or id cannot be empty".to_owned(), + )); + } + let sdk = raw::sdk()?; + let name_or_id = strings::cstring(name_or_id, "name_or_id")?; + raw::map_result(sdk.delete_session_image(self.raw(), name_or_id.as_ptr())) + } + + /// Tags an image. + pub fn tag_image( + &self, + image: impl AsRef, + repo: impl AsRef, + tag: impl AsRef, + ) -> Result<()> { + let image = image.as_ref(); + let repo = repo.as_ref(); + let tag = tag.as_ref(); + if image.is_empty() || repo.is_empty() || tag.is_empty() { + return Err(Error::InvalidInput( + "image, repo, and tag cannot be empty".to_owned(), + )); + } + + let sdk = raw::sdk()?; + let image = strings::cstring(image, "image")?; + let repo = strings::cstring(repo, "repo")?; + let tag = strings::cstring(tag, "tag")?; + let options = wslc_sys::WslcTagImageOptions { + image: image.as_ptr(), + repo: repo.as_ptr(), + tag: tag.as_ptr(), + }; + raw::map_result(sdk.tag_session_image(self.raw(), &options)) + } + + /// Creates a container builder. + pub fn container(&self, options: ContainerOptions) -> ContainerBuilder { + ContainerBuilder::new(self.clone(), options) + } + + /// Opens an existing container by name, full ID, or unambiguous ID prefix. + pub fn open_container(&self, name_or_id: impl AsRef) -> Result { + let name_or_id = name_or_id.as_ref(); + if name_or_id.trim().is_empty() { + return Err(Error::InvalidInput( + "container name or id cannot be empty".to_owned(), + )); + } + if name_or_id.contains('\0') { + return Err(Error::Nul("container name or id".to_owned())); + } + + let sdk = raw::sdk()?; + let name_or_id = strings::cstring(name_or_id, "container name or id")?; + let raw_container = raw::map_result(sdk.open_container(self.raw(), name_or_id.as_ptr()))?; + let raw = NonNull::new(raw_container).ok_or_else(|| { + Error::from_hresult( + wslc_sys::S_OK, + "WslcOpenContainer returned a null container", + ) + })?; + Ok(Container::from_opened(raw, self.inner.clone())) + } + + /// Fetches logs for a container in this session. + /// + /// The public C SDK does not yet expose container logs, so this helper uses + /// the `wslc container logs` CLI. Override the executable with `WSLC_CLI_PATH` + /// when needed. + pub fn logs( + &self, + container_id: impl AsRef, + options: LogsOptions, + ) -> Result { + logs::fetch_container_logs(self.name(), container_id.as_ref(), &options) + } + + /// Creates a VHD-backed volume. + pub fn create_vhd_volume(&self, options: VhdOptions) -> Result<()> { + let sdk = raw::sdk()?; + let name = strings::cstring(&options.name, "vhd name")?; + let (flags, uid, gid) = if let Some((uid, gid)) = options.owner { + (wslc_sys::WSLC_VHD_REQ_FLAG_OWNER, uid, gid) + } else { + (wslc_sys::WSLC_VHD_REQ_FLAG_NONE, 0, 0) + }; + let raw_options = wslc_sys::WslcVhdRequirements { + name: name.as_ptr(), + sizeBytes: options.size_bytes, + type_: options.vhd_type.as_raw(), + flags, + uid, + gid, + }; + raw::map_result(sdk.create_session_vhd_volume(self.raw(), &raw_options)) + } + + /// Deletes a VHD-backed volume. + pub fn delete_vhd_volume(&self, name: impl AsRef) -> Result<()> { + let name = name.as_ref(); + if name.trim().is_empty() { + return Err(Error::InvalidInput( + "volume name cannot be empty".to_owned(), + )); + } + let sdk = raw::sdk()?; + let name = strings::cstring(name, "volume name")?; + raw::map_result(sdk.delete_session_vhd_volume(self.raw(), name.as_ptr())) + } + + /// Terminates the session. + pub fn terminate(&self) -> Result<()> { + let sdk = raw::sdk()?; + raw::map_result(sdk.terminate_session(self.raw())) + } +} + +/// Session builder. +pub struct SessionBuilder { + name: String, + storage_path: PathBuf, + cpu_count: Option, + memory_mb: Option, + timeout_ms: Option, + enable_gpu: bool, + vhd: Option, + terminate_on_drop: bool, +} + +impl SessionBuilder { + /// Sets CPU count. + pub fn cpu_count(mut self, cpu_count: u32) -> Self { + self.cpu_count = Some(cpu_count); + self + } + + /// Sets memory in MB. + pub fn memory_mb(mut self, memory_mb: u32) -> Self { + self.memory_mb = Some(memory_mb); + self + } + + /// Sets create timeout. + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout_ms = u32::try_from(timeout.as_millis()).ok(); + if self.timeout_ms.is_none() { + self.timeout_ms = Some(u32::MAX); + } + self + } + + /// Enables GPU support. + pub fn enable_gpu(mut self, enable: bool) -> Self { + self.enable_gpu = enable; + self + } + + /// Sets a session VHD. + pub fn vhd(mut self, options: VhdOptions) -> Self { + self.vhd = Some(options); + self + } + + /// Terminates the session when dropped. + pub fn terminate_on_drop(mut self, enable: bool) -> Self { + self.terminate_on_drop = enable; + self + } + + fn validate(&self) -> Result<()> { + if self.name.trim().is_empty() { + return Err(Error::InvalidInput( + "session name cannot be empty".to_owned(), + )); + } + if self.name.contains('\0') { + return Err(Error::Nul("session name".to_owned())); + } + if self.storage_path.as_os_str().is_empty() { + return Err(Error::InvalidInput( + "storage path cannot be empty".to_owned(), + )); + } + if self.cpu_count == Some(0) { + return Err(Error::InvalidInput( + "cpu_count must be greater than zero".to_owned(), + )); + } + if self.memory_mb == Some(0) { + return Err(Error::InvalidInput( + "memory_mb must be greater than zero".to_owned(), + )); + } + if self.timeout_ms == Some(u32::MAX) { + return Err(Error::InvalidInput( + "timeout does not fit in u32 milliseconds".to_owned(), + )); + } + Ok(()) + } + + /// Starts the WSLC session. + pub fn start(self) -> Result { + self.validate()?; + let com = com::try_initialize_mta()?; + let sdk = raw::sdk()?; + let name = strings::wide_str(&self.name); + let storage_path = strings::wide_path(&self.storage_path); + let mut settings = wslc_sys::WslcSessionSettings::default(); + raw::map_result(sdk.init_session_settings( + name.as_ptr(), + storage_path.as_ptr(), + &mut settings, + ))?; + + if let Some(cpu_count) = self.cpu_count { + raw::map_result(sdk.set_session_cpu_count(&mut settings, cpu_count))?; + } + if let Some(memory_mb) = self.memory_mb { + raw::map_result(sdk.set_session_memory(&mut settings, memory_mb))?; + } + if let Some(timeout_ms) = self.timeout_ms { + raw::map_result(sdk.set_session_timeout(&mut settings, timeout_ms))?; + } + if self.enable_gpu { + raw::map_result(sdk.set_session_feature_flags( + &mut settings, + wslc_sys::WSLC_SESSION_FEATURE_FLAG_ENABLE_GPU, + ))?; + } + + let vhd_name; + let vhd_raw; + if let Some(vhd) = &self.vhd { + vhd_name = strings::cstring(&vhd.name, "vhd name")?; + let (flags, uid, gid) = if let Some((uid, gid)) = vhd.owner { + (wslc_sys::WSLC_VHD_REQ_FLAG_OWNER, uid, gid) + } else { + (wslc_sys::WSLC_VHD_REQ_FLAG_NONE, 0, 0) + }; + vhd_raw = wslc_sys::WslcVhdRequirements { + name: vhd_name.as_ptr(), + sizeBytes: vhd.size_bytes, + type_: vhd.vhd_type.as_raw(), + flags, + uid, + gid, + }; + raw::map_result(sdk.set_session_vhd(&mut settings, &vhd_raw))?; + } + + let raw_session = raw::map_result(sdk.create_session(&mut settings))?; + let raw = NonNull::new(raw_session).ok_or_else(|| { + Error::from_hresult(wslc_sys::S_OK, "WslcCreateSession returned a null session") + })?; + Ok(Session { + inner: Rc::new(SessionInner { + raw, + name: self.name, + terminate_on_drop: self.terminate_on_drop, + _com: com, + }), + }) + } +} + +impl Drop for SessionInner { + fn drop(&mut self) { + if let Ok(sdk) = raw::sdk() { + if self.terminate_on_drop { + let _ = sdk.terminate_session(self.raw.as_ptr()); + } + let _ = sdk.release_session(self.raw.as_ptr()); + } + } +} diff --git a/vendor/wslc-rs/crates/wslc/src/storage.rs b/vendor/wslc-rs/crates/wslc/src/storage.rs new file mode 100644 index 0000000..90f2b7b --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/src/storage.rs @@ -0,0 +1,54 @@ +/// VHD volume type. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VhdType { + /// Expanding VHDX. + Dynamic, + /// Fixed-allocation VHDX. + Fixed, +} + +impl VhdType { + pub(crate) fn as_raw(self) -> wslc_sys::WslcVhdType { + match self { + Self::Dynamic => wslc_sys::WslcVhdType::WSLC_VHD_TYPE_DYNAMIC, + Self::Fixed => wslc_sys::WslcVhdType::WSLC_VHD_TYPE_FIXED, + } + } +} + +/// VHD-backed session volume options. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VhdOptions { + /// Volume name. + pub name: String, + /// Desired size in bytes. + pub size_bytes: u64, + /// VHD allocation strategy. + pub vhd_type: VhdType, + /// Optional Linux owner. + pub owner: Option<(u32, u32)>, +} + +impl VhdOptions { + /// Creates volume options. + pub fn new(name: impl Into, size_bytes: u64) -> Self { + Self { + name: name.into(), + size_bytes, + vhd_type: VhdType::Dynamic, + owner: None, + } + } + + /// Sets the VHD type. + pub fn vhd_type(mut self, vhd_type: VhdType) -> Self { + self.vhd_type = vhd_type; + self + } + + /// Sets the Linux owner for create-volume operations. + pub fn owner(mut self, uid: u32, gid: u32) -> Self { + self.owner = Some((uid, gid)); + self + } +} diff --git a/vendor/wslc-rs/crates/wslc/src/strings.rs b/vendor/wslc-rs/crates/wslc/src/strings.rs new file mode 100644 index 0000000..e7b68b3 --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/src/strings.rs @@ -0,0 +1,53 @@ +use std::ffi::{CString, OsStr}; +use std::path::Path; + +use crate::{Error, Result}; + +pub(crate) fn cstring(value: &str, field: &str) -> Result { + CString::new(value).map_err(|_| Error::Nul(field.to_owned())) +} + +pub(crate) fn cstrings<'a>( + values: impl IntoIterator, + field: &str, +) -> Result> { + values + .into_iter() + .map(|value| cstring(value, field)) + .collect() +} + +#[cfg(windows)] +pub(crate) fn wide_os(value: &OsStr) -> Vec { + use std::os::windows::ffi::OsStrExt; + + value.encode_wide().chain(std::iter::once(0)).collect() +} + +#[cfg(not(windows))] +pub(crate) fn wide_os(value: &OsStr) -> Vec { + value + .to_string_lossy() + .encode_utf16() + .chain(std::iter::once(0)) + .collect() +} + +pub(crate) fn wide_path(value: &Path) -> Vec { + wide_os(value.as_os_str()) +} + +pub(crate) fn wide_str(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cstring_rejects_interior_nul() { + let err = cstring("a\0b", "field").unwrap_err(); + assert!(matches!(err, Error::Nul(field) if field == "field")); + } +} diff --git a/vendor/wslc-rs/crates/wslc/tests/api_contract.rs b/vendor/wslc-rs/crates/wslc/tests/api_contract.rs new file mode 100644 index 0000000..39c73fc --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/tests/api_contract.rs @@ -0,0 +1,604 @@ +use std::path::PathBuf; +use std::time::Duration; + +use wslc::{ + ComponentFlags, ContainerOptions, ContainerState, DeleteContainerOptions, Error, + ImageProgressStatus, ImagePullOptions, LogsOptions, NetworkingMode, ProcessOptions, + ProcessState, Service, Session, Signal, Version, VhdOptions, VhdType, WslcErrorKind, +}; + +#[test] +fn session_builder_rejects_empty_session_name_before_loading_sdk() { + let err = Session::builder("", PathBuf::from(r"C:\WslcData\test")) + .start() + .unwrap_err(); + + assert!(matches!(err, Error::InvalidInput(message) if message.contains("session name"))); +} + +#[test] +fn session_builder_rejects_zero_cpu_count() { + let err = Session::builder("test", PathBuf::from(r"C:\WslcData\test")) + .cpu_count(0) + .start() + .unwrap_err(); + + assert!(matches!(err, Error::InvalidInput(message) if message.contains("cpu_count"))); +} + +#[test] +fn process_options_reject_empty_command_when_used_as_init_process() { + let err = ProcessOptions::new(Vec::::new()) + .validate() + .unwrap_err(); + + assert!(matches!(err, Error::InvalidInput(message) if message.contains("command"))); +} + +#[test] +fn image_pull_options_reject_empty_uri_before_loading_sdk() { + let err = ImagePullOptions::new("").validate().unwrap_err(); + + assert!(matches!(err, Error::InvalidInput(message) if message.contains("image uri"))); +} + +#[test] +fn image_pull_options_reject_interior_nul_before_loading_sdk() { + let err = ImagePullOptions::new("docker.io/library/alpine\0latest") + .validate() + .unwrap_err(); + + assert!(matches!(err, Error::Nul(field) if field == "image uri")); + + let err = ImagePullOptions::new("docker.io/library/alpine:latest") + .registry_auth("token\0tail") + .validate() + .unwrap_err(); + + assert!(matches!(err, Error::Nul(field) if field == "registry_auth")); +} + +#[test] +fn container_options_reject_empty_and_nul_image_names() { + let err = ContainerOptions::new(" ").validate().unwrap_err(); + + assert!(matches!(err, Error::InvalidInput(message) if message.contains("container image"))); + + let err = ContainerOptions::new("alpine\0latest") + .validate() + .unwrap_err(); + + assert!(matches!(err, Error::Nul(field) if field == "container image")); +} + +#[test] +fn delete_container_options_default_is_not_forceful() { + assert!(!DeleteContainerOptions::default().force); +} + +#[test] +fn logs_options_validate_and_build_cli_args_before_loading_sdk() { + let err = LogsOptions::new().tail(0).validate().unwrap_err(); + assert!(matches!(err, Error::InvalidInput(message) if message.contains("tail"))); + + let err = LogsOptions::new() + .since(10) + .until(5) + .validate() + .unwrap_err(); + assert!(matches!(err, Error::InvalidInput(message) if message.contains("since"))); + + let args = LogsOptions::new() + .follow(true) + .timestamps(true) + .details(true) + .since(1) + .until(2) + .tail(3) + .to_cli_args("session-a", "container-b") + .expect("cli args"); + let rendered: Vec<_> = args + .iter() + .map(|value| value.to_string_lossy().into_owned()) + .collect(); + assert_eq!( + rendered, + [ + "--session", + "session-a", + "container", + "logs", + "--follow", + "--timestamps", + "--details", + "--since", + "1", + "--until", + "2", + "--tail", + "3", + "container-b", + ] + ); + assert_eq!( + LogsOptions::new() + .follow(true) + .timestamps(true) + .details(true) + .as_flags(), + wslc_sys::WSLC_LOGS_FLAG_FOLLOW + | wslc_sys::WSLC_LOGS_FLAG_TIMESTAMPS + | wslc_sys::WSLC_LOGS_FLAG_DETAILS + ); +} + +#[test] +fn builder_style_options_preserve_values() { + let pull = ImagePullOptions::new("alpine:latest").registry_auth("secret"); + assert_eq!(pull.uri, "alpine:latest"); + assert_eq!(pull.registry_auth.as_deref(), Some("secret")); + + let process = ProcessOptions::new(["/bin/echo"]) + .working_dir("/tmp") + .env("A", "B") + .capture_stdout() + .capture_stderr() + .inherit_output() + .streaming(); + assert_eq!(process.cmdline, vec!["/bin/echo"]); + assert_eq!(process.working_dir.as_deref(), Some("/tmp")); + assert_eq!(process.env, vec![("A".to_owned(), "B".to_owned())]); + + let vhd = VhdOptions::new("cache", 1024) + .vhd_type(VhdType::Fixed) + .owner(1000, 1000); + assert_eq!(vhd.name, "cache"); + assert_eq!(vhd.size_bytes, 1024); + assert_eq!(vhd.vhd_type, VhdType::Fixed); + assert_eq!(vhd.owner, Some((1000, 1000))); + + let delete = DeleteContainerOptions::default().force(true); + assert!(delete.force); +} + +#[test] +fn process_options_reject_nul_and_invalid_env_before_loading_sdk() { + let err = ProcessOptions::new(["/bin/echo", "hello\0world"]) + .validate() + .unwrap_err(); + + assert!(matches!(err, Error::Nul(field) if field == "process arg")); + + let err = ProcessOptions::new(["/bin/pwd"]) + .working_dir("/tmp\0x") + .validate() + .unwrap_err(); + + assert!(matches!(err, Error::Nul(field) if field == "working_dir")); + + let err = ProcessOptions::new(["/bin/env"]) + .env("", "value") + .validate() + .unwrap_err(); + + assert!(matches!(err, Error::InvalidInput(message) if message.contains("env variable key"))); + + let err = ProcessOptions::new(["/bin/env"]) + .env("A=B", "value") + .validate() + .unwrap_err(); + + assert!(matches!(err, Error::InvalidInput(message) if message.contains("env variable key"))); + + let err = ProcessOptions::new(["/bin/env"]) + .env("A", "va\0lue") + .validate() + .unwrap_err(); + + assert!(matches!(err, Error::Nul(field) if field == "env")); +} + +#[test] +fn timeout_validation_rejects_values_that_do_not_fit_u32_milliseconds() { + let err = Session::builder("test", PathBuf::from(r"C:\WslcData\test")) + .timeout(Duration::from_millis(u64::from(u32::MAX) + 1)) + .start() + .unwrap_err(); + + assert!(matches!(err, Error::InvalidInput(message) if message.contains("timeout"))); +} + +#[test] +fn hresult_errors_expose_original_code_and_wslc_kind() { + let err = Error::from_hresult(0x8004_0603_u32 as i32, "missing container"); + + assert_eq!(err.hresult(), Some(0x8004_0603_u32 as i32)); + assert_eq!(err.wslc_kind(), Some(WslcErrorKind::ContainerNotFound)); + assert!(err.to_string().contains("missing container")); +} + +#[test] +fn known_wslc_hresult_codes_map_to_error_kinds() { + let cases = [ + (0x8004_0601_u32 as i32, WslcErrorKind::ImageNotFound), + ( + 0x8004_0602_u32 as i32, + WslcErrorKind::ContainerPrefixAmbiguous, + ), + (0x8004_0603_u32 as i32, WslcErrorKind::ContainerNotFound), + (0x8004_0604_u32 as i32, WslcErrorKind::VolumeNotFound), + (0x8004_0605_u32 as i32, WslcErrorKind::ContainerNotRunning), + (0x8004_0606_u32 as i32, WslcErrorKind::ContainerIsRunning), + (0x8004_0607_u32 as i32, WslcErrorKind::SessionReserved), + (0x8004_0608_u32 as i32, WslcErrorKind::InvalidSessionName), + (0x8004_0609_u32 as i32, WslcErrorKind::NetworkNotFound), + ( + 0x8004_060A_u32 as i32, + WslcErrorKind::WindowsUpdateSearchFailed, + ), + (0x8004_060B_u32 as i32, WslcErrorKind::SdkUpdateNeeded), + (0x8004_060C_u32 as i32, WslcErrorKind::ContainerDisabled), + ( + 0x8004_060D_u32 as i32, + WslcErrorKind::RegistryBlockedByPolicy, + ), + (0x8004_060E_u32 as i32, WslcErrorKind::VolumeNotAvailable), + (0x8004_060F_u32 as i32, WslcErrorKind::SessionNotFound), + ]; + + for (code, expected) in cases { + assert_eq!( + Error::from_hresult(code, "case").wslc_kind(), + Some(expected) + ); + } + + assert_eq!( + Error::from_hresult(0x8004_9999_u32 as i32, "unknown").wslc_kind(), + Some(WslcErrorKind::Unknown(0x8004_9999_u32 as i32)) + ); + assert_eq!( + Error::from_hresult(0x8007_0005_u32 as i32, "other").wslc_kind(), + None + ); +} + +#[test] +fn raw_sdk_enums_map_to_safe_unknown_variants() { + assert_eq!( + ContainerState::from(wslc_sys::WslcContainerState::WSLC_CONTAINER_STATE_CREATED), + ContainerState::Created + ); + assert_eq!( + ContainerState::from(wslc_sys::WslcContainerState::WSLC_CONTAINER_STATE_RUNNING), + ContainerState::Running + ); + assert_eq!( + ContainerState::from(wslc_sys::WslcContainerState::WSLC_CONTAINER_STATE_EXITED), + ContainerState::Exited + ); + assert_eq!( + ContainerState::from(wslc_sys::WslcContainerState::WSLC_CONTAINER_STATE_DELETED), + ContainerState::Deleted + ); + assert_eq!( + ContainerState::from(wslc_sys::WslcContainerState::WSLC_CONTAINER_STATE_INVALID), + ContainerState::Invalid + ); + + assert_eq!( + ProcessState::from(wslc_sys::WslcProcessState::WSLC_PROCESS_STATE_RUNNING), + ProcessState::Running + ); + assert_eq!( + ProcessState::from(wslc_sys::WslcProcessState::WSLC_PROCESS_STATE_EXITED), + ProcessState::Exited + ); + assert_eq!( + ProcessState::from(wslc_sys::WslcProcessState::WSLC_PROCESS_STATE_SIGNALLED), + ProcessState::Signalled + ); + assert_eq!( + ProcessState::from(wslc_sys::WslcProcessState::WSLC_PROCESS_STATE_UNKNOWN), + ProcessState::Unknown + ); + + assert_eq!( + ImageProgressStatus::from( + wslc_sys::WslcImageProgressStatus::WSLC_IMAGE_PROGRESS_STATUS_PULLING, + ), + ImageProgressStatus::Pulling + ); + assert_eq!( + ImageProgressStatus::from( + wslc_sys::WslcImageProgressStatus::WSLC_IMAGE_PROGRESS_STATUS_WAITING, + ), + ImageProgressStatus::Waiting + ); + assert_eq!( + ImageProgressStatus::from( + wslc_sys::WslcImageProgressStatus::WSLC_IMAGE_PROGRESS_STATUS_DOWNLOADING, + ), + ImageProgressStatus::Downloading + ); + assert_eq!( + ImageProgressStatus::from( + wslc_sys::WslcImageProgressStatus::WSLC_IMAGE_PROGRESS_STATUS_VERIFYING, + ), + ImageProgressStatus::Verifying + ); + assert_eq!( + ImageProgressStatus::from( + wslc_sys::WslcImageProgressStatus::WSLC_IMAGE_PROGRESS_STATUS_EXTRACTING, + ), + ImageProgressStatus::Extracting + ); + assert_eq!( + ImageProgressStatus::from( + wslc_sys::WslcImageProgressStatus::WSLC_IMAGE_PROGRESS_STATUS_COMPLETE, + ), + ImageProgressStatus::Complete + ); + assert_eq!( + ImageProgressStatus::from( + wslc_sys::WslcImageProgressStatus::WSLC_IMAGE_PROGRESS_STATUS_UNKNOWN, + ), + ImageProgressStatus::Unknown + ); +} + +#[test] +fn component_flags_and_version_are_plain_value_types() { + let flags = ComponentFlags::from_bits_retain( + ComponentFlags::VIRTUAL_MACHINE_PLATFORM.bits() + | ComponentFlags::WSL_PACKAGE.bits() + | 0x8000_0000, + ); + + assert!(ComponentFlags::NONE.is_empty()); + assert!(!flags.is_empty()); + assert!(flags.contains(ComponentFlags::VIRTUAL_MACHINE_PLATFORM)); + assert!(flags.contains(ComponentFlags::WSL_PACKAGE)); + assert!(!flags.contains(ComponentFlags::SDK_NEEDS_UPDATE)); + assert_eq!( + format!("{:?}", ComponentFlags::NONE), + "ComponentFlags(NONE)" + ); + assert!(format!("{flags:?}").contains("VIRTUAL_MACHINE_PLATFORM")); + + let version = Version::from(wslc_sys::WslcVersion { + major: 2, + minor: 9, + revision: 3, + }); + assert_eq!(version.major, 2); + assert_eq!(version.minor, 9); + assert_eq!(version.revision, 3); +} + +#[test] +fn service_reports_missing_sdk_as_sdk_not_found_or_components() { + let result = Service::version(); + + if let Err(error) = result { + assert!( + matches!( + error, + Error::SdkNotFound(_) | Error::MissingComponents(_) | Error::UnsupportedPlatform(_) + ), + "unexpected error: {error:?}" + ); + } +} + +#[test] +fn signal_values_match_linux_signal_numbers() { + let _networking = [NetworkingMode::None, NetworkingMode::Bridged]; + assert_eq!(Signal::Sigterm.as_raw(), 15); + assert_eq!(Signal::Sigkill.as_raw(), 9); + assert_eq!(Signal::Sighup.as_raw(), 1); + assert_eq!(Signal::Sigint.as_raw(), 2); + assert_eq!(Signal::Sigquit.as_raw(), 3); +} + +#[test] +fn safe_crate_sources_do_not_contain_unsafe_code() { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let src_dir = manifest_dir.join("src"); + let mut violations = Vec::new(); + + for entry in std::fs::read_dir(&src_dir).expect("read src dir") { + let path = entry.expect("read src entry").path(); + if path.extension().and_then(|value| value.to_str()) != Some("rs") { + continue; + } + + let source = std::fs::read_to_string(&path).expect("read source file"); + for (line_number, line) in source.lines().enumerate() { + if line.contains("unsafe") || line.contains("extern \"system\"") { + violations.push(format!( + "{}:{}: {}", + path.strip_prefix(&manifest_dir).unwrap().display(), + line_number + 1, + line.trim() + )); + } + } + } + + assert!( + violations.is_empty(), + "the safe wslc crate must not contain unsafe code:\n{}", + violations.join("\n") + ); +} + +#[test] +fn sys_runtime_wraps_process_string_arrays_as_pcstr_values() { + let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|path| path.parent()) + .expect("workspace root") + .to_path_buf(); + let runtime = std::fs::read_to_string(workspace.join("crates/wslc-sys/src/runtime.rs")) + .expect("read wslc-sys runtime"); + + assert!( + runtime.contains("argv.as_ptr().cast()"), + "process argv must pass a pointer to PCSTR values, not raw C string pointers" + ); + assert!( + runtime.contains("env.as_ptr().cast()"), + "process env must pass a pointer to PCSTR values, not raw C string pointers" + ); +} + +#[test] +fn process_raw_settings_keep_string_storage_alive() { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let process_source = + std::fs::read_to_string(manifest_dir.join("src/process.rs")).expect("read process source"); + + assert!( + process_source.contains("pub(crate) struct RawProcessSettings"), + "process raw settings should own backing CString storage" + ); + assert!( + process_source.contains("_argv: RetainedCStringArray"), + "process argv strings and pointer array must outlive the SDK settings call" + ); + assert!( + process_source.contains("_env: RetainedCStringArray"), + "process env strings and pointer array must outlive the SDK settings call" + ); + assert!( + process_source.contains("pointers: Vec"), + "process settings must retain the pointer arrays borrowed by the SDK" + ); + assert!( + process_source.contains("_working_dir: Option"), + "process working directory storage must outlive the SDK settings call" + ); +} + +#[test] +fn repository_links_point_to_ivanbeethoven_repo() { + let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|path| path.parent()) + .expect("workspace root") + .to_path_buf(); + let expected = "https://github.com/Ivanbeethoven/wslc-rs"; + let forbidden = [ + "github.com/hxy9243/wslc-rs", + "github.com//wslc-rs", + ]; + let files = [ + "Cargo.toml", + "README.md", + "docs/sdk-installation.md", + "docs/build-and-linking.md", + "wslc-rust-api-design.md", + "crates/wslc/README.md", + "crates/wslc-sys/README.md", + ]; + + let mut violations = Vec::new(); + for file in files { + let path = workspace.join(file); + let source = std::fs::read_to_string(&path).expect("read repository metadata file"); + for value in forbidden { + if source.contains(value) { + violations.push(format!("{file} contains {value}")); + } + } + } + + let manifest = + std::fs::read_to_string(workspace.join("Cargo.toml")).expect("read workspace manifest"); + assert!( + manifest.contains(expected), + "workspace manifest must contain {expected}" + ); + assert!( + violations.is_empty(), + "repository link violations:\n{}", + violations.join("\n") + ); +} + +#[test] +fn sdk_installation_guide_is_linked_from_public_readmes() { + let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|path| path.parent()) + .expect("workspace root") + .to_path_buf(); + + assert!( + workspace.join("docs/sdk-installation.md").is_file(), + "missing SDK installation guide" + ); + + for file in ["README.md", "crates/wslc/README.md"] { + let source = + std::fs::read_to_string(workspace.join(file)).expect("read public README file"); + assert!( + source.contains("sdk-installation.md"), + "{file} should link to the SDK installation guide" + ); + } +} + +#[test] +fn public_docs_do_not_contain_private_registry_mirrors() { + let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|path| path.parent()) + .expect("workspace root") + .to_path_buf(); + let files = [ + "README.md", + "docs/sdk-installation.md", + "crates/wslc/README.md", + "crates/wslc/examples/hello.rs", + "crates/wslc/examples/container_inspect.rs", + ]; + let forbidden = ["xuanyuan.run", "w23geq", "um1ao"]; + + let mut violations = Vec::new(); + for file in files { + let source = std::fs::read_to_string(workspace.join(file)).expect("read public doc file"); + for value in forbidden { + if source.contains(value) { + violations.push(format!("{file} contains {value}")); + } + } + } + + assert!( + violations.is_empty(), + "private registry mirror leaked into public docs:\n{}", + violations.join("\n") + ); +} + +#[test] +fn sdk_validation_examples_are_present() { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let examples = [ + "hello.rs", + "service_info.rs", + "list_images.rs", + "vhd_volume.rs", + "container_inspect.rs", + "container_logs.rs", + ]; + + for example in examples { + assert!( + manifest_dir.join("examples").join(example).is_file(), + "missing SDK validation example: {example}" + ); + } +} diff --git a/vendor/wslc-rs/crates/wslc/tests/debug_com.rs b/vendor/wslc-rs/crates/wslc/tests/debug_com.rs new file mode 100644 index 0000000..6558da4 --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/tests/debug_com.rs @@ -0,0 +1,47 @@ +#![cfg(feature = "integration")] +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use wslc::{Service, Session}; + +fn unique_name(prefix: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("{prefix}-{nanos}-{}", std::process::id()) +} + +fn storage_path(name: &str) -> PathBuf { + PathBuf::from(format!(r"C:\WslcData\{name}")) +} + +#[test] +fn direct_start_without_ensure_available() { + let name = unique_name("wslc-rs-direct"); + println!("Starting session: {name}"); + // Skip Service::ensure_available() — call start() directly + let session = Session::builder(&name, storage_path(&name)) + .cpu_count(2) + .memory_mb(2048) + .timeout(Duration::from_secs(180)) + .terminate_on_drop(true) + .start() + .expect("session should start directly"); + println!("Session started OK, terminating..."); + session.terminate().expect("session should terminate"); +} + +#[test] +fn two_createsessionmanager_calls() { + // First call + let v1 = Service::version().expect("first version call"); + println!("First version call OK: {v1:?}"); + + // Second call - same as what CreateSession does + let v2 = Service::version().expect("second version call"); + println!("Second version call OK: {v2:?}"); + + // Third call + let v3 = Service::version().expect("third version call"); + println!("Third version call OK: {v3:?}"); +} diff --git a/vendor/wslc-rs/crates/wslc/tests/debug_com3.rs b/vendor/wslc-rs/crates/wslc/tests/debug_com3.rs new file mode 100644 index 0000000..49ee207 --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/tests/debug_com3.rs @@ -0,0 +1,44 @@ +#![cfg(feature = "integration")] +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use wslc::{Service, Session}; + +fn unique_name(prefix: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("{prefix}-{nanos}-{}", std::process::id()) +} + +#[test] +fn version_after_ensure_available() { + let v1 = Service::version().expect("v1"); + println!("v1: {v1:?}"); + + Service::ensure_available().expect("ensure"); + println!("ensure OK"); + + // After ensure_available (which calls version), version still works + let v2 = Service::version().expect("v2 after ensure"); + println!("v2: {v2:?}"); +} + +#[test] +fn version_then_ensure_then_start() { + let v1 = Service::version().expect("v1"); + println!("v1: {v1:?}"); + + Service::ensure_available().expect("ensure"); + println!("ensure OK"); + + let name = unique_name("test"); + Session::builder(&name, PathBuf::from(format!(r"C:\WslcData\{name}"))) + .cpu_count(2) + .memory_mb(2048) + .timeout(Duration::from_secs(180)) + .terminate_on_drop(true) + .start() + .expect("session should start"); + println!("start OK"); +} diff --git a/vendor/wslc-rs/crates/wslc/tests/exec_capture.rs b/vendor/wslc-rs/crates/wslc/tests/exec_capture.rs new file mode 100644 index 0000000..6cd7c6a --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/tests/exec_capture.rs @@ -0,0 +1,63 @@ +#![cfg(feature = "integration")] +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; +use wslc::{ContainerOptions, ImagePullOptions, ProcessOptions, Session}; + +fn unique_name(prefix: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("{}-{}-{}", prefix, nanos, std::process::id()) +} + +fn session_dir(name: &str) -> PathBuf { + PathBuf::from(format!("C:\\WslcData\\{name}")) +} + +#[test] +fn exec_captures_stdout() { + let name = unique_name("exec-test"); + let session = Session::builder(&name, session_dir(&name)) + .cpu_count(2) + .memory_mb(1024) + .terminate_on_drop(true) + .start() + .expect("session should start"); + + let mirror = std::env::var("WSLC_REGISTRY_MIRROR").ok(); + let image = mirror + .map(|m| format!("{m}/library/alpine:latest")) + .unwrap_or_else(|| "docker.io/library/alpine:latest".to_string()); + + session + .pull_image(ImagePullOptions::new(&image)) + .run() + .expect("pull"); + + let container = session + .container(ContainerOptions::new(&image)) + .name(format!("{name}-container")) + .init_process(ProcessOptions::new(["/bin/sleep", "60"])) + .auto_remove(true) + .create() + .expect("container create"); + + container.start().expect("container start"); + + let output = container + .exec(ProcessOptions::new(["/bin/echo", "hello-from-exec"]).capture_stdout()) + .expect("exec should succeed") + .wait_with_output() + .expect("wait should succeed"); + + assert_eq!(output.status, 0); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "hello-from-exec" + ); + + container + .stop(wslc::Signal::Sigkill, std::time::Duration::from_secs(5)) + .ok(); +} diff --git a/vendor/wslc-rs/crates/wslc/tests/fuse_privileged.rs b/vendor/wslc-rs/crates/wslc/tests/fuse_privileged.rs new file mode 100644 index 0000000..2425f20 --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/tests/fuse_privileged.rs @@ -0,0 +1,92 @@ +#![cfg(feature = "integration")] + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use wslc::{ContainerOptions, ImagePullOptions, ProcessOptions, Service, Session}; + +fn unique_name(prefix: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + format!("{prefix}-{nanos}-{}", std::process::id()) +} + +fn storage_path(name: &str) -> String { + format!(r"C:\WslcData\{name}") +} + +#[test] +fn privileged_container_fuse_test() { + Service::ensure_available().expect("WSLC service should be available"); + + let name = unique_name("wslc-rs-fuse"); + let session = Session::builder(&name, storage_path(&name)) + .cpu_count(2) + .memory_mb(2048) + .timeout(Duration::from_secs(300)) + .terminate_on_drop(true) + .start() + .expect("session should start"); + + let pull_opts = ImagePullOptions::new("docker.io/library/debian:trixie-slim"); + session + .pull_image(pull_opts) + .on_progress(|p| { + eprintln!("pull: {:?} {}/{}", p.status, p.current_bytes, p.total_bytes); + }) + .run() + .expect("should pull debian image"); + + let script = r#" +set -eu +echo "=== SECCOMP ===" +cat /proc/1/status | grep -i seccomp +echo "=== CAPS ===" +cat /proc/1/status | grep Cap +echo "=== FUSE MODULES ===" +grep fuse /proc/filesystems || echo "NO_FUSE_MODULES" +echo "=== FUSE DEV ===" +ls -la /dev/fuse +test -c /dev/fuse +echo "=== OPEN TEST ===" +if exec 3<>/dev/fuse; then + echo "FUSE_OPEN_OK" + exec 3>&- +else + echo "FUSE_OPEN_FAILED" + exit 1 +fi +echo "=== DONE ===" +"#; + + let cname = unique_name("c"); + let cmdline = vec!["/bin/sh".to_string(), "-c".to_string(), script.to_string()]; + + let output = session + .container(ContainerOptions::new( + "docker.io/library/debian:trixie-slim", + )) + .name(&cname) + .privileged(true) + .init_process(ProcessOptions { + cmdline, + working_dir: None, + env: Vec::new(), + output_mode: wslc::OutputMode::Capture, + }) + .create() + .expect("should create privileged container") + .start_and_wait() + .expect("should start and wait"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("=== STDOUT ===\n{stdout}"); + eprintln!("=== STDERR ===\n{stderr}"); + + let combined = format!("{stdout}{stderr}"); + assert!( + combined.contains("FUSE_OPEN_OK"), + "FUSE device should be accessible in privileged container!\nOutput:\n{combined}" + ); +} diff --git a/vendor/wslc-rs/crates/wslc/tests/integration_smoke.rs b/vendor/wslc-rs/crates/wslc/tests/integration_smoke.rs new file mode 100644 index 0000000..10c3980 --- /dev/null +++ b/vendor/wslc-rs/crates/wslc/tests/integration_smoke.rs @@ -0,0 +1,213 @@ +#![cfg(feature = "integration")] + +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use std::sync::{Arc, Mutex}; + +use wslc::{ + Error, ImageProgress, ImageProgressStatus, ImagePullOptions, ProcessOptions, Service, Session, + VhdOptions, WslcErrorKind, +}; + +const DEFAULT_ALPINE_IMAGE: &str = "docker.io/library/alpine:latest"; + +fn unique_name(prefix: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + format!("{prefix}-{nanos}-{}", std::process::id()) +} + +fn storage_path(name: &str) -> PathBuf { + std::env::var_os("WSLC_TEST_STORAGE_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\WslcData")) + .join(name) +} + +fn start_session(prefix: &str) -> Session { + Service::ensure_available().expect("WSLC service should be available for integration tests"); + + let name = unique_name(prefix); + Session::builder(&name, storage_path(&name)) + .cpu_count(2) + .memory_mb(2048) + .timeout(Duration::from_secs(180)) + .terminate_on_drop(true) + .start() + .expect("session should start") +} + +#[test] +fn service_is_available_when_integration_feature_is_enabled() { + Service::ensure_available().expect("WSLC service should be available for integration tests"); +} + +#[test] +fn service_reports_no_missing_components_and_nonzero_version() { + Service::ensure_available().expect("WSLC service should be available for integration tests"); + + let missing = Service::missing_components().expect("missing component check should succeed"); + assert!( + missing.is_empty(), + "integration host should have all WSLC components installed, got {missing:?}" + ); + + let version = Service::version().expect("version should be reported by real WSLC runtime"); + assert!( + version.major > 0 || version.minor > 0 || version.revision > 0, + "WSLC version should not be all zeros: {version:?}" + ); +} + +#[test] +fn session_lifecycle_smoke_test() { + let session = start_session("wslc-rs-session-lifecycle"); + + session.terminate().expect("session should terminate"); +} + +#[test] +fn empty_session_can_list_images() { + let session = start_session("wslc-rs-list-images"); + + let images = session + .list_images() + .expect("fresh WSLC session should be able to list images"); + assert!( + images.is_empty(), + "fresh session should not inherit images from another integration run: {images:?}" + ); + + session.terminate().expect("session should terminate"); +} + +#[test] +fn custom_session_vhd_on_configured_storage_root_can_list_images() { + Service::ensure_available().expect("WSLC service should be available for integration tests"); + + let name = unique_name("wslc-rs-session-vhd"); + let session = Session::builder(&name, storage_path(&name)) + .vhd(VhdOptions::new("storage", 64 * 1024 * 1024 * 1024)) + .terminate_on_drop(true) + .start() + .expect("session with a custom VHD should start"); + session + .list_images() + .expect("session with a custom VHD should list images"); + session.terminate().expect("session should terminate"); +} + +#[test] +fn real_wslc_errors_are_mapped_to_typed_kinds() { + let session = start_session("wslc-rs-errors"); + + let image_error = session + .delete_image("wslc-rs-missing-image:latest") + .expect_err("deleting a missing image should fail through WSLC"); + assert_eq!( + image_error.wslc_kind(), + Some(WslcErrorKind::ImageNotFound), + "unexpected missing image error: {image_error:?}" + ); + + let volume_error = session + .delete_vhd_volume("wslc-rs-missing-volume") + .expect_err("deleting a missing VHD volume should fail through WSLC"); + assert_eq!( + volume_error.wslc_kind(), + Some(WslcErrorKind::VolumeNotFound), + "unexpected missing volume error: {volume_error:?}" + ); + + session.terminate().expect("session should terminate"); +} + +#[test] +fn create_and_delete_vhd_volume_smoke_test() { + let session = start_session("wslc-rs-vhd"); + let volume = unique_name("wslc-rs-volume"); + + session + .create_vhd_volume(VhdOptions::new(&volume, 64 * 1024 * 1024)) + .expect("VHD volume should be created by real WSLC"); + session + .delete_vhd_volume(&volume) + .expect("VHD volume should be deleted by real WSLC"); + + let err = session + .delete_vhd_volume(&volume) + .expect_err("deleting the VHD volume twice should fail"); + assert_eq!(err.wslc_kind(), Some(WslcErrorKind::VolumeNotFound)); + + session.terminate().expect("session should terminate"); +} + +#[test] +fn rust_side_validation_runs_before_wslc_calls() { + let err = Session::builder(" ", storage_path("wslc-rs-invalid")) + .start() + .expect_err("blank session name should be rejected before WSLC call"); + assert!( + matches!(err, Error::InvalidInput(_)), + "expected InvalidInput, got {err:?}" + ); +} + +#[test] +#[ignore = "requires registry access and pulls an Alpine image"] +fn alpine_echo_smoke_test() { + let session = start_session("wslc-rs-integration"); + + let progress_events = Arc::new(Mutex::new(Vec::::new())); + let progress_sink = Arc::clone(&progress_events); + session + .pull_image(ImagePullOptions::new(DEFAULT_ALPINE_IMAGE)) + .on_progress(move |progress| { + progress_sink + .lock() + .expect("progress mutex should not be poisoned") + .push(progress); + }) + .run() + .expect("alpine pull should succeed"); + + let progress_events = progress_events + .lock() + .expect("progress mutex should not be poisoned"); + assert!( + progress_events + .iter() + .any(|event| event.status == ImageProgressStatus::Complete), + "image pull should report at least one complete progress event: {progress_events:?}" + ); + drop(progress_events); + + let images = session + .list_images() + .expect("session should list pulled images"); + assert!( + images.iter().any(|image| image.name.contains("alpine")), + "pulled alpine image should be listed: {images:?}" + ); + + let output = session + .container(wslc::ContainerOptions::new(DEFAULT_ALPINE_IMAGE)) + .name("wslc-rs-integration-echo") + .init_process(ProcessOptions::new(["/bin/echo", "hello from wslc-rs"]).capture_stdout()) + .auto_remove(true) + .create() + .expect("container should be created") + .start_and_wait() + .expect("container should run"); + + assert_eq!(output.status, 0); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "hello from wslc-rs" + ); + + session.terminate().expect("session should terminate"); +} diff --git a/vendor/wslc-rs/docs/api-design.md b/vendor/wslc-rs/docs/api-design.md new file mode 100644 index 0000000..e270ecf --- /dev/null +++ b/vendor/wslc-rs/docs/api-design.md @@ -0,0 +1,320 @@ +# Rust API 设计 + +## 1. 对外模块 + +```rust +pub mod service; +pub mod session; +pub mod container; +pub mod process; +pub mod image; +pub mod storage; +pub mod error; + +pub use service::Service; +pub use session::{Session, SessionBuilder}; +pub use container::{Container, ContainerBuilder, ContainerOptions}; +pub use process::{Process, ProcessOptions, Output, OutputMode}; +pub use image::{ImageInfo, ImagePullOptions, ImageProgress, ImageProgressStatus}; +pub use storage::{VhdOptions, VhdType}; +pub use error::{Error, Result}; +``` + +## 2. Service API + +```rust +impl Service { + pub fn missing_components() -> Result; + pub fn version() -> Result; + pub fn ensure_available() -> Result<()>; + pub fn install_with_dependencies(progress: F) -> Result<()> + where + F: FnMut(InstallProgress) + Send + 'static; +} +``` + +### 设计说明 + +`ensure_available()` 是用户最常用的入口: + +```rust +Service::ensure_available()?; +``` + +内部逻辑: + +1. 调用 `WslcGetMissingComponents`; +2. 如果缺少组件,返回 `Error::MissingComponents`; +3. 调用 `WslcGetVersion`; +4. 可选检查 SDK/runtime 是否过旧。 + +## 3. Session API + +```rust +impl Session { + pub fn builder( + name: impl Into, + storage_path: impl Into, + ) -> SessionBuilder; + + pub fn pull_image(&self, options: ImagePullOptions) -> ImagePullOperation; + + pub fn list_images(&self) -> Result>; + pub fn delete_image(&self, name_or_id: impl AsRef) -> Result<()>; + pub fn tag_image(&self, image: impl AsRef, repo: impl AsRef, tag: impl AsRef) -> Result<()>; + + pub fn container(&self, options: ContainerOptions) -> ContainerBuilder; + pub fn open_container(&self, name_or_id: impl AsRef) -> Result; + pub fn logs(&self, container_id: impl AsRef, options: LogsOptions) -> Result; + + pub fn create_vhd_volume(&self, options: VhdOptions) -> Result<()>; + pub fn delete_vhd_volume(&self, name: impl AsRef) -> Result<()>; + + pub fn terminate(&self) -> Result<()>; +} +``` + +### SessionBuilder + +```rust +pub struct SessionBuilder { + name: String, + storage_path: PathBuf, + cpu_count: Option, + memory_mb: Option, + timeout_ms: Option, + enable_gpu: bool, + vhd: Option, + terminate_on_drop: bool, +} + +impl SessionBuilder { + pub fn cpu_count(mut self, cpu_count: u32) -> Self; + pub fn memory_mb(mut self, memory_mb: u32) -> Self; + pub fn timeout(mut self, timeout: Duration) -> Self; + pub fn enable_gpu(mut self, enable: bool) -> Self; + pub fn vhd(mut self, options: VhdOptions) -> Self; + pub fn terminate_on_drop(mut self, enable: bool) -> Self; + pub fn start(self) -> Result; +} +``` + +## 4. Image API + +```rust +pub struct ImagePullOptions { + pub uri: String, + pub registry_auth: Option, +} + +impl ImagePullOptions { + pub fn new(uri: impl Into) -> Self; +} + +pub struct ImagePullOperation<'a> { + session: &'a Session, + options: ImagePullOptions, +} + +impl<'a> ImagePullOperation<'a> { + pub fn on_progress(self, f: F) -> Self + where + F: FnMut(ImageProgress) + Send + 'static; + + pub fn run(self) -> Result<()>; +} +``` + +### ImageProgress + +```rust +pub struct ImageProgress { + pub id: String, + pub status: ImageProgressStatus, + pub current_bytes: u64, + pub total_bytes: u64, +} + +pub enum ImageProgressStatus { + Unknown, + Pulling, + Waiting, + Downloading, + Verifying, + Extracting, + Complete, +} +``` + +## 5. Container API + +```rust +pub struct ContainerOptions { + pub image: String, +} + +impl ContainerOptions { + pub fn new(image: impl Into) -> Self; +} + +impl Session { + pub fn container(&self, options: ContainerOptions) -> ContainerBuilder; +} + +pub struct ContainerBuilder { + session: Session, + options: ContainerOptions, + name: Option, + hostname: Option, + domain_name: Option, + networking: NetworkingMode, + flags: ContainerFlags, + init_process: Option, + ports: Vec, + volumes: Vec, + named_volumes: Vec, +} + +impl ContainerBuilder { + pub fn name(mut self, name: impl Into) -> Self; + pub fn hostname(mut self, hostname: impl Into) -> Self; + pub fn domain_name(mut self, domain: impl Into) -> Self; + pub fn networking(mut self, mode: NetworkingMode) -> Self; + pub fn auto_remove(mut self, enable: bool) -> Self; + pub fn privileged(mut self, enable: bool) -> Self; + pub fn enable_gpu(mut self, enable: bool) -> Self; + pub fn init_process(mut self, process: ProcessOptions) -> Self; + pub fn port(mut self, windows_port: u16, container_port: u16) -> Self; + pub fn volume(mut self, windows_path: impl Into, container_path: impl Into) -> Self; + pub fn create(self) -> Result; +} +``` + +### Container methods + +```rust +impl Container { + pub fn id(&self) -> Result; + pub fn start(&self) -> Result<()>; + pub fn start_attached(&self) -> Result<()>; + pub fn start_and_wait(&self) -> Result; + pub fn inspect(&self) -> Result; + pub fn state(&self) -> Result; + + pub fn exec(&self, options: ProcessOptions) -> Result; + pub fn logs(&self, options: LogsOptions) -> Result; + + pub fn stop(&self, signal: Signal, timeout: Duration) -> Result<()>; + pub fn delete(&self, options: DeleteContainerOptions) -> Result<()>; +} +``` + +### Container logs + +The public C SDK (`wslcsdk.h`) does not yet export a logs API. The service COM / +CLI surface does (`wslc container logs`, `IWSLCContainer::Logs`). The safe Rust +wrapper therefore exposes `LogsOptions` / `ContainerLogs` and retrieves logs +through the `wslc` CLI until a C export is available. + +```rust +pub struct LogsOptions { + pub follow: bool, + pub timestamps: bool, + pub details: bool, + pub since: Option, + pub until: Option, + pub tail: Option, +} + +pub struct ContainerLogs { + pub stdout: Vec, + pub stderr: Vec, +} +``` + +## 6. Process API + +```rust +pub struct ProcessOptions { + pub cmdline: Vec, + pub working_dir: Option, + pub env: Vec<(String, String)>, + pub output_mode: OutputMode, +} + +impl ProcessOptions { + pub fn new(args: I) -> Self + where + I: IntoIterator, + S: Into; + + pub fn working_dir(mut self, dir: impl Into) -> Self; + pub fn env(mut self, key: impl Into, value: impl Into) -> Self; + pub fn capture_stdout(mut self) -> Self; + pub fn capture_stderr(mut self) -> Self; + pub fn inherit_output(mut self) -> Self; + pub fn streaming(mut self) -> Self; +} +``` + +### Process methods + +```rust +impl Process { + pub fn pid(&self) -> Result; + pub fn state(&self) -> Result; + pub fn signal(&self, signal: Signal) -> Result<()>; + pub fn exit_code(&self) -> Result>; + pub fn wait(&self) -> Result; + pub fn wait_with_output(&self) -> Result; +} +``` + +## 7. 输出模式 + +```rust +pub enum OutputMode { + Null, + Capture, + Streaming { + stdout: Option>, + stderr: Option>, + }, +} + +pub struct Output { + pub status: i32, + pub stdout: Vec, + pub stderr: Vec, +} +``` + +MVP 可以先只支持 `Capture`。 +流式 stdout/stderr 涉及 callback 生命周期和跨线程数据转移,应放在第二阶段。 + +## 8. 类型转换原则 + +| C 类型 | Rust 类型 | +|---|---| +| `PCWSTR` | `U16CString` / `Vec` | +| `PCSTR` | `CString` | +| `PWSTR*` error message | `CoTaskMemWString`,转 `String` 后释放 | +| `PSTR*` inspect data | `CoTaskMemCString`,转 `String` 后释放 | +| `HANDLE` | `OwnedHandle` / raw `HANDLE` wrapper | +| `HRESULT` | `Result` | +| `BOOL` | `bool` | +| bitflags enum | `bitflags!` | + +## 9. 命名风格 + +Rust API 不直接暴露 `Wslc` 前缀。 +例如: + +```rust +WslcCreateSession -> SessionBuilder::start() +WslcPullSessionImage -> Session::pull_image(...).run() +WslcCreateContainer -> Session::container(...).create() +WslcCreateContainerProcess -> Container::exec(...) +``` + +这样文档读起来像 Rust,而不是 C API 的一层薄皮。 diff --git a/vendor/wslc-rs/docs/architecture.md b/vendor/wslc-rs/docs/architecture.md new file mode 100644 index 0000000..a86e05b --- /dev/null +++ b/vendor/wslc-rs/docs/architecture.md @@ -0,0 +1,221 @@ +# 架构设计 + +## 1. 总体分层 + +```text +Application + │ + ▼ +wslc Safe Rust API + │ - builder pattern + │ - RAII handle ownership + │ - Result + │ - output streaming / async adapter + ▼ +wslc-sys Unsafe FFI + │ - raw handles + │ - raw HRESULT + │ - raw callbacks + │ - raw pointers + ▼ +wslcsdk.dll Microsoft WSL Container SDK + │ + ▼ +WSL / WSLC runtime +``` + +## 2. crate 划分 + +### `wslc-sys` + +职责: + +- 暴露 `extern "system"` FFI; +- 定义 raw enum / struct / handle; +- 链接 `wslcsdk` 和 `ole32`; +- 不提供任何 safe API; +- 不在 Drop 中释放资源; +- 不把 `HRESULT` 转成业务错误。 + +设计原则: + +```rust +pub type WslcSession = *mut core::ffi::c_void; +pub type WslcContainer = *mut core::ffi::c_void; +pub type WslcProcess = *mut core::ffi::c_void; + +extern "system" { + pub fn WslcCreateSession( + settings: *mut WslcSessionSettings, + session: *mut WslcSession, + error_message: *mut *mut u16, + ) -> windows_sys::core::HRESULT; +} +``` + +### `wslc` + +职责: + +- 对外暴露安全 API; +- 将 `HRESULT` 和 `errorMessage` 转换成 `Error`; +- 用 Drop 释放 handle; +- 用 builder 保证参数校验; +- 用 `Arc` 维护父子对象生命周期; +- 用 callback trampoline 安全桥接 Rust 闭包; +- 可选提供 blocking / async 两套 API。 + +## 3. 对象关系 + +```text +Service + ├── missing_components() + ├── version() + └── install_with_dependencies() + +Session + ├── owns WslcSession + ├── pull_image() + ├── list_images() + ├── create_vhd_volume() + ├── container() + └── terminate() + +Container + ├── owns WslcContainer + ├── keeps Arc + ├── start() + ├── inspect() + ├── state() + ├── exec() + ├── stop() + └── delete() + +Process + ├── owns WslcProcess + ├── keeps Arc + ├── pid() + ├── wait() + ├── signal() + ├── exit_code() + └── output() +``` + +## 4. 生命周期原则 + +### 父对象必须长于子对象 + +Rust 封装应保证: + +- `Session` 活着时,其创建的 `Container` 才能安全存在; +- `Container` 活着时,其内部 `Process` 才能安全存在; +- `Process` 回调上下文必须至少活到进程退出或 handle release。 + +建议内部结构: + +```rust +pub struct Session { + inner: Arc, +} + +struct SessionInner { + raw: NonNull, +} + +pub struct Container { + inner: Arc, +} + +struct ContainerInner { + raw: NonNull, + session: Arc, +} + +pub struct Process { + inner: Arc, +} + +struct ProcessInner { + raw: NonNull, + container: Arc, +} +``` + +## 5. Drop 策略 + +`Drop` 只释放 SDK handle,不做破坏性操作: + +```text +Drop(Session) -> WslcReleaseSession +Drop(Container) -> WslcReleaseContainer +Drop(Process) -> WslcReleaseProcess +``` + +不要在 Drop 中自动: + +- `WslcTerminateSession` +- `WslcStopContainer` +- `WslcDeleteContainer` + +原因: + +- Drop 中执行阻塞 / 破坏性操作不可控; +- 用户可能希望容器继续运行; +- Rust panic unwind 时自动删除容器容易造成意外。 + +可以提供显式方法: + +```rust +session.terminate()?; +container.stop(Signal::Sigterm, Duration::from_secs(10))?; +container.delete(DeleteContainerOptions::default())?; +``` + +也可以提供 opt-in guard: + +```rust +let session = Session::builder(...).terminate_on_drop(true).start()?; +let container = session.container(...).delete_on_drop(true).create()?; +``` + +## 6. 同步与异步 API + +MVP 先提供 blocking API: + +```rust +session.pull_image(...).run()?; +container.start()?; +process.wait()?; +``` + +后续再基于 Windows wait handle / callback / threadpool 提供 async API: + +```rust +session.pull_image(...).await?; +let output = container.start_and_wait_async().await?; +``` + +建议不要一开始强绑定 Tokio,先提供 feature: + +```toml +[features] +default = ["blocking"] +tokio = ["dep:tokio"] +async-std = [] +``` + +## 7. CLI fallback + +可选单独实现: + +```text +wslc-cli +``` + +用于: + +- smoke test; +- 当 SDK 未安装但 CLI 可用时做临时 fallback; +- 对比 SDK 行为。 + +不要让主库默认走 CLI,否则错误模型和语义会变弱。 diff --git a/vendor/wslc-rs/docs/build-and-linking.md b/vendor/wslc-rs/docs/build-and-linking.md new file mode 100644 index 0000000..ee79a5f --- /dev/null +++ b/vendor/wslc-rs/docs/build-and-linking.md @@ -0,0 +1,182 @@ +# 构建与链接设计 + +## 1. 目标平台 + +MVP 只支持: + +```text +x86_64-pc-windows-msvc +``` + +后续支持: + +```text +aarch64-pc-windows-msvc +``` + +不建议优先支持 GNU Windows target,因为 Microsoft SDK 和 import lib 更偏 MSVC 工具链。 + +## 2. 依赖 + +建议依赖: + +```toml +[dependencies] +bitflags = "2" +thiserror = "2" +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_System_Com", + "Win32_System_Threading", +] } + +[build-dependencies] +``` + +可选: + +```toml +[features] +default = ["blocking"] +blocking = [] +tokio = ["dep:tokio"] +integration = [] +dynamic-loading = [] +``` + +## 3. 链接方案 + +### 方案 A:静态 import lib 链接 + +最简单: + +```rust +println!("cargo:rustc-link-search=native={lib_dir}"); +println!("cargo:rustc-link-lib=dylib=wslcsdk"); +println!("cargo:rustc-link-lib=dylib=ole32"); +``` + +要求: + +- 编译时能找到 `wslcsdk.lib`; +- 运行时能找到 `wslcsdk.dll`。 + +### 方案 B:运行时动态加载 + +通过 `LoadLibraryW` / `GetProcAddress` 加载 `wslcsdk.dll`。 + +优点: + +- 编译时不需要 `wslcsdk.lib`; +- 对 SDK 缺失的错误提示更友好; +- preview 阶段可检查函数是否存在。 + +缺点: + +- 代码更复杂; +- 每个函数都要做 function pointer; +- 类型签名仍需维护。 + +建议: + +- MVP 用方案 A; +- `dynamic-loading` feature 作为后续增强。 + +## 4. SDK 路径解析 + +建议 `build.rs` 搜索优先级: + +1. `WSLC_SDK_LIB_DIR` +2. `WSLC_SDK_DIR` +3. NuGet cache:`%USERPROFILE%\.nuget\packages\microsoft.wsl.containers\\...` +4. 报错,提示用户安装 SDK 或设置环境变量 + +示例错误: + +```text +Could not find wslcsdk.lib. + +Set one of: + WSLC_SDK_LIB_DIR=C:\path\to\native\lib + WSLC_SDK_DIR=C:\Users\\.nuget\packages\microsoft.wsl.containers\ + +Also make sure wslcsdk.dll is available at runtime. +``` + +## 5. DLL 运行时定位 + +Windows 运行时查找 DLL 的常见方式: + +- 与最终 exe 在同一目录; +- `PATH` 中; +- 系统目录; +- 通过 `SetDllDirectory` / `AddDllDirectory` 控制。 + +对 crate 来说,不建议自动复制 DLL。更好的文档方式是提醒用户: + +```powershell +$env:PATH="$env:WSLC_SDK_BIN_DIR;$env:PATH" +cargo run --example hello +``` + +或者用户自己在应用构建脚本里复制。 + +## 6. 绑定生成 + +### 开发者命令 + +```powershell +cargo xtask bindgen --header C:\path\to\wslcsdk.h +``` + +生成: + +```text +crates/wslc-sys/src/bindings.rs +``` + +建议提交生成文件。普通用户不需要安装 clang。 + +### CI 检查 + +可以做一个简单检查: + +1. 下载 / 使用指定版本 `wslcsdk.h`; +2. 运行 bindgen; +3. 与仓库内 `bindings.rs` 比较; +4. 如果 diff 非空,提示更新。 + +## 7. Cargo workspace 建议 + +```toml +[workspace] +members = [ + "crates/wslc", + "crates/wslc-sys", +] +resolver = "2" + +[workspace.package] +edition = "2021" +license = "MIT OR Apache-2.0" +repository = "https://github.com/Ivanbeethoven/wslc-rs" +``` + +## 8. 版本锁定 + +由于 WSLC preview 可能破坏 API,建议在 README 里标注测试过的 SDK/runtime: + +```text +Tested with: +- WSL: 2.9.3 +- Microsoft.WSL.Containers: 2.9.3 +- Rust: stable MSVC +``` + +并在 crate 中暴露: + +```rust +pub const TESTED_WSLC_SDK_VERSION: &str = "2.9.3"; +``` + +这不是运行时硬约束,只是帮助用户排查。 diff --git a/vendor/wslc-rs/docs/error-model.md b/vendor/wslc-rs/docs/error-model.md new file mode 100644 index 0000000..ff22f44 --- /dev/null +++ b/vendor/wslc-rs/docs/error-model.md @@ -0,0 +1,138 @@ +# 错误模型设计 + +## 1. 目标 + +Rust API 应该让用户能同时获得: + +- 友好的错误类型; +- 原始 HRESULT; +- SDK 返回的 error message; +- 输入参数上下文; +- 可匹配的 WSLC 专有错误。 + +## 2. Error enum + +```rust +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("unsupported platform: {0}")] + UnsupportedPlatform(&'static str), + + #[error("WSLC SDK not found: {0}")] + SdkNotFound(String), + + #[error("missing WSL components: {0:?}")] + MissingComponents(ComponentFlags), + + #[error("COM initialization failed: HRESULT=0x{code:08x}, {message}")] + ComInitialization { + code: i32, + message: String, + }, + + #[error("WSLC call failed: HRESULT=0x{code:08x}, {message}")] + HResult { + code: i32, + message: String, + }, + + #[error("invalid input: {0}")] + InvalidInput(String), + + #[error("string contains interior NUL: {0}")] + Nul(String), + + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("UTF-8 conversion error: {0}")] + Utf8(#[from] std::string::FromUtf8Error), +} +``` + +## 3. WSLC 专有错误码 + +底层 C API 定义了若干 WSLC 专有 HRESULT,例如: + +```text +WSLC_E_IMAGE_NOT_FOUND +WSLC_E_CONTAINER_PREFIX_AMBIGUOUS +WSLC_E_CONTAINER_NOT_FOUND +WSLC_E_CONTAINER_NOT_RUNNING +WSLC_E_CONTAINER_IS_RUNNING +WSLC_E_INVALID_SESSION_NAME +WSLC_E_SDK_UPDATE_NEEDED +WSLC_E_REGISTRY_BLOCKED_BY_POLICY +WSLC_E_VOLUME_NOT_AVAILABLE +WSLC_E_SESSION_NOT_FOUND +``` + +建议提供辅助方法: + +```rust +impl Error { + pub fn wslc_kind(&self) -> Option; + pub fn hresult(&self) -> Option; +} +``` + +```rust +pub enum WslcErrorKind { + ImageNotFound, + ContainerPrefixAmbiguous, + ContainerNotFound, + ContainerNotRunning, + ContainerIsRunning, + SessionReserved, + InvalidSessionName, + NetworkNotFound, + SdkUpdateNeeded, + RegistryBlockedByPolicy, + VolumeNotAvailable, + SessionNotFound, + Unknown(i32), +} +``` + +## 4. 保留原始信息 + +不要把错误过度抽象成: + +```rust +Error::ContainerNotFound +``` + +而丢失原始 message。更好的方式: + +```rust +Error::HResult { + code, + message, +} +``` + +再通过: + +```rust +err.wslc_kind() == Some(WslcErrorKind::ContainerNotFound) +``` + +判断类别。 + +## 5. 输入校验错误 + +Rust 层应尽早捕获: + +- 空 session name; +- 非法 storage path; +- image name 空字符串; +- argv 为空; +- 字符串包含 `\0`; +- container path 不是绝对路径; +- port 为 0; +- memory_mb 太小或太大; +- timeout 不可转换成 u32 milliseconds。 + +不要把明显的输入错误直接传给 C API。 diff --git a/vendor/wslc-rs/docs/ffi-and-safety.md b/vendor/wslc-rs/docs/ffi-and-safety.md new file mode 100644 index 0000000..9458efe --- /dev/null +++ b/vendor/wslc-rs/docs/ffi-and-safety.md @@ -0,0 +1,79 @@ +# FFI and Safety Boundary + +## Principle + +`wslc-sys` is the unsafe boundary. The safe `wslc` crate must not contain +`unsafe` blocks or `extern "system"` declarations. + +The boundary is split like this: + +- `wslc-sys/src/lib.rs`: ABI-compatible types, constants, callback signatures, + and function pointer signatures from `wslcsdk.h`. +- `wslc-sys/src/runtime.rs`: dynamic loading of `wslcsdk.dll`, COM helpers, + `CoTaskMemFree`, HRESULT conversion, Windows handle waiting, SDK callback + trampolines, and checked wrappers around raw SDK calls. +- `wslc/src`: input validation, owned Rust types, builders, RAII ownership, and + public safe API only. + +The `wslc` test suite includes a source-level contract test that fails if +`unsafe` or `extern "system"` appears in `crates/wslc/src`. + +## Responsibilities + +The runtime boundary must handle: + +- raw handle ownership and release order; +- HRESULT mapping while preserving the original code; +- SDK-allocated `PWSTR` / `PSTR` memory release with `CoTaskMemFree`; +- C string and UTF-16 string lifetime conversion; +- callback context lifetime and panic containment; +- process stdout/stderr buffer copying during callback execution; +- COM initialization and uninitialization; +- Windows `HANDLE` waits. + +The safe crate must handle: + +- rejecting invalid user input before loading or calling the SDK when possible; +- keeping COM guards alive for objects that need COM during release; +- avoiding `Send` / `Sync` promises for SDK handles until thread-safety is + explicitly established; +- returning typed Rust errors and preserving original HRESULT values. + +## Callback Rules + +SDK callbacks are allowed to cross only through `wslc-sys::runtime`. + +Callbacks must: + +- return quickly; +- copy SDK-owned byte buffers immediately; +- catch or contain panics before returning across FFI; +- treat null context/data pointers as no-op callbacks. + +Callbacks must not: + +- store SDK-owned raw pointers after the callback returns; +- run blocking user logic while holding internal locks; +- let panic unwind across the FFI boundary. + +## Drop Rules + +`Drop` implementations in `wslc` call safe runtime wrapper methods only. +Release errors are ignored in `Drop`, because `Drop` cannot return errors and +must not panic during cleanup. + +## Verification + +Use these checks before release: + +```powershell +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +``` + +On a machine with the WSLC SDK DLL on `PATH`, also run: + +```powershell +cargo test -p wslc --features integration --test integration_smoke +``` diff --git a/vendor/wslc-rs/docs/implementation-checklist.md b/vendor/wslc-rs/docs/implementation-checklist.md new file mode 100644 index 0000000..f29b200 --- /dev/null +++ b/vendor/wslc-rs/docs/implementation-checklist.md @@ -0,0 +1,50 @@ +# 实现检查清单 + +## FFI + +- [ ] 定义 `WslcSessionSettings` +- [ ] 定义 `WslcContainerSettings` +- [ ] 定义 `WslcProcessSettings` +- [ ] 定义 `WslcSession` +- [ ] 定义 `WslcContainer` +- [ ] 定义 `WslcProcess` +- [ ] 绑定 session APIs +- [ ] 绑定 container APIs +- [ ] 绑定 process APIs +- [ ] 绑定 image APIs +- [ ] 绑定 storage APIs +- [ ] 绑定 install/version APIs +- [ ] 链接 `wslcsdk` +- [ ] 链接 `ole32` + +## Safe wrapper + +- [ ] `Error` +- [ ] `Service` +- [ ] `SessionBuilder` +- [ ] `Session` +- [ ] `ImagePullOperation` +- [ ] `ContainerBuilder` +- [ ] `Container` +- [ ] `ProcessOptions` +- [ ] `Process` +- [ ] `Output` +- [ ] `CoTaskMem` wrapper +- [ ] UTF-16 conversion +- [ ] CString conversion +- [ ] callback trampoline +- [ ] Drop release handles +- [ ] integration test feature + +## 文档 + +- [ ] README quickstart +- [ ] SDK 安装说明 +- [ ] Windows-only 说明 +- [ ] Preview API 说明 +- [ ] 安全边界说明 +- [ ] 示例:hello +- [ ] 示例:nginx port mapping +- [ ] 示例:volume mount +- [ ] 示例:exec +- [ ] 示例:image list diff --git a/vendor/wslc-rs/docs/research-notes.md b/vendor/wslc-rs/docs/research-notes.md new file mode 100644 index 0000000..b41c6ef --- /dev/null +++ b/vendor/wslc-rs/docs/research-notes.md @@ -0,0 +1,161 @@ +# 资料调研记录 + +调研日期:2026-07-03 + +## 1. 官方资料结论 + +WSL container / WSLC 是 WSL 里的容器能力,目标是在 Windows 上构建、运行和管理 Linux 容器。它主要包含: + +- `wslc.exe`:命令行工具; +- WSL container API:程序化 API。 + +官方资料显示 API 的核心对象包括: + +| 对象 | 含义 | +|---|---| +| `WslcService` | 服务级入口,检查缺失组件、查询版本、安装依赖 | +| `Session` | WSL-backed container host,负责镜像和容器 | +| `Container` | 容器实例,可 start / stop / inspect / delete / exec | +| `Process` | 容器中的 Linux 进程,可处理 stdout/stderr/stdin、signal、exit code | + +官方 API 已提供 C、C++/WinRT、C# 投影。当前没有发现官方 Rust 投影,因此 Rust 库建议走 C ABI 绑定。 + +## 2. 为什么不只封装 CLI + +封装 `wslc.exe` 的优点是简单,但问题也明显: + +- 输出格式可能变化; +- 错误只能从 exit code / stderr 推断; +- 进程 IO、回调、进度、句柄生命周期不好表达; +- 难以提供 Rust 友好的强类型 API。 + +因此建议: + +- `wslc-cli` 只作为测试或 fallback; +- 正式 API 基于 `wslcsdk.dll` / `wslcsdk.lib`。 + +## 3. C API 关键事实 + +`wslcsdk.h` 中的 API 主要分组: + +- Session APIs +- Container APIs +- Process APIs +- Image APIs +- Storage APIs +- Install and Version APIs + +核心句柄: + +```c +DECLARE_HANDLE(WslcSession); +DECLARE_HANDLE(WslcContainer); +DECLARE_HANDLE(WslcProcess); +``` + +核心创建流程: + +```c +WslcInitSessionSettings(...) +WslcCreateSession(...) + +WslcPullSessionImage(...) + +WslcInitProcessSettings(...) +WslcSetProcessSettingsCmdLine(...) +WslcSetProcessSettingsCallbacks(...) + +WslcInitContainerSettings(...) +WslcSetContainerSettingsInitProcess(...) +WslcCreateContainer(...) +WslcStartContainer(...) +``` + +释放流程: + +```c +WslcReleaseProcess(...) +WslcReleaseContainer(...) +WslcReleaseSession(...) +``` + +资源终止: + +```c +WslcStopContainer(...) +WslcDeleteContainer(...) +WslcTerminateSession(...) +``` + +## 4. Rust 绑定路线 + +建议用两种方式之一: + +### 路线 A:手写 `wslc-sys` + +优点: + +- API 不大,手写可控; +- preview 阶段可避免 bindgen 生成过多 Windows 头文件噪声; +- 更容易维护 feature flag。 + +缺点: + +- 要谨慎同步 struct size / enum / function signature; +- API 更新时容易漏改。 + +### 路线 B:bindgen 生成 `wslc-sys` + +优点: + +- 与官方头文件同步更轻松; +- 不容易漏函数签名。 + +缺点: + +- Windows 头文件复杂,生成结果可能比较脏; +- 用户构建时要求 clang,不适合普通 crate 使用者; +- preview 期间生成结果可能频繁变化。 + +### 推荐混合方案 + +- 开发阶段使用 `xtask bindgen` 从 `wslcsdk.h` 生成绑定; +- 将清理后的 `bindings.rs` 提交到仓库; +- 普通用户构建 crate 时不依赖 clang; +- 通过 CI 定期比对官方 `wslcsdk.h` 的导出函数列表。 + +## 5. MVP 优先级 + +第一阶段只做最小闭环: + +1. `Service::missing_components()` +2. `Service::version()` +3. `Session::builder(...).start()` +4. `Session::pull_image(...)` +5. `Session::container(...).create()` +6. `Container::start()` +7. `Container::wait()` / `start_and_wait()` +8. `Container::stop()` / `delete()` +9. `Session::terminate()` + +第二阶段再支持: + +- exec additional process; +- stdout / stderr streaming; +- stdin 写入; +- port mapping; +- volume mount; +- inspect; +- list images / delete image; +- registry auth; +- VHD-backed volume; +- GPU flag。 + +## 6. 外部链接 + +- WSL developer docs: https://wsl.dev/ +- WSL container overview: https://learn.microsoft.com/windows/wsl/wsl-container +- WSL container tutorial: https://learn.microsoft.com/windows/wsl/tutorials/wsl-containers +- WSL C API reference: https://wsl.dev/technical-documentation/api/c/ +- WSL official repo SDK source: https://github.com/microsoft/WSL/tree/master/src/windows/WslcSDK +- Raw `wslcsdk.h`: https://raw.githubusercontent.com/microsoft/WSL/master/src/windows/WslcSDK/wslcsdk.h diff --git a/vendor/wslc-rs/docs/roadmap.md b/vendor/wslc-rs/docs/roadmap.md new file mode 100644 index 0000000..c62934e --- /dev/null +++ b/vendor/wslc-rs/docs/roadmap.md @@ -0,0 +1,88 @@ +# Roadmap + +## 0.1.0:最小可用版本 + +目标:跑通 “pull alpine -> create container -> echo -> capture output -> stop/delete”。 + +功能: + +- `Service::missing_components` +- `Service::version` +- `Service::ensure_available` +- `SessionBuilder` +- `Session::pull_image` +- `ContainerBuilder` +- `ProcessOptions::new` +- `Container::start` +- `Process::wait` +- `Container::start_and_wait` +- `Container::stop` +- `Container::delete` +- `Session::terminate` +- 基础错误模型 +- 基础集成测试 + +不做: + +- async; +- streaming stdin; +- exec; +- port mapping; +- volume; +- GPU; +- registry auth。 + +## 0.2.0:容器管理补齐 + +- `Container::inspect` +- `Container::state` +- `Container::id` +- `Container::exec` +- `Process::pid` +- `Process::signal` +- `Process::exit_code` +- port mapping +- basic volume mount +- named volume mount + +## 0.3.0:镜像与存储 + +- `Session::list_images` +- `Session::delete_image` +- `Session::tag_image` +- `Session::push_image` +- `Session::authenticate` +- `Session::create_vhd_volume` +- `Session::delete_vhd_volume` +- `ImageInfo` + +## 0.4.0:IO 与异步 + +- stdout/stderr streaming +- stdin write +- `wait_async` +- `pull_image_async` +- optional Tokio feature +- callback panic isolation +- worker thread runtime + +## 0.5.0:高级能力 + +- GPU feature flag +- privileged container +- crash dump callback +- session termination event +- dynamic loading mode +- CLI fallback +- richer diagnostics + +## 1.0.0:稳定版 + +前提: + +- 官方 WSLC API 达到稳定或接近稳定; +- 已覆盖 x64 / ARM64; +- CI 可在 Windows runner 上跑集成测试; +- FFI 与安全策略经过 review; +- 文档完善; +- 公开 API 基本不再频繁破坏。 diff --git a/vendor/wslc-rs/docs/sdk-installation.md b/vendor/wslc-rs/docs/sdk-installation.md new file mode 100644 index 0000000..ede9413 --- /dev/null +++ b/vendor/wslc-rs/docs/sdk-installation.md @@ -0,0 +1,127 @@ +# Installing the WSLC SDK + +This project loads Microsoft's preview WSLC SDK (`wslcsdk.dll`) at runtime. The +Rust crates do not redistribute Microsoft binaries, so a Windows host that calls +real WSLC APIs must install WSL and make the SDK native directory available on +`PATH`. + +Official entry points: + +- [WSL developer documentation](https://wsl.dev/) +- [WSL development loop](https://wsl.dev/dev-loop/) +- [WSL container API reference](https://wsl.dev/api-reference/) +- [Microsoft.WSL.Containers NuGet package](https://www.nuget.org/packages/Microsoft.WSL.Containers) +- [WSL container overview](https://learn.microsoft.com/en-us/windows/wsl/wsl-container) + +## Prerequisites + +Run these commands in an elevated PowerShell session if WSL is not already +installed or current: + +```powershell +wsl --install --no-distribution +wsl --update +wsl --status +``` + +After installation or update, restart Windows if WSL asks you to do so. + +## Download the SDK + +Install the preview SDK package with NuGet. The repository CI currently uses +`2.9.3`; change the version when validating against a newer preview. + +```powershell +$env:WSLC_SDK_VERSION = "2.9.3" +$env:WSLC_SDK_ROOT = "$PWD\.wslc-sdk" + +nuget install Microsoft.WSL.Containers ` + -Version $env:WSLC_SDK_VERSION ` + -OutputDirectory $env:WSLC_SDK_ROOT ` + -DirectDownload ` + -NonInteractive + +$packageDir = Join-Path $env:WSLC_SDK_ROOT "Microsoft.WSL.Containers.$($env:WSLC_SDK_VERSION)" +$nativeDir = Join-Path $packageDir "runtimes\win-x64\native" + +if (-not (Test-Path (Join-Path $nativeDir "wslcsdk.dll"))) { + throw "wslcsdk.dll was not found under $nativeDir" +} + +$env:PATH = "$nativeDir;$env:PATH" +``` + +To make the SDK available in future terminals, add `$nativeDir` to the user or +machine `PATH`. + +## Add the Rust Crate + +For a normal application, depend on the safe wrapper: + +```powershell +cargo add wslc +``` + +Only depend on `wslc-sys` directly when you need the raw ABI surface or are +building another wrapper. + +## Smoke Tests + +First check that the Rust workspace builds without calling the SDK: + +```powershell +cargo test --workspace +``` + +Then run a host-safe WSLC probe: + +```powershell +cargo test -p wslc --features integration --test integration_smoke +``` + +For the real registry/container path, configure a Docker Hub mirror if your +network needs one, then run the ignored Alpine test: + +```powershell +$env:WSLC_REGISTRY_MIRROR = "" +cargo test -p wslc --features integration --test integration_smoke -- --ignored --nocapture +``` + +The mirror value is a registry host such as `mirror.example.com`, not an +`https://` URL. The SDK also supports per-registry mirror variables such as +`WSLC_REGISTRY_MIRROR_GHCR_IO`. + +## Example Programs + +The safe crate includes small examples for validating an installed SDK: + +```powershell +cargo run -p wslc --example service_info +cargo run -p wslc --example list_images +cargo run -p wslc --example hello +cargo run -p wslc --example container_inspect +cargo run -p wslc --example vhd_volume +``` + +`hello` and `container_inspect` may pull an image. Set `WSLC_REGISTRY_MIRROR` +before running them when direct Docker Hub access is unavailable. + +## GitHub Actions + +GitHub-hosted Windows runners can install `Microsoft.WSL.Containers` and compile +the integration tests, but they may report +`MissingComponents(ComponentFlags(WSL_PACKAGE))` because the hosted image does +not expose the full WSLC runtime environment. Use the manual `WSLC network E2E` +workflow on a self-hosted Windows runner with WSL installed for the full Alpine +pull-and-run test. + +## Troubleshooting + +- `SdkNotFound`: `wslcsdk.dll` is not on `PATH`, or the SDK package was not + downloaded. +- `MissingComponents(ComponentFlags(WSL_PACKAGE))`: update or install WSL on the + host, then restart if required. +- Registry pull failures: set `WSLC_REGISTRY_MIRROR` or the matching + per-registry mirror environment variable. +- Preview SDK mismatch: update the NuGet package version and rerun the + integration tests. diff --git a/vendor/wslc-rs/docs/superpowers/plans/2026-07-04-wslc-rs-mvp.md b/vendor/wslc-rs/docs/superpowers/plans/2026-07-04-wslc-rs-mvp.md new file mode 100644 index 0000000..7617f29 --- /dev/null +++ b/vendor/wslc-rs/docs/superpowers/plans/2026-07-04-wslc-rs-mvp.md @@ -0,0 +1,72 @@ +# wslc-rs MVP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build and publish the initial `wslc-sys` and `wslc` crates from the existing WSLC Rust API design. + +**Architecture:** `wslc-sys` owns raw ABI-compatible types and constants. `wslc` owns runtime SDK loading, safe builders, input validation, HRESULT/error mapping, RAII handle release, callback-backed output capture, docs, examples, and tests. + +**Tech Stack:** Rust 2021, `thiserror`, `bitflags`, `libloading`, `windows-sys`, Cargo workspace, crates.io. + +--- + +### Task 1: Workspace and Tests + +**Files:** +- Create: `Cargo.toml` +- Create: `crates/wslc-sys/Cargo.toml` +- Create: `crates/wslc-sys/src/lib.rs` +- Create: `crates/wslc/Cargo.toml` +- Create: `crates/wslc/src/lib.rs` +- Create: `crates/wslc/tests/api_contract.rs` + +- [ ] Write failing API contract tests for validation, HRESULT mapping, and SDK absence. +- [ ] Run `cargo test -p wslc --test api_contract` and confirm failures. + +### Task 2: Raw ABI + +**Files:** +- Modify: `crates/wslc-sys/src/lib.rs` + +- [ ] Define opaque settings, raw handles, constants, structs, callback types, and function pointer signatures matching the current public `wslcsdk.h`. +- [ ] Add compile-time size/alignment tests. +- [ ] Run `cargo test -p wslc-sys`. + +### Task 3: Safe Wrapper Core + +**Files:** +- Create: `crates/wslc/src/error.rs` +- Create: `crates/wslc/src/raw.rs` +- Create: `crates/wslc/src/strings.rs` +- Create: `crates/wslc/src/com.rs` +- Modify: `crates/wslc/src/lib.rs` + +- [ ] Implement `Error`, `WslcErrorKind`, `ComponentFlags`, HRESULT checks, runtime SDK loading, COM initialization, and string conversion helpers. +- [ ] Run API contract tests until the pure validation tests pass. + +### Task 4: Service, Session, Image, Container, Process + +**Files:** +- Create: `crates/wslc/src/service.rs` +- Create: `crates/wslc/src/session.rs` +- Create: `crates/wslc/src/image.rs` +- Create: `crates/wslc/src/container.rs` +- Create: `crates/wslc/src/process.rs` +- Create: `crates/wslc/src/storage.rs` +- Modify: `crates/wslc/src/lib.rs` + +- [ ] Implement the MVP blocking API: service checks, session lifecycle, image pull, container create/start/stop/delete, process options, output capture, and VHD option types. +- [ ] Add integration tests gated behind `--features integration`. +- [ ] Run `cargo test`. + +### Task 5: Docs and Publishing + +**Files:** +- Modify: `README.md` +- Modify: `crates/wslc/README.md` +- Modify: `crates/wslc-sys/README.md` +- Modify: `examples/hello.rs` + +- [ ] Document runtime SDK loading, Windows host target, preview status, feature gates, and examples. +- [ ] Run `cargo fmt --all`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, and `cargo package -p wslc-sys --allow-dirty`, `cargo package -p wslc --allow-dirty`. +- [ ] Initialize git if needed, create the GitHub repository, push the code, publish `wslc-sys`, then publish `wslc`. diff --git a/vendor/wslc-rs/wslc-rust-api-design.md b/vendor/wslc-rs/wslc-rust-api-design.md new file mode 100644 index 0000000..a70201d --- /dev/null +++ b/vendor/wslc-rs/wslc-rust-api-design.md @@ -0,0 +1,1646 @@ +--- + + + +# wslc-rs:WSL Containers 的 Rust API 设计文档 + +> 状态:设计草案 / Preview-ready +> 面向平台:Windows host +> 目标:为 Microsoft WSL Containers(`wslc.exe` / `wslcsdk.dll`)提供安全、符合 Rust 习惯的 API。 + +## 1. 项目定位 + +`wslc-rs` 不是 Docker API 的替代实现,也不是传统 WSL distro 管理库。它面向的是 **WSL Containers / WSLC**:在 Windows 上通过 WSL 2 内核构建、运行、管理 Linux 容器。 + +当前官方资料显示,WSL container 功能主要包含两个部分: + +1. `wslc.exe`:用于构建、运行、交互 Linux 容器的命令行工具; +2. WSL container API:面向 Windows 应用开发者的程序化 API,可用于拉取镜像、运行容器、处理 stdin/stdout、挂载文件、网络、GPU 等能力。 + +官方已经提供 C、C++/WinRT、C# 投影;暂未看到官方 Rust 投影。因此本项目建议采用两层结构: + +```text +wslc-rs +├── crates/wslc-sys # unsafe FFI:绑定 wslcsdk.h / wslcsdk.dll +└── crates/wslc # safe wrapper:Rust 友好的 Session / Container / Process API +``` + +## 2. 推荐技术路线 + +优先使用 **SDK 绑定**,而不是单纯封装 `wslc.exe` CLI。 + +原因: + +- CLI 适合手工调试和兼容性回退; +- SDK 更适合库开发,可以直接管理句柄、回调、错误信息、进程 IO、镜像进度; +- Rust API 可以通过 RAII、类型系统、生命周期约束和 `Result` 包装 SDK 的不安全部分。 + +推荐结构: + +```text +wslc-sys + ├── 负责链接 wslcsdk.lib / wslcsdk.dll + ├── 暴露 C ABI:WslcCreateSession、WslcCreateContainer、WslcPullSessionImage ... + ├── 不做业务语义判断 + └── 所有函数均为 unsafe + +wslc + ├── SessionBuilder / Session + ├── ContainerBuilder / Container + ├── ProcessBuilder / Process / Output + ├── Image API + ├── Storage API + ├── Service API + └── Error / Result / Handle ownership +``` + +## 3. 最小使用示例 + +目标 API 设计如下: + +```rust +use std::path::PathBuf; +use std::time::Duration; + +use wslc::{ + ContainerOptions, DeleteContainerOptions, ImagePullOptions, ProcessOptions, + Service, Session, Signal, +}; + +fn main() -> wslc::Result<()> { + Service::ensure_available()?; + + let session = Session::builder("my-rust-app", PathBuf::from(r"C:\WslcData\my-rust-app")) + .cpu_count(4) + .memory_mb(4096) + .start()?; + + session + .pull_image(ImagePullOptions::new("docker.io/library/alpine:latest")) + .on_progress(|p| { + println!("pull: {:?} {}/{}", p.status, p.current_bytes, p.total_bytes); + }) + .run()?; + + let process = ProcessOptions::new(["/bin/echo", "hello from wslc-rs"]) + .capture_stdout() + .capture_stderr(); + + let container = session + .container(ContainerOptions::new("alpine:latest")) + .name("hello-wslc-rs") + .init_process(process) + .auto_remove(true) + .create()?; + + let output = container.start_and_wait()?; + println!("{}", String::from_utf8_lossy(&output.stdout)); + + container.stop(Signal::Sigterm, Duration::from_secs(10)).ok(); + container.delete(DeleteContainerOptions::default()).ok(); + session.terminate()?; + + Ok(()) +} +``` + +## 4. 核心概念映射 + +| 官方概念 | Rust 封装 | 责任 | +|---|---|---| +| `WslcService` | `Service` | 检查组件、查询版本、安装缺失依赖 | +| `WslcSession` | `Session` | 一个 WSL-backed container host,负责镜像和容器生命周期 | +| `WslcContainer` | `Container` | 容器实例:start / stop / inspect / delete / exec | +| `WslcProcess` | `Process` | 容器内 Linux 进程:stdout / stderr / stdin / signal / exit code | +| `WslcImageInfo` | `ImageInfo` | 镜像列表和元信息 | +| `HRESULT + errorMessage` | `Error` | 统一错误模型 | + +## 5. 开发环境 + +### 5.1 Windows 与 WSL + +该库面向 **Windows host 应用**。因为底层依赖 `wslcsdk.dll` / `wslcsdk.lib` 和 Windows API,所以主要目标不是在 Linux WSL 内部编译运行,而是在 Windows Rust toolchain 下编译。 + +推荐: + +```powershell +rustup toolchain install stable-x86_64-pc-windows-msvc +rustup default stable-x86_64-pc-windows-msvc + +wsl --update +wslc version +wslc run --rm hello-world +``` + +### 5.2 SDK 获取 + +建议先支持三种方式: + +1. 用户通过环境变量指定 SDK 路径; +2. 从本机 NuGet 缓存定位 `Microsoft.WSL.Containers` 包; +3. 将 SDK 作为 CI / dev 环境依赖下载,但不要把微软 DLL 或 lib 文件直接提交到你的仓库。 + +环境变量建议: + +```powershell +$env:WSLC_SDK_DIR="C:\Users\\.nuget\packages\microsoft.wsl.containers\" +$env:WSLC_SDK_LIB_DIR="$env:WSLC_SDK_DIR\runtimes\win-x64\native" +$env:WSLC_SDK_BIN_DIR="$env:WSLC_SDK_DIR\runtimes\win-x64\native" +``` + +`build.rs` 建议: + +```rust +fn main() { + if !cfg!(windows) { + panic!("wslc-rs only supports Windows host targets"); + } + + let lib_dir = std::env::var("WSLC_SDK_LIB_DIR") + .expect("WSLC_SDK_LIB_DIR must point to the directory containing wslcsdk.lib"); + + println!("cargo:rustc-link-search=native={lib_dir}"); + println!("cargo:rustc-link-lib=dylib=wslcsdk"); + println!("cargo:rustc-link-lib=dylib=ole32"); +} +``` + +## 6. 文档目录 + +- [`docs/research-notes.md`](docs/research-notes.md):资料调研与结论 +- [`docs/architecture.md`](docs/architecture.md):整体架构设计 +- [`docs/api-design.md`](docs/api-design.md):Rust API 设计 +- [`docs/ffi-and-safety.md`](docs/ffi-and-safety.md):FFI、安全边界、内存释放、回调 +- [`docs/build-and-linking.md`](docs/build-and-linking.md):构建、链接、SDK 路径 +- [`docs/error-model.md`](docs/error-model.md):错误模型 +- [`docs/roadmap.md`](docs/roadmap.md):MVP 路线图 +- [`examples/hello.rs`](examples/hello.rs):目标 API 示例 + +## 7. 版本策略 + +由于 WSLC 当前仍处于 preview,建议版本号策略保守一些: + +- `0.1.x`:MVP,支持 session / pull image / container start / output capture; +- `0.2.x`:补齐 volumes / port mappings / exec / inspect; +- `0.3.x`:补齐 image push / auth / VHD-backed volume; +- `0.4.x`:异步 API、streaming stdin/stdout; +- `1.0.0`:等官方 API 稳定后再考虑。 + +建议 crate README 明确写上: + +> This crate wraps a preview Microsoft API. Breaking changes may happen before WSLC reaches GA. + +## 8. License 建议 + +你自己的 Rust 封装建议用 MIT 或 Apache-2.0。 +不要把 Microsoft SDK 的二进制文件、头文件复制进 crate 发布包,除非确认其许可证允许再分发。更稳的方式是:用户本地安装 SDK,`build.rs` 通过路径引用。 + + +--- + + + +# 资料调研记录 + +调研日期:2026-07-03 + +## 1. 官方资料结论 + +WSL container / WSLC 是 WSL 里的容器能力,目标是在 Windows 上构建、运行和管理 Linux 容器。它主要包含: + +- `wslc.exe`:命令行工具; +- WSL container API:程序化 API。 + +官方资料显示 API 的核心对象包括: + +| 对象 | 含义 | +|---|---| +| `WslcService` | 服务级入口,检查缺失组件、查询版本、安装依赖 | +| `Session` | WSL-backed container host,负责镜像和容器 | +| `Container` | 容器实例,可 start / stop / inspect / delete / exec | +| `Process` | 容器中的 Linux 进程,可处理 stdout/stderr/stdin、signal、exit code | + +官方 API 已提供 C、C++/WinRT、C# 投影。当前没有发现官方 Rust 投影,因此 Rust 库建议走 C ABI 绑定。 + +## 2. 为什么不只封装 CLI + +封装 `wslc.exe` 的优点是简单,但问题也明显: + +- 输出格式可能变化; +- 错误只能从 exit code / stderr 推断; +- 进程 IO、回调、进度、句柄生命周期不好表达; +- 难以提供 Rust 友好的强类型 API。 + +因此建议: + +- `wslc-cli` 只作为测试或 fallback; +- 正式 API 基于 `wslcsdk.dll` / `wslcsdk.lib`。 + +## 3. C API 关键事实 + +`wslcsdk.h` 中的 API 主要分组: + +- Session APIs +- Container APIs +- Process APIs +- Image APIs +- Storage APIs +- Install and Version APIs + +核心句柄: + +```c +DECLARE_HANDLE(WslcSession); +DECLARE_HANDLE(WslcContainer); +DECLARE_HANDLE(WslcProcess); +``` + +核心创建流程: + +```c +WslcInitSessionSettings(...) +WslcCreateSession(...) + +WslcPullSessionImage(...) + +WslcInitProcessSettings(...) +WslcSetProcessSettingsCmdLine(...) +WslcSetProcessSettingsCallbacks(...) + +WslcInitContainerSettings(...) +WslcSetContainerSettingsInitProcess(...) +WslcCreateContainer(...) +WslcStartContainer(...) +``` + +释放流程: + +```c +WslcReleaseProcess(...) +WslcReleaseContainer(...) +WslcReleaseSession(...) +``` + +资源终止: + +```c +WslcStopContainer(...) +WslcDeleteContainer(...) +WslcTerminateSession(...) +``` + +## 4. Rust 绑定路线 + +建议用两种方式之一: + +### 路线 A:手写 `wslc-sys` + +优点: + +- API 不大,手写可控; +- preview 阶段可避免 bindgen 生成过多 Windows 头文件噪声; +- 更容易维护 feature flag。 + +缺点: + +- 要谨慎同步 struct size / enum / function signature; +- API 更新时容易漏改。 + +### 路线 B:bindgen 生成 `wslc-sys` + +优点: + +- 与官方头文件同步更轻松; +- 不容易漏函数签名。 + +缺点: + +- Windows 头文件复杂,生成结果可能比较脏; +- 用户构建时要求 clang,不适合普通 crate 使用者; +- preview 期间生成结果可能频繁变化。 + +### 推荐混合方案 + +- 开发阶段使用 `xtask bindgen` 从 `wslcsdk.h` 生成绑定; +- 将清理后的 `bindings.rs` 提交到仓库; +- 普通用户构建 crate 时不依赖 clang; +- 通过 CI 定期比对官方 `wslcsdk.h` 的导出函数列表。 + +## 5. MVP 优先级 + +第一阶段只做最小闭环: + +1. `Service::missing_components()` +2. `Service::version()` +3. `Session::builder(...).start()` +4. `Session::pull_image(...)` +5. `Session::container(...).create()` +6. `Container::start()` +7. `Container::wait()` / `start_and_wait()` +8. `Container::stop()` / `delete()` +9. `Session::terminate()` + +第二阶段再支持: + +- exec additional process; +- stdout / stderr streaming; +- stdin 写入; +- port mapping; +- volume mount; +- inspect; +- list images / delete image; +- registry auth; +- VHD-backed volume; +- GPU flag。 + +## 6. 外部链接 + +- WSL developer docs: https://wsl.dev/ +- WSL container overview: https://learn.microsoft.com/windows/wsl/wsl-container +- WSL container tutorial: https://learn.microsoft.com/windows/wsl/tutorials/wsl-containers +- WSL C API reference: https://wsl.dev/technical-documentation/api/c/ +- WSL official repo SDK source: https://github.com/microsoft/WSL/tree/master/src/windows/WslcSDK +- Raw `wslcsdk.h`: https://raw.githubusercontent.com/microsoft/WSL/master/src/windows/WslcSDK/wslcsdk.h + + +--- + + + +# 架构设计 + +## 1. 总体分层 + +```text +Application + │ + ▼ +wslc Safe Rust API + │ - builder pattern + │ - RAII handle ownership + │ - Result + │ - output streaming / async adapter + ▼ +wslc-sys Unsafe FFI + │ - raw handles + │ - raw HRESULT + │ - raw callbacks + │ - raw pointers + ▼ +wslcsdk.dll Microsoft WSL Container SDK + │ + ▼ +WSL / WSLC runtime +``` + +## 2. crate 划分 + +### `wslc-sys` + +职责: + +- 暴露 `extern "system"` FFI; +- 定义 raw enum / struct / handle; +- 链接 `wslcsdk` 和 `ole32`; +- 不提供任何 safe API; +- 不在 Drop 中释放资源; +- 不把 `HRESULT` 转成业务错误。 + +设计原则: + +```rust +pub type WslcSession = *mut core::ffi::c_void; +pub type WslcContainer = *mut core::ffi::c_void; +pub type WslcProcess = *mut core::ffi::c_void; + +extern "system" { + pub fn WslcCreateSession( + settings: *mut WslcSessionSettings, + session: *mut WslcSession, + error_message: *mut *mut u16, + ) -> windows_sys::core::HRESULT; +} +``` + +### `wslc` + +职责: + +- 对外暴露安全 API; +- 将 `HRESULT` 和 `errorMessage` 转换成 `Error`; +- 用 Drop 释放 handle; +- 用 builder 保证参数校验; +- 用 `Arc` 维护父子对象生命周期; +- 用 callback trampoline 安全桥接 Rust 闭包; +- 可选提供 blocking / async 两套 API。 + +## 3. 对象关系 + +```text +Service + ├── missing_components() + ├── version() + └── install_with_dependencies() + +Session + ├── owns WslcSession + ├── pull_image() + ├── list_images() + ├── create_vhd_volume() + ├── container() + └── terminate() + +Container + ├── owns WslcContainer + ├── keeps Arc + ├── start() + ├── inspect() + ├── state() + ├── exec() + ├── stop() + └── delete() + +Process + ├── owns WslcProcess + ├── keeps Arc + ├── pid() + ├── wait() + ├── signal() + ├── exit_code() + └── output() +``` + +## 4. 生命周期原则 + +### 父对象必须长于子对象 + +Rust 封装应保证: + +- `Session` 活着时,其创建的 `Container` 才能安全存在; +- `Container` 活着时,其内部 `Process` 才能安全存在; +- `Process` 回调上下文必须至少活到进程退出或 handle release。 + +建议内部结构: + +```rust +pub struct Session { + inner: Arc, +} + +struct SessionInner { + raw: NonNull, +} + +pub struct Container { + inner: Arc, +} + +struct ContainerInner { + raw: NonNull, + session: Arc, +} + +pub struct Process { + inner: Arc, +} + +struct ProcessInner { + raw: NonNull, + container: Arc, +} +``` + +## 5. Drop 策略 + +`Drop` 只释放 SDK handle,不做破坏性操作: + +```text +Drop(Session) -> WslcReleaseSession +Drop(Container) -> WslcReleaseContainer +Drop(Process) -> WslcReleaseProcess +``` + +不要在 Drop 中自动: + +- `WslcTerminateSession` +- `WslcStopContainer` +- `WslcDeleteContainer` + +原因: + +- Drop 中执行阻塞 / 破坏性操作不可控; +- 用户可能希望容器继续运行; +- Rust panic unwind 时自动删除容器容易造成意外。 + +可以提供显式方法: + +```rust +session.terminate()?; +container.stop(Signal::Sigterm, Duration::from_secs(10))?; +container.delete(DeleteContainerOptions::default())?; +``` + +也可以提供 opt-in guard: + +```rust +let session = Session::builder(...).terminate_on_drop(true).start()?; +let container = session.container(...).delete_on_drop(true).create()?; +``` + +## 6. 同步与异步 API + +MVP 先提供 blocking API: + +```rust +session.pull_image(...).run()?; +container.start()?; +process.wait()?; +``` + +后续再基于 Windows wait handle / callback / threadpool 提供 async API: + +```rust +session.pull_image(...).await?; +let output = container.start_and_wait_async().await?; +``` + +建议不要一开始强绑定 Tokio,先提供 feature: + +```toml +[features] +default = ["blocking"] +tokio = ["dep:tokio"] +async-std = [] +``` + +## 7. CLI fallback + +可选单独实现: + +```text +wslc-cli +``` + +用于: + +- smoke test; +- 当 SDK 未安装但 CLI 可用时做临时 fallback; +- 对比 SDK 行为。 + +不要让主库默认走 CLI,否则错误模型和语义会变弱。 + + +--- + + + +# Rust API 设计 + +## 1. 对外模块 + +```rust +pub mod service; +pub mod session; +pub mod container; +pub mod process; +pub mod image; +pub mod storage; +pub mod error; + +pub use service::Service; +pub use session::{Session, SessionBuilder}; +pub use container::{Container, ContainerBuilder, ContainerOptions}; +pub use process::{Process, ProcessOptions, Output, OutputMode}; +pub use image::{ImageInfo, ImagePullOptions, ImageProgress, ImageProgressStatus}; +pub use storage::{VhdOptions, VhdType}; +pub use error::{Error, Result}; +``` + +## 2. Service API + +```rust +impl Service { + pub fn missing_components() -> Result; + pub fn version() -> Result; + pub fn ensure_available() -> Result<()>; + pub fn install_with_dependencies(progress: F) -> Result<()> + where + F: FnMut(InstallProgress) + Send + 'static; +} +``` + +### 设计说明 + +`ensure_available()` 是用户最常用的入口: + +```rust +Service::ensure_available()?; +``` + +内部逻辑: + +1. 调用 `WslcGetMissingComponents`; +2. 如果缺少组件,返回 `Error::MissingComponents`; +3. 调用 `WslcGetVersion`; +4. 可选检查 SDK/runtime 是否过旧。 + +## 3. Session API + +```rust +impl Session { + pub fn builder( + name: impl Into, + storage_path: impl Into, + ) -> SessionBuilder; + + pub fn pull_image(&self, options: ImagePullOptions) -> ImagePullOperation; + + pub fn list_images(&self) -> Result>; + pub fn delete_image(&self, name_or_id: impl AsRef) -> Result<()>; + pub fn tag_image(&self, image: impl AsRef, repo: impl AsRef, tag: impl AsRef) -> Result<()>; + + pub fn container(&self, options: ContainerOptions) -> ContainerBuilder; + + pub fn create_vhd_volume(&self, options: VhdOptions) -> Result<()>; + pub fn delete_vhd_volume(&self, name: impl AsRef) -> Result<()>; + + pub fn terminate(&self) -> Result<()>; +} +``` + +### SessionBuilder + +```rust +pub struct SessionBuilder { + name: String, + storage_path: PathBuf, + cpu_count: Option, + memory_mb: Option, + timeout_ms: Option, + enable_gpu: bool, + vhd: Option, + terminate_on_drop: bool, +} + +impl SessionBuilder { + pub fn cpu_count(mut self, cpu_count: u32) -> Self; + pub fn memory_mb(mut self, memory_mb: u32) -> Self; + pub fn timeout(mut self, timeout: Duration) -> Self; + pub fn enable_gpu(mut self, enable: bool) -> Self; + pub fn vhd(mut self, options: VhdOptions) -> Self; + pub fn terminate_on_drop(mut self, enable: bool) -> Self; + pub fn start(self) -> Result; +} +``` + +## 4. Image API + +```rust +pub struct ImagePullOptions { + pub uri: String, + pub registry_auth: Option, +} + +impl ImagePullOptions { + pub fn new(uri: impl Into) -> Self; +} + +pub struct ImagePullOperation<'a> { + session: &'a Session, + options: ImagePullOptions, +} + +impl<'a> ImagePullOperation<'a> { + pub fn on_progress(self, f: F) -> Self + where + F: FnMut(ImageProgress) + Send + 'static; + + pub fn run(self) -> Result<()>; +} +``` + +### ImageProgress + +```rust +pub struct ImageProgress { + pub id: String, + pub status: ImageProgressStatus, + pub current_bytes: u64, + pub total_bytes: u64, +} + +pub enum ImageProgressStatus { + Unknown, + Pulling, + Waiting, + Downloading, + Verifying, + Extracting, + Complete, +} +``` + +## 5. Container API + +```rust +pub struct ContainerOptions { + pub image: String, +} + +impl ContainerOptions { + pub fn new(image: impl Into) -> Self; +} + +impl Session { + pub fn container(&self, options: ContainerOptions) -> ContainerBuilder; +} + +pub struct ContainerBuilder { + session: Session, + options: ContainerOptions, + name: Option, + hostname: Option, + domain_name: Option, + networking: NetworkingMode, + flags: ContainerFlags, + init_process: Option, + ports: Vec, + volumes: Vec, + named_volumes: Vec, +} + +impl ContainerBuilder { + pub fn name(mut self, name: impl Into) -> Self; + pub fn hostname(mut self, hostname: impl Into) -> Self; + pub fn domain_name(mut self, domain: impl Into) -> Self; + pub fn networking(mut self, mode: NetworkingMode) -> Self; + pub fn auto_remove(mut self, enable: bool) -> Self; + pub fn privileged(mut self, enable: bool) -> Self; + pub fn enable_gpu(mut self, enable: bool) -> Self; + pub fn init_process(mut self, process: ProcessOptions) -> Self; + pub fn port(mut self, windows_port: u16, container_port: u16) -> Self; + pub fn volume(mut self, windows_path: impl Into, container_path: impl Into) -> Self; + pub fn create(self) -> Result; +} +``` + +### Container methods + +```rust +impl Container { + pub fn id(&self) -> Result; + pub fn start(&self) -> Result<()>; + pub fn start_attached(&self) -> Result<()>; + pub fn start_and_wait(&self) -> Result; + pub fn inspect(&self) -> Result; + pub fn state(&self) -> Result; + + pub fn exec(&self, options: ProcessOptions) -> Result; + + pub fn stop(&self, signal: Signal, timeout: Duration) -> Result<()>; + pub fn delete(&self, options: DeleteContainerOptions) -> Result<()>; +} +``` + +## 6. Process API + +```rust +pub struct ProcessOptions { + pub cmdline: Vec, + pub working_dir: Option, + pub env: Vec<(String, String)>, + pub output_mode: OutputMode, +} + +impl ProcessOptions { + pub fn new(args: I) -> Self + where + I: IntoIterator, + S: Into; + + pub fn working_dir(mut self, dir: impl Into) -> Self; + pub fn env(mut self, key: impl Into, value: impl Into) -> Self; + pub fn capture_stdout(mut self) -> Self; + pub fn capture_stderr(mut self) -> Self; + pub fn inherit_output(mut self) -> Self; + pub fn streaming(mut self) -> Self; +} +``` + +### Process methods + +```rust +impl Process { + pub fn pid(&self) -> Result; + pub fn state(&self) -> Result; + pub fn signal(&self, signal: Signal) -> Result<()>; + pub fn exit_code(&self) -> Result>; + pub fn wait(&self) -> Result; + pub fn wait_with_output(&self) -> Result; +} +``` + +## 7. 输出模式 + +```rust +pub enum OutputMode { + Null, + Capture, + Streaming { + stdout: Option>, + stderr: Option>, + }, +} + +pub struct Output { + pub status: i32, + pub stdout: Vec, + pub stderr: Vec, +} +``` + +MVP 可以先只支持 `Capture`。 +流式 stdout/stderr 涉及 callback 生命周期和跨线程数据转移,应放在第二阶段。 + +## 8. 类型转换原则 + +| C 类型 | Rust 类型 | +|---|---| +| `PCWSTR` | `U16CString` / `Vec` | +| `PCSTR` | `CString` | +| `PWSTR*` error message | `CoTaskMemWString`,转 `String` 后释放 | +| `PSTR*` inspect data | `CoTaskMemCString`,转 `String` 后释放 | +| `HANDLE` | `OwnedHandle` / raw `HANDLE` wrapper | +| `HRESULT` | `Result` | +| `BOOL` | `bool` | +| bitflags enum | `bitflags!` | + +## 9. 命名风格 + +Rust API 不直接暴露 `Wslc` 前缀。 +例如: + +```rust +WslcCreateSession -> SessionBuilder::start() +WslcPullSessionImage -> Session::pull_image(...).run() +WslcCreateContainer -> Session::container(...).create() +WslcCreateContainerProcess -> Container::exec(...) +``` + +这样文档读起来像 Rust,而不是 C API 的一层薄皮。 + + +--- + + + +# FFI 与安全边界 + +## 1. 基本原则 + +`wslc-sys` 是 unsafe 边界,`wslc` 负责把 unsafe 变成安全 API。 + +必须处理的问题: + +- raw handle 的所有权; +- `HRESULT` 错误转换; +- `PWSTR*` / `PSTR*` 返回内存释放; +- C 字符串生命周期; +- callback 的 Rust 闭包生命周期; +- 进程 IO buffer 的短生命周期; +- COM 初始化; +- Windows HANDLE 等待; +- 多线程 Send / Sync 边界。 + +## 2. HRESULT 转换 + +底层函数通常返回 `HRESULT`。建议统一封装: + +```rust +fn check_hresult(hr: HRESULT, error_message: *mut u16) -> Result<()> { + if hr >= 0 { + return Ok(()); + } + + let message = unsafe { take_cotaskmem_wide_string(error_message) }; + Err(Error::HResult { + code: hr, + message, + }) +} +``` + +注意: + +- `S_OK` 以及非失败 HRESULT 视为成功; +- `error_message` 可能为空; +- 错误码要保留原始 `HRESULT`,便于用户排查; +- WSLC 专有错误码可映射成更友好的 enum,但不要丢失原始 code。 + +## 3. CoTaskMemAlloc 内存释放 + +官方 C API 中,部分字符串或数组由 SDK 分配,调用方拥有所有权,需要用 `CoTaskMemFree` 释放。 + +建议封装: + +```rust +struct CoTaskMem(*mut T); + +impl Drop for CoTaskMem { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + windows_sys::Win32::System::Com::CoTaskMemFree(Some(self.0.cast())); + } + } + } +} +``` + +然后提供: + +```rust +unsafe fn take_cotaskmem_utf8(ptr: *mut i8) -> Result; +unsafe fn take_cotaskmem_utf16(ptr: *mut u16) -> Result; +unsafe fn take_cotaskmem_slice(ptr: *mut T, len: usize) -> Vec; +``` + +## 4. 字符串转换 + +### PCWSTR + +用于 session name、storage path、Windows path。 + +```rust +fn to_wide_null(s: &OsStr) -> Vec { + use std::os::windows::ffi::OsStrExt; + s.encode_wide().chain(std::iter::once(0)).collect() +} +``` + +### PCSTR + +用于 image name、container path、argv、env。 + +```rust +let c = CString::new(value).map_err(Error::nul)?; +``` + +必须拒绝内部 `\0`: + +```rust +Error::InvalidInput("string contains interior NUL") +``` + +## 5. COM 初始化 + +C 示例里会调用 COM 初始化。Rust 库建议提供两种策略: + +### 策略 A:用户显式初始化 + +```rust +let _com = wslc::com::initialize_mta()?; +``` + +优点:语义清楚。 +缺点:使用门槛高。 + +### 策略 B:库内部惰性初始化 + +```rust +Service::ensure_available()?; +``` + +第一次调用时在当前线程尝试 `CoInitializeEx(COINIT_MULTITHREADED)`。 + +问题: + +- 如果调用线程已经用 STA 初始化 COM,MTA 初始化可能失败; +- Rust 库不能随意改变调用方线程模型。 + +### 推荐 + +MVP 可以采用: + +```rust +pub fn initialize_mta() -> Result; +``` + +同时在高级 API 中尽量自动处理;如果当前线程 COM apartment 不兼容,返回清晰错误: + +```rust +Error::ComInitialization { + code, + message: "WSLC requires COM initialization on this thread; use wslc::initialize_mta() or run on a dedicated worker thread", +} +``` + +长期可提供 dedicated worker thread: + +```rust +let runtime = WslcRuntime::new()?; +runtime.block_on(|service| { ... }); +``` + +## 6. Callback 安全 + +### SDK 回调特点 + +进程 stdout/stderr 回调收到的 buffer: + +- 由 SDK 拥有; +- 只在 callback 调用期间有效; +- 不能保存原始指针; +- 必须立即复制; +- callback 必须快速返回。 + +因此 Rust 回调必须这样做: + +```rust +extern "system" fn stdout_trampoline( + handle: WslcProcessIOHandle, + data: *const u8, + len: u32, + context: *mut c_void, +) { + let slice = unsafe { std::slice::from_raw_parts(data, len as usize) }; + let owned = slice.to_vec(); + + let tx = unsafe { &*(context as *const crossbeam_channel::Sender) }; + let _ = tx.try_send(OutputEvent::Stdout(owned)); +} +``` + +不要在 trampoline 里: + +- 做阻塞 IO; +- 加大锁; +- 调用用户闭包做复杂逻辑; +- 保存 `data` 指针; +- panic 穿过 FFI 边界。 + +所有 callback 都要包 `catch_unwind` 或保证内部无 panic。 + +## 7. `Send` / `Sync` + +不要默认给 raw handle 标记 `Send + Sync`。 + +建议策略: + +- MVP:`Session` / `Container` / `Process` 先不实现 `Send` / `Sync`,或仅在明确确认 SDK 线程安全后实现; +- 如果需要异步 API,使用单独 worker thread 拥有所有 handle; +- 用户线程通过 channel 给 worker 发命令。 + +## 8. Drop 中的错误处理 + +`Drop` 不能返回错误。释放句柄失败时只能: + +- debug log; +- tracing warn; +- 忽略。 + +不要在 Drop 中 panic。 + +```rust +impl Drop for SessionInner { + fn drop(&mut self) { + unsafe { + let _ = wslc_sys::WslcReleaseSession(self.raw.as_ptr()); + } + } +} +``` + +## 9. 不安全 API 隔离 + +`wslc` crate 中尽量只在少数文件出现 unsafe: + +```text +src/raw.rs # handle wrapper +src/string.rs # CString / wide string / CoTaskMem +src/callback.rs # trampoline +src/wait.rs # Windows HANDLE wait +``` + +并对每个 unsafe block 写明 safety 注释: + +```rust +// SAFETY: +// - settings points to a properly initialized WslcSessionSettings. +// - raw_session is valid for writes. +// - error_message is either null or receives CoTaskMem-allocated memory. +unsafe { + hr = wslc_sys::WslcCreateSession(&mut settings, &mut raw_session, &mut error_message); +} +``` + +## 10. 测试策略 + +### 单元测试 + +- 字符串转换; +- HRESULT 映射; +- bitflags 转换; +- Drop 顺序; +- callback 复制数据。 + +### 集成测试 + +要求本机存在 WSLC: + +```powershell +wsl --update +wslc run --rm hello-world +cargo test --features integration +``` + +用 feature 控制: + +```toml +[features] +integration = [] +``` + +避免普通 CI / crate 用户在无 WSLC 环境下测试失败。 + + +--- + + + +# 构建与链接设计 + +## 1. 目标平台 + +MVP 只支持: + +```text +x86_64-pc-windows-msvc +``` + +后续支持: + +```text +aarch64-pc-windows-msvc +``` + +不建议优先支持 GNU Windows target,因为 Microsoft SDK 和 import lib 更偏 MSVC 工具链。 + +## 2. 依赖 + +建议依赖: + +```toml +[dependencies] +bitflags = "2" +thiserror = "2" +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_System_Com", + "Win32_System_Threading", +] } + +[build-dependencies] +``` + +可选: + +```toml +[features] +default = ["blocking"] +blocking = [] +tokio = ["dep:tokio"] +integration = [] +dynamic-loading = [] +``` + +## 3. 链接方案 + +### 方案 A:静态 import lib 链接 + +最简单: + +```rust +println!("cargo:rustc-link-search=native={lib_dir}"); +println!("cargo:rustc-link-lib=dylib=wslcsdk"); +println!("cargo:rustc-link-lib=dylib=ole32"); +``` + +要求: + +- 编译时能找到 `wslcsdk.lib`; +- 运行时能找到 `wslcsdk.dll`。 + +### 方案 B:运行时动态加载 + +通过 `LoadLibraryW` / `GetProcAddress` 加载 `wslcsdk.dll`。 + +优点: + +- 编译时不需要 `wslcsdk.lib`; +- 对 SDK 缺失的错误提示更友好; +- preview 阶段可检查函数是否存在。 + +缺点: + +- 代码更复杂; +- 每个函数都要做 function pointer; +- 类型签名仍需维护。 + +建议: + +- MVP 用方案 A; +- `dynamic-loading` feature 作为后续增强。 + +## 4. SDK 路径解析 + +建议 `build.rs` 搜索优先级: + +1. `WSLC_SDK_LIB_DIR` +2. `WSLC_SDK_DIR` +3. NuGet cache:`%USERPROFILE%\.nuget\packages\microsoft.wsl.containers\\...` +4. 报错,提示用户安装 SDK 或设置环境变量 + +示例错误: + +```text +Could not find wslcsdk.lib. + +Set one of: + WSLC_SDK_LIB_DIR=C:\path\to\native\lib + WSLC_SDK_DIR=C:\Users\\.nuget\packages\microsoft.wsl.containers\ + +Also make sure wslcsdk.dll is available at runtime. +``` + +## 5. DLL 运行时定位 + +Windows 运行时查找 DLL 的常见方式: + +- 与最终 exe 在同一目录; +- `PATH` 中; +- 系统目录; +- 通过 `SetDllDirectory` / `AddDllDirectory` 控制。 + +对 crate 来说,不建议自动复制 DLL。更好的文档方式是提醒用户: + +```powershell +$env:PATH="$env:WSLC_SDK_BIN_DIR;$env:PATH" +cargo run --example hello +``` + +或者用户自己在应用构建脚本里复制。 + +## 6. 绑定生成 + +### 开发者命令 + +```powershell +cargo xtask bindgen --header C:\path\to\wslcsdk.h +``` + +生成: + +```text +crates/wslc-sys/src/bindings.rs +``` + +建议提交生成文件。普通用户不需要安装 clang。 + +### CI 检查 + +可以做一个简单检查: + +1. 下载 / 使用指定版本 `wslcsdk.h`; +2. 运行 bindgen; +3. 与仓库内 `bindings.rs` 比较; +4. 如果 diff 非空,提示更新。 + +## 7. Cargo workspace 建议 + +```toml +[workspace] +members = [ + "crates/wslc", + "crates/wslc-sys", +] +resolver = "2" + +[workspace.package] +edition = "2021" +license = "MIT OR Apache-2.0" +repository = "https://github.com/Ivanbeethoven/wslc-rs" +``` + +## 8. 版本锁定 + +由于 WSLC preview 可能破坏 API,建议在 README 里标注测试过的 SDK/runtime: + +```text +Tested with: +- WSL: 2.9.3 +- Microsoft.WSL.Containers: 2.9.3 +- Rust: stable MSVC +``` + +并在 crate 中暴露: + +```rust +pub const TESTED_WSLC_SDK_VERSION: &str = "2.9.3"; +``` + +这不是运行时硬约束,只是帮助用户排查。 + + +--- + + + +# 错误模型设计 + +## 1. 目标 + +Rust API 应该让用户能同时获得: + +- 友好的错误类型; +- 原始 HRESULT; +- SDK 返回的 error message; +- 输入参数上下文; +- 可匹配的 WSLC 专有错误。 + +## 2. Error enum + +```rust +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("unsupported platform: {0}")] + UnsupportedPlatform(&'static str), + + #[error("WSLC SDK not found: {0}")] + SdkNotFound(String), + + #[error("missing WSL components: {0:?}")] + MissingComponents(ComponentFlags), + + #[error("COM initialization failed: HRESULT=0x{code:08x}, {message}")] + ComInitialization { + code: i32, + message: String, + }, + + #[error("WSLC call failed: HRESULT=0x{code:08x}, {message}")] + HResult { + code: i32, + message: String, + }, + + #[error("invalid input: {0}")] + InvalidInput(String), + + #[error("string contains interior NUL: {0}")] + Nul(String), + + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("UTF-8 conversion error: {0}")] + Utf8(#[from] std::string::FromUtf8Error), +} +``` + +## 3. WSLC 专有错误码 + +底层 C API 定义了若干 WSLC 专有 HRESULT,例如: + +```text +WSLC_E_IMAGE_NOT_FOUND +WSLC_E_CONTAINER_PREFIX_AMBIGUOUS +WSLC_E_CONTAINER_NOT_FOUND +WSLC_E_CONTAINER_NOT_RUNNING +WSLC_E_CONTAINER_IS_RUNNING +WSLC_E_INVALID_SESSION_NAME +WSLC_E_SDK_UPDATE_NEEDED +WSLC_E_REGISTRY_BLOCKED_BY_POLICY +WSLC_E_VOLUME_NOT_AVAILABLE +WSLC_E_SESSION_NOT_FOUND +``` + +建议提供辅助方法: + +```rust +impl Error { + pub fn wslc_kind(&self) -> Option; + pub fn hresult(&self) -> Option; +} +``` + +```rust +pub enum WslcErrorKind { + ImageNotFound, + ContainerPrefixAmbiguous, + ContainerNotFound, + ContainerNotRunning, + ContainerIsRunning, + SessionReserved, + InvalidSessionName, + NetworkNotFound, + SdkUpdateNeeded, + RegistryBlockedByPolicy, + VolumeNotAvailable, + SessionNotFound, + Unknown(i32), +} +``` + +## 4. 保留原始信息 + +不要把错误过度抽象成: + +```rust +Error::ContainerNotFound +``` + +而丢失原始 message。更好的方式: + +```rust +Error::HResult { + code, + message, +} +``` + +再通过: + +```rust +err.wslc_kind() == Some(WslcErrorKind::ContainerNotFound) +``` + +判断类别。 + +## 5. 输入校验错误 + +Rust 层应尽早捕获: + +- 空 session name; +- 非法 storage path; +- image name 空字符串; +- argv 为空; +- 字符串包含 `\0`; +- container path 不是绝对路径; +- port 为 0; +- memory_mb 太小或太大; +- timeout 不可转换成 u32 milliseconds。 + +不要把明显的输入错误直接传给 C API。 + + +--- + + + +# Roadmap + +## 0.1.0:最小可用版本 + +目标:跑通 “pull alpine -> create container -> echo -> capture output -> stop/delete”。 + +功能: + +- `Service::missing_components` +- `Service::version` +- `Service::ensure_available` +- `SessionBuilder` +- `Session::pull_image` +- `ContainerBuilder` +- `ProcessOptions::new` +- `Container::start` +- `Process::wait` +- `Container::start_and_wait` +- `Container::stop` +- `Container::delete` +- `Session::terminate` +- 基础错误模型 +- 基础集成测试 + +不做: + +- async; +- streaming stdin; +- exec; +- port mapping; +- volume; +- GPU; +- registry auth。 + +## 0.2.0:容器管理补齐 + +- `Container::inspect` +- `Container::state` +- `Container::id` +- `Container::exec` +- `Process::pid` +- `Process::signal` +- `Process::exit_code` +- port mapping +- basic volume mount +- named volume mount + +## 0.3.0:镜像与存储 + +- `Session::list_images` +- `Session::delete_image` +- `Session::tag_image` +- `Session::push_image` +- `Session::authenticate` +- `Session::create_vhd_volume` +- `Session::delete_vhd_volume` +- `ImageInfo` + +## 0.4.0:IO 与异步 + +- stdout/stderr streaming +- stdin write +- `wait_async` +- `pull_image_async` +- optional Tokio feature +- callback panic isolation +- worker thread runtime + +## 0.5.0:高级能力 + +- GPU feature flag +- privileged container +- crash dump callback +- session termination event +- dynamic loading mode +- CLI fallback +- richer diagnostics + +## 1.0.0:稳定版 + +前提: + +- 官方 WSLC API 达到稳定或接近稳定; +- 已覆盖 x64 / ARM64; +- CI 可在 Windows runner 上跑集成测试; +- FFI 与安全策略经过 review; +- 文档完善; +- 公开 API 基本不再频繁破坏。 + + +--- + + + +# 实现检查清单 + +## FFI + +- [ ] 定义 `WslcSessionSettings` +- [ ] 定义 `WslcContainerSettings` +- [ ] 定义 `WslcProcessSettings` +- [ ] 定义 `WslcSession` +- [ ] 定义 `WslcContainer` +- [ ] 定义 `WslcProcess` +- [ ] 绑定 session APIs +- [ ] 绑定 container APIs +- [ ] 绑定 process APIs +- [ ] 绑定 image APIs +- [ ] 绑定 storage APIs +- [ ] 绑定 install/version APIs +- [ ] 链接 `wslcsdk` +- [ ] 链接 `ole32` + +## Safe wrapper + +- [ ] `Error` +- [ ] `Service` +- [ ] `SessionBuilder` +- [ ] `Session` +- [ ] `ImagePullOperation` +- [ ] `ContainerBuilder` +- [ ] `Container` +- [ ] `ProcessOptions` +- [ ] `Process` +- [ ] `Output` +- [ ] `CoTaskMem` wrapper +- [ ] UTF-16 conversion +- [ ] CString conversion +- [ ] callback trampoline +- [ ] Drop release handles +- [ ] integration test feature + +## 文档 + +- [ ] README quickstart +- [ ] SDK 安装说明 +- [ ] Windows-only 说明 +- [ ] Preview API 说明 +- [ ] 安全边界说明 +- [ ] 示例:hello +- [ ] 示例:nginx port mapping +- [ ] 示例:volume mount +- [ ] 示例:exec +- [ ] 示例:image list diff --git a/wix/Product.ico b/wix/Product.ico index 59dc283..b8e4268 100644 Binary files a/wix/Product.ico and b/wix/Product.ico differ diff --git a/wix/Product.png b/wix/Product.png new file mode 100644 index 0000000..bb014be Binary files /dev/null and b/wix/Product.png differ diff --git a/wix/main.wxs b/wix/main.wxs index b7128dc..06b3e84 100644 --- a/wix/main.wxs +++ b/wix/main.wxs @@ -3,7 +3,8 @@ WSLc Desktop installer. - Installs the GUI app under Program Files - Creates Start Menu + Desktop shortcuts (with product icon) - - Does NOT add PATH (no command-line launch requirement) + - Does NOT add PATH and does NOT bundle wslcsdk.dll + (LIVE mode loads the system / PATH copy of the SDK) --> diff --git a/wix/sdk/README.md b/wix/sdk/README.md new file mode 100644 index 0000000..ac0fc8e --- /dev/null +++ b/wix/sdk/README.md @@ -0,0 +1,14 @@ +# SDK notes + +The MSI does **not** embed `wslcsdk.dll`. At runtime the app: + +1. Reads `wslc version` / `wsl --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\` + +Optional offline helper for developers: + +```powershell +.\scripts\fetch-wslc-sdk.ps1 +$env:PATH = "$(Resolve-Path .\.wslc-sdk\native);$env:PATH" +```