From 6ae993de8cb2b5f515deb741d082ce317cd662c9 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 17 Jun 2026 08:35:43 -0500 Subject: [PATCH] WSL browsing support --- crates/librarian-app/src/icons.rs | 5 + crates/librarian-app/src/main.rs | 77 +++++++++++--- crates/librarian-app/src/rows.rs | 17 ++- crates/librarian-app/src/tree.rs | 71 +++++++++++-- crates/librarian-core/src/model.rs | 92 +++++++++++++++-- crates/librarian-win/Cargo.toml | 1 + crates/librarian-win/src/icon.rs | 37 +++++++ crates/librarian-win/src/lib.rs | 4 +- crates/librarian-win/src/wsl.rs | 160 +++++++++++++++++++++++++++++ 9 files changed, 434 insertions(+), 30 deletions(-) create mode 100644 crates/librarian-win/src/wsl.rs diff --git a/crates/librarian-app/src/icons.rs b/crates/librarian-app/src/icons.rs index c318491..7c61128 100644 --- a/crates/librarian-app/src/icons.rs +++ b/crates/librarian-app/src/icons.rs @@ -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) -> 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)) diff --git a/crates/librarian-app/src/main.rs b/crates/librarian-app/src/main.rs index e2023a0..137ae5b 100644 --- a/crates/librarian-app/src/main.rs +++ b/crates/librarian-app/src/main.rs @@ -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, }, + /// 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, + }, Folder { entries: Vec, loading: bool, @@ -568,6 +575,7 @@ enum Message { SelectAll, Activate, ThisPcLoaded(Vec), + WslLoaded(Vec), Loaded(u64, Result, 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 { 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 = 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 { 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 { } /// 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, 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 { size: px, mtime: row.modified, }), - Location::ThisPc => None, + Location::ThisPc | Location::Wsl => None, } } diff --git a/crates/librarian-app/src/rows.rs b/crates/librarian-app/src/rows.rs index d92cd73..4b89100 100644 --- a/crates/librarian-app/src/rows.rs +++ b/crates/librarian-app/src/rows.rs @@ -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\` 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. diff --git a/crates/librarian-app/src/tree.rs b/crates/librarian-app/src/tree.rs index 10ad26b..50439f6 100644 --- a/crates/librarian-app/src/tree.rs +++ b/crates/librarian-app/src/tree.rs @@ -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\ 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, diff --git a/crates/librarian-core/src/model.rs b/crates/librarian-core/src/model.rs index 70aa621..9723bcc 100644 --- a/crates/librarian-core/src/model.rs +++ b/crates/librarian-core/src/model.rs @@ -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\`) is the `Wsl` group. pub fn parent(&self) -> Option { 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\` (or +/// legacy `\\wsl$\`) 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)); + } +} diff --git a/crates/librarian-win/Cargo.toml b/crates/librarian-win/Cargo.toml index affe1ce..66e841e 100644 --- a/crates/librarian-win/Cargo.toml +++ b/crates/librarian-win/Cargo.toml @@ -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", ] } diff --git a/crates/librarian-win/src/icon.rs b/crates/librarian-win/src/icon.rs index 52fc1a8..22fd229 100644 --- a/crates/librarian-win/src/icon.rs +++ b/crates/librarian-win/src/icon.rs @@ -116,6 +116,31 @@ pub fn computer_icon(_apt: &Apartment, large: bool) -> Option { } } +/// 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 { + // 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() diff --git a/crates/librarian-win/src/lib.rs b/crates/librarian-win/src/lib.rs index 8b78234..4a59161 100644 --- a/crates/librarian-win/src/lib.rs +++ b/crates/librarian-win/src/lib.rs @@ -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}; diff --git a/crates/librarian-win/src/wsl.rs b/crates/librarian-win/src/wsl.rs new file mode 100644 index 0000000..1dfaf8e --- /dev/null +++ b/crates/librarian-win/src/wsl.rs @@ -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\` +//! 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\` 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 { + 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 { + 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); + } +}