WSL browsing support

This commit is contained in:
2026-06-17 08:35:43 -05:00
parent e1666287dd
commit 6ae993de8c
9 changed files with 434 additions and 30 deletions
+5
View File
@@ -11,6 +11,7 @@ use std::path::PathBuf;
use iced::widget::image::Handle;
use librarian_win::{
IconImage, ShellWorker, computer_icon, folder_icon, icon_for_extension, icon_for_path,
wsl_icon,
};
/// What an icon represents. `Path` is used for things with a per-item icon
@@ -20,6 +21,9 @@ pub enum IconKey {
Folder,
/// The shell's "This PC" / Computer icon (the drives container node).
Computer,
/// The shell's "Linux" / WSL icon (the penguin), shared by the WSL group node
/// and every distro — one local extraction, cached and reused.
Wsl,
Ext(String),
Path(PathBuf),
}
@@ -62,6 +66,7 @@ pub fn extract_icons(worker: &ShellWorker, keys: Vec<IconKey>) -> Vec<(IconKey,
let image = match &key {
IconKey::Folder => worker.run(|apt| folder_icon(apt, false)),
IconKey::Computer => worker.run(|apt| computer_icon(apt, false)),
IconKey::Wsl => worker.run(|apt| wsl_icon(apt, false)),
IconKey::Ext(ext) => {
let ext = ext.clone();
worker.run(move |apt| icon_for_extension(apt, &ext, false))
+62 -15
View File
@@ -31,8 +31,9 @@ use librarian_core::{
sort_entries,
};
use librarian_win::{
Apartment, DriveInfo, IconImage, KnownFolder, ShellWorker, copy_items, create_folder,
delete_to_recycle, known_folders, list_drives, move_items, rename, user_home,
Apartment, DriveInfo, IconImage, KnownFolder, ShellWorker, WslDistro, copy_items,
create_folder, delete_to_recycle, known_folders, list_drives, list_wsl_distros, move_items,
rename, user_home,
};
use columns::{ColRule, Column, ColumnLayout};
@@ -289,6 +290,12 @@ enum Content {
// separately.
drives: Vec<DriveInfo>,
},
/// The "Linux" landing list: installed WSL distributions. Like `ThisPc`, a
/// pathless virtual root whose rows navigate into real `\\wsl.localhost\`
/// (UNC) paths.
Wsl {
distros: Vec<WslDistro>,
},
Folder {
entries: Vec<Entry>,
loading: bool,
@@ -568,6 +575,7 @@ enum Message {
SelectAll,
Activate,
ThisPcLoaded(Vec<DriveInfo>),
WslLoaded(Vec<WslDistro>),
Loaded(u64, Result<Vec<Entry>, String>),
/// The current directory changed on disk; re-enumerate it in place.
DirChanged,
@@ -805,7 +813,7 @@ impl Librarian {
fn column_layout_for(&self, location: &Location) -> ColumnLayout {
match location {
Location::Path(path) => self.col_store.get(path).copied().unwrap_or_default(),
Location::ThisPc => ColumnLayout::default(),
Location::ThisPc | Location::Wsl => ColumnLayout::default(),
}
}
@@ -878,7 +886,7 @@ impl Librarian {
self.pending_reveal = Some(path);
self.drive_reveal()
}
Location::ThisPc => {
Location::ThisPc | Location::Wsl => {
self.pending_reveal = None;
Task::none()
}
@@ -977,7 +985,7 @@ impl Librarian {
// the old watcher and starts one on the new directory.
let watch = match self.history.current() {
Location::Path(path) => Subscription::run_with(path.clone(), watch_stream),
Location::ThisPc => Subscription::none(),
Location::ThisPc | Location::Wsl => Subscription::none(),
};
// Animate the loading spinner only while the overlay is up, so an idle
@@ -1132,6 +1140,12 @@ impl Librarian {
self.status = format!("{} items", self.rows.len());
return Task::batch([self.request_icons(), self.begin_grid_session(true)]);
}
Message::WslLoaded(distros) => {
self.content = Content::Wsl { distros };
self.recompute_rows();
self.status = format!("{} items", self.rows.len());
return Task::batch([self.request_icons(), self.begin_grid_session(true)]);
}
Message::Loaded(token, result) => {
if token != self.load_token {
return Task::none(); // a newer navigation won
@@ -1644,7 +1658,7 @@ impl Librarian {
self.pending_reveal = Some(path.clone());
self.drive_reveal()
}
Location::ThisPc => {
Location::ThisPc | Location::Wsl => {
self.pending_reveal = None;
Task::none()
}
@@ -1656,6 +1670,13 @@ impl Librarian {
self.rows.clear();
Task::perform(offload(list_drives), Message::ThisPcLoaded)
}
Location::Wsl => {
self.content = Content::Wsl {
distros: Vec::new(),
};
self.rows.clear();
Task::perform(offload(list_wsl_distros), Message::WslLoaded)
}
Location::Path(path) => {
self.next_token += 1;
let token = self.next_token;
@@ -1730,7 +1751,7 @@ impl Librarian {
fn current_dir(&self) -> Option<PathBuf> {
match self.history.current() {
Location::Path(path) => Some(path.clone()),
Location::ThisPc => None,
Location::ThisPc | Location::Wsl => None,
}
}
@@ -1745,7 +1766,7 @@ impl Librarian {
.filter_map(|i| self.rows.get(i))
.filter_map(|row| match &row.target {
Location::Path(path) => Some(path.clone()),
Location::ThisPc => None,
Location::ThisPc | Location::Wsl => None,
})
.collect()
}
@@ -1756,7 +1777,7 @@ impl Librarian {
self.current_dir()?;
match &self.rows.get(self.selection.lead()?)?.target {
Location::Path(path) => Some(path.clone()),
Location::ThisPc => None,
Location::ThisPc | Location::Wsl => None,
}
}
@@ -1939,6 +1960,7 @@ impl Librarian {
fn recompute_rows(&mut self) {
self.rows = match &self.content {
Content::ThisPc { drives } => drives.iter().map(rows::row_from_drive).collect(),
Content::Wsl { distros } => distros.iter().map(rows::row_from_distro).collect(),
Content::Folder { entries, .. } => {
let mut visible: Vec<Entry> = entries
.iter()
@@ -2251,7 +2273,7 @@ impl Librarian {
// Separate the user's folders (above) from the "This PC" drives
// section (below) with a divider. Skip it when "This PC" is the very
// first row, so we never lead with a stray rule.
if i > 0 && matches!(row.location, Location::ThisPc) {
if i > 0 && matches!(row.location, Location::ThisPc | Location::Wsl) {
list = list.push(tree_section_divider());
}
list = list.push(self.view_tree_row(row));
@@ -2845,7 +2867,7 @@ fn gap() -> Element<'static, Message> {
fn full_path_text(row: &Row) -> String {
match &row.target {
Location::Path(path) => path.display().to_string(),
Location::ThisPc => row.label.clone(),
Location::ThisPc | Location::Wsl => row.label.clone(),
}
}
@@ -3245,6 +3267,7 @@ fn menu_panel_style(theme: &Theme) -> container::Style {
fn address_text(location: &Location) -> String {
match location {
Location::ThisPc => "This PC".to_string(),
Location::Wsl => "Linux".to_string(),
Location::Path(path) => path.display().to_string(),
}
}
@@ -3254,6 +3277,7 @@ fn address_text(location: &Location) -> String {
fn tab_title(location: &Location) -> String {
match location {
Location::ThisPc => "This PC".to_string(),
Location::Wsl => "Linux".to_string(),
Location::Path(path) => path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
@@ -3277,6 +3301,12 @@ fn fetch_tree_roots(worker: &ShellWorker) -> Vec<TreeChild> {
None => roots.extend(known),
}
roots.push(this_pc_tree_child());
// Surface the "Linux" group only when WSL is actually present — i.e. the
// Lxss registry key lists at least one distro. This startup check is the
// sole gate: when it's empty the node (and its penguin icon) never exist.
if !list_wsl_distros().is_empty() {
roots.push(wsl_tree_child());
}
roots
}
@@ -3297,14 +3327,18 @@ fn home_tree_child(home: PathBuf, folders: Vec<TreeChild>) -> TreeChild {
}
/// Load the children for a folder-tree node: the drives under the "This PC"
/// node, or the (visible) subdirectories of a real folder, sorted by name. Runs
/// on a worker thread via [`offload`]. (Neither path needs the COM worker — the
/// top-level known folders that do are loaded by [`fetch_tree_roots`].)
/// node, the WSL distros under the "Linux" node, or the (visible) subdirectories
/// of a real folder, sorted by name. Runs on a worker thread via [`offload`].
/// (None of these need the COM worker — the top-level known folders that do are
/// loaded by [`fetch_tree_roots`].)
fn fetch_tree_children(location: &Location, show_hidden: bool) -> Result<Vec<TreeChild>, String> {
match location {
// "This PC" now contains only the system drives; the user's folders are
// their own top-level nodes (see [`fetch_tree_roots`]).
Location::ThisPc => Ok(list_drives().iter().map(tree_child_from_drive).collect()),
// The "Linux" group lists the installed WSL distros (registry-sourced,
// so listing them never starts a distro).
Location::Wsl => Ok(list_wsl_distros().iter().map(tree_child_from_distro).collect()),
Location::Path(dir) => {
let mut dirs = read_subdirs(dir).map_err(|e| e.to_string())?;
dirs.retain(|e| is_visible(e, show_hidden, ""));
@@ -3323,6 +3357,12 @@ fn this_pc_tree_child() -> TreeChild {
TreeChild::lazy("This PC".to_string(), IconKey::Computer, Location::ThisPc)
}
/// The standalone "Linux" tree node — an expandable container for the WSL
/// distros, mirroring the "This PC" node and carrying the shared penguin icon.
fn wsl_tree_child() -> TreeChild {
TreeChild::lazy("Linux".to_string(), IconKey::Wsl, Location::Wsl)
}
/// A tree child built from a drive, reusing the file-list row mapping so the
/// label and icon match what the "This PC" listing shows.
fn tree_child_from_drive(drive: &DriveInfo) -> TreeChild {
@@ -3330,6 +3370,13 @@ fn tree_child_from_drive(drive: &DriveInfo) -> TreeChild {
TreeChild::lazy(row.label, row.icon, row.target)
}
/// A tree child built from a WSL distro, reusing the landing-list row mapping so
/// the label, icon, and target match what the "Linux" listing shows.
fn tree_child_from_distro(distro: &WslDistro) -> TreeChild {
let row = rows::row_from_distro(distro);
TreeChild::lazy(row.label, row.icon, row.target)
}
fn tree_child_from_known(folder: &KnownFolder) -> TreeChild {
let row = rows::row_from_known(folder);
TreeChild::lazy(row.label, row.icon, row.target)
@@ -3659,7 +3706,7 @@ fn thumb_key(row: &Row, px: u16) -> Option<ThumbKey> {
size: px,
mtime: row.modified,
}),
Location::ThisPc => None,
Location::ThisPc | Location::Wsl => None,
}
}
+16 -1
View File
@@ -6,7 +6,7 @@ use std::time::SystemTime;
use chrono::{DateTime, Local, Utc};
use librarian_core::{Entry, Location};
use librarian_win::{DriveInfo, DriveKind, KnownFolder};
use librarian_win::{DriveInfo, DriveKind, KnownFolder, WslDistro, distro_unc_path};
use crate::icons::IconKey;
use crate::search::SearchHit;
@@ -75,6 +75,21 @@ pub fn row_from_known(folder: &KnownFolder) -> Row {
}
}
/// A row for one WSL distribution in the "Linux" landing list. Activating it
/// navigates into the distro's `\\wsl.localhost\<name>` root; it shares the
/// Linux/penguin icon with the WSL group node.
pub fn row_from_distro(distro: &WslDistro) -> Row {
Row {
label: distro.name.clone(),
icon: IconKey::Wsl,
is_container: true,
target: Location::Path(distro_unc_path(&distro.name)),
size: None,
modified: None,
type_label: "Linux distribution".to_string(),
}
}
/// A row for one search result. The label is the path *relative to the search
/// root* (so the result's location is visible at a glance, like an editor's
/// search panel). A directory hit navigates when activated; a file hit opens.
+65 -6
View File
@@ -323,12 +323,15 @@ fn reveal_node(node: &mut TreeNode, target: &Path) -> Reveal {
return reveal_node(&mut children[i], target);
}
// No path child matched. At the top level the drives live under the
// "This PC" node (whose own location is pathless), so fall back to
// descending into it — one of its drives will prefix the target.
if let Some(i) = children
.iter()
.position(|c| matches!(c.location, Location::ThisPc))
{
// pathless "This PC" node and the distros under the pathless "Linux"
// (Wsl) node, so fall back into whichever virtual root owns this
// target — one of its children will prefix it.
let virtual_root = if is_wsl_path(target) {
Location::Wsl
} else {
Location::ThisPc
};
if let Some(i) = children.iter().position(|c| c.location == virtual_root) {
return reveal_node(&mut children[i], target);
}
Reveal::Stop // target not under any child (hidden, gone, …)
@@ -336,6 +339,23 @@ fn reveal_node(node: &mut TreeNode, target: &Path) -> Reveal {
}
}
/// Whether `target` lives under the WSL "Linux" group — a `\\wsl.localhost\`
/// (or legacy `\\wsl$\`) UNC path — so reveal descends into the `Wsl` node
/// rather than "This PC". A cheap host-segment check keeps this module
/// filesystem-free.
fn is_wsl_path(target: &Path) -> bool {
let lossy = target.to_string_lossy();
let Some(rest) = lossy
.strip_prefix(r"\\")
.or_else(|| lossy.strip_prefix("//"))
else {
return false;
};
let host_len = rest.find(['\\', '/']).unwrap_or(rest.len());
let host = &rest[..host_len];
host.eq_ignore_ascii_case("wsl.localhost") || host.eq_ignore_ascii_case("wsl$")
}
/// Among `children`, the index of the one whose path is the longest prefix of
/// `target` (the deepest ancestor to descend into). `starts_with` is
/// component-wise, so `C:\Users` matches `C:\Users\me` but not `C:\UsersX`.
@@ -367,6 +387,11 @@ mod tests {
TreeChild::lazy("This PC".to_string(), IconKey::Folder, Location::ThisPc)
}
/// A pathless "Linux" child — the WSL distros' container at the top level.
fn wsl_group() -> TreeChild {
TreeChild::lazy("Linux".to_string(), IconKey::Wsl, Location::Wsl)
}
/// Find a visible row by label, for assertions.
fn row_id(tree: &Tree, label: &str) -> NodeId {
tree.visible_rows()
@@ -555,6 +580,40 @@ mod tests {
}
}
#[test]
fn reveal_descends_through_wsl_to_a_distro_path() {
// Distros sit under the pathless "Linux" node, so revealing a
// \\wsl.localhost\<distro> path must fall back into the Wsl group
// (not This PC, which a non-WSL path would).
let mut tree = Tree::new();
tree.set_children(
ROOT_ID,
vec![
child("Desktop", "C:\\Users\\me\\Desktop"),
this_pc(),
wsl_group(),
],
);
let target = PathBuf::from(r"\\wsl.localhost\Ubuntu\home");
// No top-level path child prefixes the target and it's a WSL path, so
// reveal descends into the "Linux" node and asks to load it.
let wsl = row_id(&tree, "Linux");
match tree.reveal(&target) {
Reveal::Load(id, Location::Wsl) => assert_eq!(id, wsl),
other => panic!("expected to load the Wsl node, got {other:?}"),
}
// Its distros arrive; the next step descends into the Ubuntu root.
tree.set_children(wsl, vec![child("Ubuntu", r"\\wsl.localhost\Ubuntu")]);
match tree.reveal(&target) {
Reveal::Load(_, Location::Path(p)) => {
assert_eq!(p, PathBuf::from(r"\\wsl.localhost\Ubuntu"))
}
other => panic!("expected to load the Ubuntu distro root, got {other:?}"),
}
}
#[test]
fn branch_grafts_children_and_starts_expanded() {
// A `branch` node arrives with its children already attached and shown,
+85 -7
View File
@@ -4,7 +4,7 @@
//! Windows file attributes are read from the standard library's cached
//! `MetadataExt` so enumeration needs no extra syscalls per entry.
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf, Prefix};
use std::time::SystemTime;
// Raw Windows file-attribute bits (see `MetadataExt::file_attributes`). Defined
@@ -75,13 +75,17 @@ impl Entry {
}
}
/// A browsable location. `ThisPc` is the virtual root (drives + known folders);
/// everything reachable below a real folder is a plain `Path`. More virtual
/// roots (Recycle Bin, Network) can be added here later.
/// A browsable location. `ThisPc` and `Wsl` are the virtual roots — `ThisPc`
/// holds the drives (and known folders), `Wsl` the installed Linux distros;
/// everything reachable below a real folder (including inside a distro, via its
/// `\\wsl.localhost\` UNC path) is a plain `Path`. More virtual roots (Recycle
/// Bin, Network) can be added here later.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Location {
Path(PathBuf),
ThisPc,
/// The "Linux" group: the landing list of WSL distributions.
Wsl,
}
impl Location {
@@ -89,6 +93,7 @@ impl Location {
pub fn label(&self) -> String {
match self {
Location::ThisPc => "This PC".to_string(),
Location::Wsl => "Linux".to_string(),
Location::Path(p) => p
.file_name()
.and_then(|n| n.to_str())
@@ -102,7 +107,7 @@ impl Location {
pub fn as_path(&self) -> Option<&Path> {
match self {
Location::Path(p) => Some(p),
Location::ThisPc => None,
Location::ThisPc | Location::Wsl => None,
}
}
@@ -111,16 +116,20 @@ impl Location {
let trimmed = input.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("This PC") {
Location::ThisPc
} else if trimmed.eq_ignore_ascii_case("Linux") || trimmed.eq_ignore_ascii_case("WSL") {
Location::Wsl
} else {
Location::Path(PathBuf::from(trimmed))
}
}
/// The parent location, used by the "Up" command. The parent of a drive
/// root (or any path with no parent) is `ThisPc`.
/// root (or any path with no parent) is `ThisPc`; the parent of a distro
/// root (`\\wsl.localhost\<name>`) is the `Wsl` group.
pub fn parent(&self) -> Option<Location> {
match self {
Location::ThisPc => None,
Location::ThisPc | Location::Wsl => None,
Location::Path(p) if is_wsl_root_path(p) => Some(Location::Wsl),
Location::Path(p) => match p.parent() {
Some(parent) if !parent.as_os_str().is_empty() => {
Some(Location::Path(parent.to_path_buf()))
@@ -130,3 +139,72 @@ impl Location {
}
}
}
/// Whether `p` is the root of a WSL distro — a `\\wsl.localhost\<name>` (or
/// legacy `\\wsl$\<name>`) UNC path with nothing below the distro share. Such a
/// path has no filesystem parent, so "Up" routes to the `Wsl` group instead.
fn is_wsl_root_path(p: &Path) -> bool {
let mut comps = p.components();
let Some(Component::Prefix(prefix)) = comps.next() else {
return false;
};
let Prefix::UNC(server, _share) = prefix.kind() else {
return false;
};
let server = server.to_string_lossy();
if !server.eq_ignore_ascii_case("wsl.localhost") && !server.eq_ignore_ascii_case("wsl$") {
return false;
}
// The UNC prefix already includes the distro (the share); a bare distro root
// leaves only the root directory, with no further components below it.
matches!(comps.next(), Some(Component::RootDir)) && comps.next().is_none()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_virtual_roots() {
assert_eq!(Location::parse(""), Location::ThisPc);
assert_eq!(Location::parse("This PC"), Location::ThisPc);
assert_eq!(Location::parse("linux"), Location::Wsl);
assert_eq!(Location::parse("WSL"), Location::Wsl);
assert_eq!(
Location::parse(r"\\wsl.localhost\Ubuntu"),
Location::Path(PathBuf::from(r"\\wsl.localhost\Ubuntu"))
);
}
#[test]
fn wsl_group_labels_and_has_no_path_or_parent() {
assert_eq!(Location::Wsl.label(), "Linux");
assert_eq!(Location::Wsl.as_path(), None);
assert_eq!(Location::Wsl.parent(), None);
}
#[test]
fn distro_root_parent_is_the_wsl_group() {
let root = Location::Path(PathBuf::from(r"\\wsl.localhost\Ubuntu"));
assert_eq!(root.parent(), Some(Location::Wsl));
// The legacy alias resolves the same way.
let legacy = Location::Path(PathBuf::from(r"\\wsl$\Ubuntu"));
assert_eq!(legacy.parent(), Some(Location::Wsl));
}
#[test]
fn inside_a_distro_walks_up_normally() {
let sub = Location::Path(PathBuf::from(r"\\wsl.localhost\Ubuntu\home"));
assert_eq!(
sub.parent(),
Some(Location::Path(PathBuf::from(r"\\wsl.localhost\Ubuntu")))
);
}
#[test]
fn ordinary_unc_share_is_not_a_distro_root() {
// A normal network share must keep its This PC fallback, not become Wsl.
let share = Location::Path(PathBuf::from(r"\\server\share"));
assert_eq!(share.parent(), Some(Location::ThisPc));
}
}
+1
View File
@@ -15,6 +15,7 @@ windows = { workspace = true, features = [
"Win32_UI_WindowsAndMessaging",
"Win32_UI_Controls",
"Win32_Storage_FileSystem",
"Win32_System_Registry",
"Win32_Graphics_Gdi",
"Win32_Graphics_Dwm",
] }
+37
View File
@@ -116,6 +116,31 @@ pub fn computer_icon(_apt: &Apartment, large: bool) -> Option<IconImage> {
}
}
/// The shell's "Linux" (WSL) icon — the penguin Explorer shows for its Linux
/// navigation node and for each distro under it. The Linux root is a virtual
/// shell namespace extension addressed by CLSID (not a file path), so we resolve
/// it by parsing name. This is purely local: it touches the registered icon
/// handler, never the `\\wsl.localhost` redirector, so it can't wake a distro.
/// Returns `None` when the extension isn't registered (i.e. WSL absent). Must run
/// on the COM STA thread.
pub fn wsl_icon(_apt: &Apartment, large: bool) -> Option<IconImage> {
// CLSID of the "Linux" shell folder registered by WSL.
const LINUX_FOLDER: &str = "::{B2B4A4D1-2754-4140-A2EB-9A76D9D7CDC6}";
let size = if large { 32 } else { 16 };
let wide = to_wide(LINUX_FOLDER);
unsafe {
let factory: IShellItemImageFactory =
SHCreateItemFromParsingName(PCWSTR(wide.as_ptr()), None).ok()?;
// No thumbnail for a namespace folder, so the shell returns its icon.
let hbm = factory
.GetImage(SIZE { cx: size, cy: size }, SIIGBF_RESIZETOFIT)
.ok()?;
let image = hbitmap_to_rgba(hbm);
_ = DeleteObject(HGDIOBJ(hbm.0));
image
}
}
/// A thumbnail (or, for items without one, the scaled shell icon) for `path`,
/// fit within a `size`×`size` box, as straight-alpha RGBA. This is the same data
/// Explorer's icon views show. `size` should be one of the Windows thumbnail
@@ -440,6 +465,18 @@ mod tests {
let _ = std::fs::remove_file(&path);
}
#[test]
fn wsl_icon_is_valid_when_present() {
// The Linux shell extension is only registered when WSL is installed, so
// this returns `None` on a machine without it — both outcomes are fine.
// When present, the decoded icon must be well-formed and visible.
if let Some(icon) = worker().run(|apt| wsl_icon(apt, false)) {
assert!(icon.width > 0 && icon.height > 0);
assert_eq!(icon.rgba.len(), (icon.width * icon.height * 4) as usize);
assert!(icon.rgba.chunks_exact(4).any(|px| px[3] != 0));
}
}
#[test]
fn extracts_the_computer_icon() {
let icon = worker()
+3 -1
View File
@@ -16,13 +16,15 @@ pub mod fileop;
pub mod icon;
pub mod known;
pub mod open;
pub mod wsl;
pub use chrome::apply_window_chrome;
pub use com::{Apartment, ShellWorker};
pub use drives::{DriveInfo, DriveKind, list_drives};
pub use fileop::{copy_items, create_folder, delete_to_recycle, move_items, rename};
pub use icon::{
IconImage, computer_icon, folder_icon, icon_for_extension, icon_for_path, thumbnail,
IconImage, computer_icon, folder_icon, icon_for_extension, icon_for_path, thumbnail, wsl_icon,
};
pub use known::{KnownFolder, known_folders, user_home};
pub use open::open_path;
pub use wsl::{WslDistro, distro_unc_path, list_wsl_distros};
+160
View File
@@ -0,0 +1,160 @@
//! WSL distro discovery for the navigation pane's "Linux" group.
//!
//! Installed distros are read straight from the registry
//! (`HKCU\Software\Microsoft\Windows\CurrentVersion\Lxss`) — the same source the
//! Windows shell uses for its Linux node. This is a handful of local registry
//! reads: no `wsl.exe` process spawn, no network, and (critically) it never
//! *starts* a distro, so listing them in the tree stays cheap and side-effect
//! free. Browsing into one then goes through the normal `\\wsl.localhost\<name>`
//! UNC path like any other directory.
use std::path::PathBuf;
use windows::Win32::Foundation::ERROR_SUCCESS;
use windows::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, KEY_READ, REG_SZ, REG_VALUE_TYPE, RegCloseKey, RegEnumKeyExW,
RegOpenKeyExW, RegQueryValueExW,
};
use windows::core::{PCWSTR, PWSTR};
use crate::util::{to_wide, wide_to_string};
/// The registry key, under `HKEY_CURRENT_USER`, whose child keys are the
/// installed WSL distributions (one per distro GUID).
const LXSS_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Lxss";
#[derive(Debug, Clone)]
pub struct WslDistro {
/// The distro's registered name (e.g. `Ubuntu`), which is also its
/// `\\wsl.localhost\` share name.
pub name: String,
}
/// The `\\wsl.localhost\<name>` root of a distro's filesystem (the Windows
/// 11-canonical form; `\\wsl$\` is the legacy alias). Browsing this path uses
/// the normal directory enumeration — the WSL P9 redirector serves it like any
/// other UNC share.
pub fn distro_unc_path(name: &str) -> PathBuf {
PathBuf::from(format!(r"\\wsl.localhost\{name}"))
}
/// Enumerate installed WSL distributions from the registry, sorted by name.
/// Returns an empty list when WSL isn't present (the `Lxss` key is absent) — the
/// caller uses that to omit the "Linux" group from the nav pane entirely.
///
/// Plain Win32 registry calls (no COM), so this is safe to run from any thread,
/// e.g. off the UI thread via `offload`.
pub fn list_wsl_distros() -> Vec<WslDistro> {
let mut distros = Vec::new();
let lxss_path = to_wide(LXSS_KEY);
unsafe {
let mut lxss = HKEY::default();
// SAFETY: `lxss_path` outlives the call backing `PCWSTR`; `lxss` receives
// the opened key handle, closed below.
let opened = RegOpenKeyExW(
HKEY_CURRENT_USER,
PCWSTR(lxss_path.as_ptr()),
None,
KEY_READ,
&mut lxss,
);
if opened != ERROR_SUCCESS {
return distros; // No Lxss key → WSL not installed.
}
// Each child key is named after a distro GUID; its `DistributionName`
// value holds the display name (and `\\wsl.localhost\` share).
let mut index = 0u32;
loop {
let mut name_buf = [0u16; 256]; // Registry key names cap at 255 chars.
let mut name_len = name_buf.len() as u32;
// SAFETY: `name_buf`/`name_len` are a valid buffer + its char length;
// the unused class/reserved/time out-params are passed as `None`.
let res = RegEnumKeyExW(
lxss,
index,
Some(PWSTR(name_buf.as_mut_ptr())),
&mut name_len,
None,
None,
None,
None,
);
if res != ERROR_SUCCESS {
break; // ERROR_NO_MORE_ITEMS (or any error) ends enumeration.
}
index += 1;
if let Some(name) = read_distribution_name(lxss, &name_buf) {
distros.push(WslDistro { name });
}
}
let _ = RegCloseKey(lxss);
}
distros.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
distros
}
/// Open the distro subkey named by the NUL-terminated `guid` buffer (under the
/// open `lxss` key) and read its `DistributionName` string value, if present and
/// non-empty.
fn read_distribution_name(lxss: HKEY, guid: &[u16]) -> Option<String> {
unsafe {
let mut sub = HKEY::default();
let opened = RegOpenKeyExW(lxss, PCWSTR(guid.as_ptr()), None, KEY_READ, &mut sub);
if opened != ERROR_SUCCESS {
return None;
}
let value_name = to_wide("DistributionName");
let mut buf = [0u16; 512];
let mut kind = REG_VALUE_TYPE::default();
let mut bytes = std::mem::size_of_val(&buf) as u32;
// SAFETY: `value_name` backs the `PCWSTR`; `buf`/`bytes` are a valid byte
// buffer and its size; `kind` receives the value type.
let res = RegQueryValueExW(
sub,
PCWSTR(value_name.as_ptr()),
None,
Some(&mut kind),
Some(buf.as_mut_ptr() as *mut u8),
Some(&mut bytes),
);
let _ = RegCloseKey(sub);
if res != ERROR_SUCCESS || kind != REG_SZ {
return None;
}
let name = wide_to_string(&buf);
(!name.is_empty()).then_some(name)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn distro_unc_path_is_the_localhost_share() {
assert_eq!(
distro_unc_path("Ubuntu"),
PathBuf::from(r"\\wsl.localhost\Ubuntu")
);
}
#[test]
fn list_is_well_formed() {
// WSL may or may not be installed on the test machine; either way the
// call must not panic, and any distro it returns has a non-empty name.
let distros = list_wsl_distros();
assert!(distros.iter().all(|d| !d.name.is_empty()));
// Sorted case-insensitively by name.
let mut sorted = distros.clone();
sorted.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
let names: Vec<&str> = distros.iter().map(|d| d.name.as_str()).collect();
let sorted_names: Vec<&str> = sorted.iter().map(|d| d.name.as_str()).collect();
assert_eq!(names, sorted_names);
}
}