diff --git a/README.md b/README.md index b95f6a1..095cc2e 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,7 @@ rexplorer [options] [folder...] --import-explorer copy Windows Explorer's settings into the active profile (backs up settings.ini) --overwrite with --import-explorer: also replace values you changed in rexplorer --recover restore the previous session after a crash + --desktop run the helper that shows rexplorer's context menu on the desktop (install starts it) --elevated when started elevated, stay so (drive-wide indexed search) instead of restarting unelevated -h, --help show this help ``` @@ -267,8 +268,8 @@ with where rexplorer is installed, how to start it and how to uninstall. `-Exe < `-Uninstall` reverts. The import is `rexplorer.exe --import-explorer [--overwrite]`, which you can run any time. It only reads Explorer's -values and sets these keys of the active profile in `settings.ini` (comments kept; a key it changes moves to the top -of the profile's section): +values and sets these keys of the active profile in `settings.ini` (comments, line endings and spacing kept; a key +already there changes in place, a new one goes to the top of the profile's section): | Explorer value | rexplorer key | |---|---| @@ -298,8 +299,8 @@ target\release\rexplorer.exe --install Uninstall: `powershell -ExecutionPolicy Bypass -File scripts\install.ps1 -Uninstall`, or `%LOCALAPPDATA%\Programs\rexplorer\rexplorer.exe --uninstall`, or More > **Uninstall...**. -What it changes is listed in the next section. The desktop, the taskbar and the Start menu stay Explorer's (Start -only gains a rexplorer shortcut). To go back +What it changes is listed in the next section. The taskbar and the Start menu stay Explorer's (Start only gains a +rexplorer shortcut), and Explorer keeps drawing the desktop: only the desktop's right-click menu becomes rexplorer's. To go back to Explorer, uninstall; if rexplorer will not start, run `C:\Windows\explorer.exe` and use Win+R to run the uninstall command above. @@ -307,7 +308,8 @@ command above. `rexplorer --install` (or More > **Install as default file manager...**) copies the running exe to `%LOCALAPPDATA%\Programs\rexplorer\rexplorer.exe` and points Windows' entry points for folder windows at that copy. -It replaces Explorer's folder windows only: the desktop, the taskbar and the Start menu stay Explorer. +It replaces Explorer's folder windows and the desktop's right-click menu: the desktop itself, the taskbar and the +Start menu stay Explorer. | Entry point | After `--install` | How | |---|---|---| @@ -315,8 +317,9 @@ It replaces Explorer's folder windows only: the desktop, the taskbar and the Sta | Win+E, the taskbar's File Explorer icon, Start's "File Explorer" | rexplorer, at the start place (Home) | `HKCU\Software\Classes\CLSID\{52205fd8-5dfb-447d-801a-d0b52f2e83e1}\shell\{open, opennewwindow}\command` = `""` | | Programs that run `explorer.exe` by bare name through `ShellExecute` (`explorer.exe /select,`, `explorer.exe `) | rexplorer, with the arguments above | `HKCU\Software\Microsoft\Windows\CurrentVersion\App Paths\explorer.exe` = the copy, `Path` = its folder | | Start menu | a "rexplorer" shortcut | `\Programs\rexplorer.lnk` | +| Right-click on the desktop | rexplorer's menu | `\rexplorer desktop menu.lnk` runs `rexplorer --desktop` at sign-in; install also starts it | | Programs that start `C:\Windows\explorer.exe` with `CreateProcess`, or call `SHOpenFolderAndSelectItems` (Chrome-style "Show in folder") | still Explorer | not reachable per-user | -| The desktop, the taskbar, Start, other programs' Open / Save dialogs, Explorer windows already open | still Explorer | untouched | +| The desktop itself (icons, wallpaper, keyboard menus), the taskbar, Start, other programs' Open / Save dialogs, Explorer windows already open | still Explorer | untouched | Each command key also gets an empty `DelegateExecute` value, which stops Windows from using Explorer's own handler. `--install` refuses, changing nothing, when another program already overrides one of these keys. Everything it @@ -336,6 +339,22 @@ Nothing touches HKLM and nothing needs elevation: all of it is per-user under HK `--register` / `--unregister` remain the registry-only subset: they point the five command keys at the exe they run from, without copying it, without App Paths and without a shortcut. +### The desktop menu + +Explorer is the Windows shell (taskbar, Start, desktop) and keeps running: rexplorer never replaces that process. +`rexplorer --desktop` is a small helper (one per sign-in) with a low-level mouse hook. A right-click that lands on +the desktop's icon list is taken from Explorer and answered with rexplorer's menu, built like the file list's: on +an icon, the shell's verbs for the selection (an unselected icon is selected first, a selected one keeps the +selection, as in Explorer); on the background, View (icon sizes, Auto arrange, Align to grid), Sort by, Refresh and +Paste, then the shell's background verbs (New, Display settings, Personalize and installed extensions). Shift+right-click +adds the extended verbs. Everything else, a right-click on any other window included, passes through untouched. + +The helper reaches Explorer's desktop view through `IShellWindows` (as Explorer's own tools do) and reads the icon +under the cursor through the list's accessibility object. Uninstall closes it and removes the Startup shortcut. To +turn it off without uninstalling, delete `rexplorer desktop menu.lnk` from the Startup folder (`shell:startup`) and +end `rexplorer.exe --desktop`. Not covered: right-dragging desktop icons (the press goes to the menu), and Shift+F10 / +the Menu key on the desktop, which still open Explorer's menu. + ### Files under `%APPDATA%\rexplorer` `settings.ini` and `themes\*.ini` are written with defaults on first run, then yours to edit. A new profile has diff --git a/crates/app/src/desktop.rs b/crates/app/src/desktop.rs new file mode 100644 index 0000000..aa71d7b --- /dev/null +++ b/crates/app/src/desktop.rs @@ -0,0 +1,239 @@ +//! `rexplorer --desktop`: rexplorer's context menu on the desktop. Explorer keeps drawing the desktop (it is the +//! shell); a low-level mouse hook takes right-clicks that land on its icon list and shows our menu instead, built +//! like the file list's: the shell's verbs for the selected icons, or View, Sort by, Refresh and Paste plus the +//! shell's background verbs. One helper runs per session; install starts it at sign-in. +//! +//! The hook runs on its own thread and only classifies and posts, so it never stalls the system's mouse input +//! (Windows drops a hook that is slow to answer). Everything else runs on the main thread. + +use std::cell::RefCell; +use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering}; + +use rexplorer_core::com::ComInit; +use rexplorer_core::desktop::{self, ARRANGE, DesktopView, ICON_SIZES, SORT_KEYS}; +use rexplorer_core::folder::ShellFolder; +use rexplorer_core::item::ShellItem; +use rexplorer_core::menu::{ContextMenu, Picked}; +use windows::Win32::Foundation::{ERROR_ALREADY_EXISTS, GetLastError, HWND, LPARAM, LRESULT, POINT, WPARAM}; +use windows::Win32::Graphics::Gdi::DeleteObject; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::System::Threading::{AttachThreadInput, CreateMutexW, GetCurrentThreadId}; +use windows::Win32::UI::HiDpi::{DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, SetProcessDpiAwarenessContext}; +use windows::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_SHIFT}; +use windows::Win32::UI::WindowsAndMessaging::*; +use windows::core::{PCWSTR, Result, w}; + +use crate::commands; +use crate::ui::{self, Entry}; + +/// The helper's hidden window; uninstall closes it by this class. +pub(crate) const CLASS: PCWSTR = w!("RexDesktop"); +const WM_DESKTOP_MENU: u32 = WM_APP + 0x60; + +/// The window the hook posts to, and the desktop list a right press started on. +static TARGET: AtomicIsize = AtomicIsize::new(0); +static LIST: AtomicIsize = AtomicIsize::new(0); +static PRESSED: AtomicBool = AtomicBool::new(false); + +thread_local! { + /// The shell menu being shown: its owner-drawn and submenu messages are forwarded to it. + static ACTIVE: RefCell> = const { RefCell::new(None) }; + /// A menu is being built or shown. + static BUSY: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +// Own entry ids (`ui::build_menu` offsets them past the shell's). +const REFRESH: usize = 1; +const PASTE: usize = 2; +const ICON: usize = 0x100; +const ARRANGE_ID: usize = 0x110; +const SORT: usize = 0x120; + +pub(crate) fn run() -> Result<()> { + unsafe { + let _once = CreateMutexW(None, true, w!("Local\\rexplorer-desktop"))?; + if GetLastError() == ERROR_ALREADY_EXISTS { + return Ok(()); + } + let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + let _com = ComInit::ole(); + let hinst = GetModuleHandleW(None)?.into(); + RegisterClassW(&WNDCLASSW { lpfnWndProc: Some(wndproc), hInstance: hinst, lpszClassName: CLASS, ..Default::default() }); + let hwnd = CreateWindowExW(WS_EX_TOOLWINDOW, CLASS, w!("rexplorer desktop menu"), WS_POPUP, 0, 0, 0, 0, None, None, Some(hinst), None)?; + TARGET.store(hwnd.0 as isize, Ordering::SeqCst); + std::thread::spawn(hook_thread); + let mut msg = MSG::default(); + while GetMessageW(&mut msg, None, 0, 0).as_bool() { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } + Ok(()) +} + +fn hook_thread() { + unsafe { + let hinst = GetModuleHandleW(None).ok().map(Into::into); + let Ok(hook) = SetWindowsHookExW(WH_MOUSE_LL, Some(mouse_hook), hinst, 0) else { return }; + let mut msg = MSG::default(); + while GetMessageW(&mut msg, None, 0, 0).as_bool() {} + let _ = UnhookWindowsHookEx(hook); + } +} + +/// Takes a right press and release on the desktop's icon list; any other input passes on untouched. +unsafe extern "system" fn mouse_hook(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { + if code == HC_ACTION as i32 { + let m = &*(lparam.0 as *const MSLLHOOKSTRUCT); + match wparam.0 as u32 { + WM_RBUTTONDOWN => { + let h = WindowFromPoint(m.pt); + if desktop::is_desktop_list(h) { + LIST.store(h.0 as isize, Ordering::SeqCst); + PRESSED.store(true, Ordering::SeqCst); + return LRESULT(1); + } + } + WM_RBUTTONUP if PRESSED.swap(false, Ordering::SeqCst) => { + let target = HWND(TARGET.load(Ordering::SeqCst) as *mut _); + let _ = PostMessageW(Some(target), WM_DESKTOP_MENU, WPARAM(LIST.load(Ordering::SeqCst) as usize), LPARAM(pack(m.pt))); + return LRESULT(1); + } + _ => {} + } + } + CallNextHookEx(None, code, wparam, lparam) +} + +/// A screen point in one LPARAM (64-bit), negative coordinates of left or upper monitors included. +fn pack(pt: POINT) -> isize { + (((pt.y as u32 as u64) << 32) | pt.x as u32 as u64) as isize +} + +fn unpack(v: isize) -> POINT { + POINT { x: v as u64 as u32 as i32, y: (v as u64 >> 32) as u32 as i32 } +} + +unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { + match msg { + WM_DESKTOP_MENU => { + show(hwnd, HWND(wparam.0 as *mut _), unpack(lparam.0)); + LRESULT(0) + } + WM_INITMENUPOPUP | WM_DRAWITEM | WM_MEASUREITEM | WM_MENUCHAR => ACTIVE + .with(|a| a.borrow().as_ref().and_then(|m| m.handle_message(msg, wparam, lparam))) + .unwrap_or_else(|| DefWindowProcW(hwnd, msg, wparam, lparam)), + WM_DESTROY => { + PostQuitMessage(0); + LRESULT(0) + } + _ => DefWindowProcW(hwnd, msg, wparam, lparam), + } +} + +/// The background menu's own part, ahead of the shell's: View, Sort by | Refresh, Paste. +unsafe fn background_entries(view: &DesktopView) -> Vec { + let size = view.icon_size().unwrap_or(0); + let mut look: Vec = ICON_SIZES.iter().enumerate().map(|(i, (t, s))| Entry { checked: *s == size, ..Entry::new(ICON + i, t, 0) }).collect(); + look.push(Entry::sep()); + look.extend(ARRANGE.iter().enumerate().map(|(i, (t, f))| Entry { checked: view.has_flag(*f).unwrap_or(false), ..Entry::new(ARRANGE_ID + i, t, 0) })); + let sort = SORT_KEYS.iter().enumerate().map(|(i, (t, _))| Entry::new(SORT + i, t, 0)).collect(); + vec![ + Entry { sub: look, ..Entry::new(0, "View", 0xE8A9) }, + Entry { sub: sort, ..Entry::new(0, "Sort by", 0xE8CB) }, + Entry::sep(), + Entry::new(REFRESH, "Refresh", 0xE72C), + Entry::new(PASTE, "Paste", 0xE77F).enabled(commands::clipboard_has_files()), + Entry::sep(), + ] +} + +unsafe fn show(hwnd: HWND, list: HWND, pt: POINT) { + // A right-click while a menu is up: close it and answer this click once it is gone, as Explorer does (nesting + // the second menu inside the first would re-enter ACTIVE). A click while the shell is still building one is + // dropped. + if BUSY.with(|b| b.replace(true)) { + if ACTIVE.with(|a| a.try_borrow().map_or(true, |m| m.is_some())) { + let _ = EndMenu(); + let _ = PostMessageW(Some(hwnd), WM_DESKTOP_MENU, WPARAM(list.0 as usize), LPARAM(pack(pt))); + } + return; + } + menu_for(hwnd, list, pt); + BUSY.with(|b| b.set(false)); +} + +/// Gives the helper the foreground: a menu whose owner is not in front ignores clicks outside it, so it would never +/// close. The click that opened it was taken from Explorer, which leaves the helper without the right to take the +/// foreground; sharing the foreground thread's input state for the call grants it. +unsafe fn take_foreground(hwnd: HWND) { + let front = GetWindowThreadProcessId(GetForegroundWindow(), None); + let me = GetCurrentThreadId(); + let attached = front != 0 && front != me && AttachThreadInput(front, me, true).as_bool(); + let _ = SetForegroundWindow(hwnd); + if attached { + let _ = AttachThreadInput(front, me, false); + } +} + +unsafe fn menu_for(hwnd: HWND, list: HWND, pt: POINT) { + // Explorer restarts replace the view: look it up for every menu. + let Ok(view) = DesktopView::find() else { return }; + let index = DesktopView::index_at(list, pt); + let _ = view.select_for_menu(list, index); + let Ok(root) = ShellFolder::desktop() else { return }; + let items: Vec = match index { + Some(_) => DesktopView::selection(list).into_iter().filter_map(|i| view.item(i).ok()).map(|pidl| ShellItem { parent: root.folder.clone(), pidl }).collect(), + None => Vec::new(), + }; + let refs: Vec<&ShellItem> = items.iter().collect(); + let menu = if refs.is_empty() { ContextMenu::for_background(&root, hwnd) } else { ContextMenu::for_items(&root, &refs, hwnd) }; + let Ok(menu) = menu else { return }; + let own = if refs.is_empty() { background_entries(&view) } else { Vec::new() }; + let mut bitmaps = Vec::new(); + let hmenu = ui::build_menu(hwnd, &own, &mut bitmaps); + let at = GetMenuItemCount(Some(hmenu)).max(0) as u32; + let extended = GetAsyncKeyState(VK_SHIFT.0 as i32) < 0; + // Explorer's menus follow Windows' app mode; read it for every menu, so a switch applies at once. + crate::theme::set_menu_theme(rexplorer_core::import_explorer::apps_use_dark()); + take_foreground(hwnd); + ACTIVE.with(|a| *a.borrow_mut() = Some(menu)); + let picked = ACTIVE.with(|a| a.borrow().as_ref().map(|m| m.show_in(hwnd, hmenu, at, pt.x, pt.y, extended))); + let menu = ACTIVE.with(|a| a.borrow_mut().take()); + let _ = PostMessageW(Some(hwnd), WM_NULL, WPARAM(0), LPARAM(0)); + let _ = DestroyMenu(hmenu); + for b in bitmaps { + let _ = DeleteObject(b.into()); + } + let _ = match picked { + Some(Ok(Picked::Rename)) => index.map_or(Ok(()), |i| view.rename(i)), + Some(Ok(Picked::Own(REFRESH))) => view.refresh(), + Some(Ok(Picked::Own(PASTE))) => menu.map_or(Ok(()), |m| m.invoke_verb(hwnd, "paste", false)), + Some(Ok(Picked::Own(id))) if id >= SORT => SORT_KEYS.get(id - SORT).map_or(Ok(()), |(_, k)| view.sort_by(*k)), + Some(Ok(Picked::Own(id))) if id >= ARRANGE_ID => ARRANGE.get(id - ARRANGE_ID).map_or(Ok(()), |(_, f)| view.toggle_flag(*f)), + Some(Ok(Picked::Own(id))) if id >= ICON => ICON_SIZES.get(id - ICON).map_or(Ok(()), |(_, s)| view.set_icon_size(*s)), + _ => Ok(()), + }; +} + +/// Closes a running helper (before install or uninstall replaces or removes the exe). True if one was running. +pub(crate) fn stop() -> bool { + unsafe { + let Ok(h) = FindWindowW(CLASS, None) else { return false }; + let _ = PostMessageW(Some(h), WM_CLOSE, WPARAM(0), LPARAM(0)); + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn points_survive_the_lparam() { + for pt in [POINT { x: 0, y: 0 }, POINT { x: -1920, y: 1080 }, POINT { x: 2559, y: -400 }] { + let back = unpack(pack(pt)); + assert_eq!((back.x, back.y), (pt.x, pt.y)); + } + } +} diff --git a/crates/app/src/list.rs b/crates/app/src/list.rs index 644f685..f2d2616 100644 --- a/crates/app/src/list.rs +++ b/crates/app/src/list.rs @@ -262,7 +262,7 @@ pub(crate) unsafe fn show_menu(app: *mut App, on_item: bool, at: Option) own.push(Entry::sep()); } let mut bitmaps = Vec::new(); - let hmenu = ui::build_menu(app, &own, &mut bitmaps); + let hmenu = ui::build_menu(hwnd, &own, &mut bitmaps); let at = GetMenuItemCount(Some(hmenu)).max(0) as u32; let extended = GetKeyState(VK_SHIFT.0 as i32) < 0; (*app).active_menu = Some(menu); diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 4b3521f..a5d53cb 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -9,6 +9,7 @@ mod archive_view; mod breadcrumb; mod commands; mod connect; +mod desktop; mod edit; mod folder_options; mod home; @@ -257,6 +258,9 @@ fn main() -> Result<()> { if !args.elevated && rexplorer_core::elevation::is_elevated() && rexplorer_core::elevation::relaunch_unelevated().is_ok() { return Ok(()); } + if args.desktop { + return desktop::run(); + } if args.recover && !crash::record_recovery(CRASH_GUARD_SECS) { // The second crash in a row: show the folder in Windows Explorer rather than crash again. let s = session::load(); diff --git a/crates/app/src/theme.rs b/crates/app/src/theme.rs index 27d3344..a75ddeb 100644 --- a/crates/app/src/theme.rs +++ b/crates/app/src/theme.rs @@ -8,7 +8,7 @@ use windows::Win32::Graphics::Gdi::*; use windows::Win32::UI::Controls::*; use windows::Win32::UI::HiDpi::GetDpiForWindow; use windows::Win32::UI::WindowsAndMessaging::*; -use windows::core::{BOOL, HSTRING, PCWSTR, w}; +use windows::core::{BOOL, HSTRING, PCSTR, PCWSTR, w}; pub struct Brushes { pub background: HBRUSH, @@ -40,6 +40,29 @@ impl Drop for Brushes { } } +static MENUS_DARK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Dark or light popup menus for the whole process, through uxtheme's unnamed SetPreferredAppMode (ordinal 135; +/// 2 forces dark, 3 forces light) and FlushMenuThemes (ordinal 136), as Explorer and Notepad do. Windows 11 also +/// rounds their corners. Builds without the ordinals keep the classic menus. +pub fn set_menu_theme(dark: bool) { + use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW}; + MENUS_DARK.store(dark, std::sync::atomic::Ordering::Relaxed); + unsafe { + let Ok(ux) = LoadLibraryW(w!("uxtheme.dll")) else { return }; + let (Some(set), Some(flush)) = (GetProcAddress(ux, PCSTR(135 as *const u8)), GetProcAddress(ux, PCSTR(136 as *const u8))) else { return }; + let set: unsafe extern "system" fn(i32) -> i32 = std::mem::transmute(set); + let flush: unsafe extern "system" fn() = std::mem::transmute(flush); + set(if dark { 2 } else { 3 }); + flush(); + } +} + +/// What `set_menu_theme` last chose: menu glyphs are drawn in the matching text color. +pub fn menus_dark() -> bool { + MENUS_DARK.load(std::sync::atomic::Ordering::Relaxed) +} + fn dwm_set(hwnd: HWND, attr: DWMWINDOWATTRIBUTE, v: &T) { // Older builds reject some attributes; ignored on purpose. let _ = unsafe { DwmSetWindowAttribute(hwnd, attr, v as *const T as *const _, size_of::() as u32) }; diff --git a/crates/app/src/ui.rs b/crates/app/src/ui.rs index ece3812..40f72e3 100644 --- a/crates/app/src/ui.rs +++ b/crates/app/src/ui.rs @@ -209,6 +209,7 @@ unsafe fn check_images(t: &Theme, dpi: i32) -> HIMAGELIST { pub(crate) unsafe fn apply_theme(app: *mut App) { let t = &(*app).theme; + theme::set_menu_theme(t.dark); theme::apply_window((*app).hwnd, t); theme::apply_list((*app).list, t); theme::apply_control((*app).addr, t); @@ -429,7 +430,7 @@ impl Entry { /// A popup menu of `entries`, every non-zero id offset by `menu::OWN_FIRST` so the shell's own ids (below it) /// can share the menu. Glyph bitmaps are pushed to `bitmaps`: delete them after the menu closes. -pub(crate) unsafe fn build_menu(app: *mut App, entries: &[Entry], bitmaps: &mut Vec) -> HMENU { +pub(crate) unsafe fn build_menu(owner: HWND, entries: &[Entry], bitmaps: &mut Vec) -> HMENU { let Ok(menu) = CreatePopupMenu() else { return HMENU::default() }; for (pos, e) in entries.iter().enumerate() { if e.id == 0 && e.sub.is_empty() { @@ -442,11 +443,11 @@ pub(crate) unsafe fn build_menu(app: *mut App, entries: &[Entry], bitmaps: &mut rexplorer_core::menu::OWN_FIRST as usize + e.id } else { flags |= MF_POPUP; - build_menu(app, &e.sub, bitmaps).0 as usize + build_menu(owner, &e.sub, bitmaps).0 as usize }; let _ = AppendMenuW(menu, flags, id, PCWSTR(text.as_ptr())); if e.glyph != 0 { - if let Some(b) = glyph_bitmap(app, e.glyph) { + if let Some(b) = glyph_bitmap(owner, e.glyph) { bitmaps.push(b); let mii = MENUITEMINFOW { cbSize: size_of::() as u32, fMask: MIIM_BITMAP, hbmpItem: b, ..Default::default() }; let _ = SetMenuItemInfoW(menu, pos as u32, true, &mii); @@ -460,7 +461,7 @@ pub(crate) unsafe fn build_menu(app: *mut App, entries: &[Entry], bitmaps: &mut /// For pages whose background menu is all our own (Home, the future Desktop). pub(crate) unsafe fn pick_entries(app: *mut App, entries: &[Entry], x: i32, y: i32) -> Option { let mut bitmaps = Vec::new(); - let menu = build_menu(app, entries, &mut bitmaps); + let menu = build_menu((*app).hwnd, entries, &mut bitmaps); let picked = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_RIGHTBUTTON, x, y, None, (*app).hwnd, None).0 as usize; let _ = DestroyMenu(menu); for b in bitmaps { @@ -471,8 +472,8 @@ pub(crate) unsafe fn pick_entries(app: *mut App, entries: &[Entry], x: i32, y: i /// A 16 px (at 96 DPI) premultiplied ARGB bitmap of `glyph` in the menu text color, for a menu item. The glyph /// is drawn white on black and that gray becomes the alpha, since GDI text leaves a DIB's alpha untouched. -unsafe fn glyph_bitmap(app: *mut App, glyph: u16) -> Option { - let dpi = GetDpiForWindow((*app).hwnd); +unsafe fn glyph_bitmap(owner: HWND, glyph: u16) -> Option { + let dpi = GetDpiForWindow(owner); let size = (16 * dpi / 96) as i32; let (bmp, bits) = views::dib(size, size)?; let dc = CreateCompatibleDC(None); @@ -489,8 +490,8 @@ unsafe fn glyph_bitmap(app: *mut App, glyph: u16) -> Option { let _ = DeleteObject(font.into()); SelectObject(dc, old); let _ = DeleteDC(dc); - // Popup menus follow the system colors, not our theme: so does the glyph. - let color = GetSysColor(COLOR_MENUTEXT); + // The glyph takes the menu's text color: white on dark menus, the system's menu text otherwise. + let color = if theme::menus_dark() { 0x00FF_FFFF } else { GetSysColor(COLOR_MENUTEXT) }; let (cr, cg, cb) = (color & 0xFF, (color >> 8) & 0xFF, (color >> 16) & 0xFF); let px = std::slice::from_raw_parts_mut(bits, (size * size * 4) as usize); for p in px.chunks_exact_mut(4) { @@ -635,24 +636,41 @@ pub(crate) unsafe fn install_flow(owner: Option, uninstall: bool, confirm: return InstallOutcome::Cancelled; } } + // A running desktop helper holds the installed exe: close it before replacing or removing it, and start it + // again afterwards. A test base (REXPLORER_REG_BASE) leaves the real helper alone. + let real = base == install::REAL_REG_BASE; + let helper_was_running = real && crate::desktop::stop(); let r = if uninstall { install::uninstall(&dir).map_err(|e| (e, None)) } else { install::install(&dir, &base).map_err(|f| (f.error, Some(f.rollback))) }; - let (text, icon, outcome) = match r { - Ok(s) => (s, MB_ICONINFORMATION, InstallOutcome::Done), + let (report, outcome) = match r { + Ok(s) => (Some((s, MB_ICONINFORMATION)), InstallOutcome::Done), Err((e, Some(rollback))) if !rollback.is_empty() => { offer_rollback(owner, &e, rollback, &dir); - return InstallOutcome::Failed; + (None, InstallOutcome::Failed) } Err((e, _)) => { let what = if uninstall { "Uninstall" } else { "Install" }; - (format!("{what} failed:\n\n{}", e.message()), MB_ICONERROR, InstallOutcome::Failed) + (Some((format!("{what} failed: + +{}", e.message()), MB_ICONERROR)), InstallOutcome::Failed) } }; - let t = HSTRING::from(text); - MessageBoxW(owner, PCWSTR(t.as_ptr()), w!("rexplorer"), MB_OK | icon); + // After a failure (rolled back or kept), the helper that was running comes back on whichever copy is in place. + let restart = match outcome { + InstallOutcome::Done => real && !uninstall, + _ => helper_was_running, + }; + let exe = dir.join("rexplorer.exe"); + if restart && exe.is_file() { + let _ = std::process::Command::new(exe).arg("--desktop").spawn(); + } + if let Some((text, icon)) = report { + let t = HSTRING::from(text); + MessageBoxW(owner, PCWSTR(t.as_ptr()), w!("rexplorer"), MB_OK | icon); + } outcome } diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 462fa7d..78ebe0d 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -27,6 +27,7 @@ windows = { workspace = true, features = [ "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_Variant", + "Win32_UI_Accessibility", "Win32_UI_Controls", "Win32_UI_Shell", "Win32_UI_Shell_Common", diff --git a/crates/core/examples/desktop_probe.rs b/crates/core/examples/desktop_probe.rs new file mode 100644 index 0000000..bf5f7a6 --- /dev/null +++ b/crates/core/examples/desktop_probe.rs @@ -0,0 +1,63 @@ +//! Read-only check of the desktop view from outside Explorer: its items, the selection, and the item at a point. +//! Usage: cargo run --release -p rexplorer-core --example desktop_probe -- [x y] +use rexplorer_core::com::ComInit; +use rexplorer_core::desktop::DesktopView; +use rexplorer_core::item::ShellItem; +use windows::Win32::Foundation::{HWND, POINT}; + +fn main() { + if std::env::args().any(|a| a == "--aware") { + #[link(name = "user32")] + unsafe extern "system" { + fn SetProcessDpiAwarenessContext(context: isize) -> i32; + } + // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, as the desktop helper runs. + unsafe { SetProcessDpiAwarenessContext(-4) }; + } + let _c = ComInit::sta(); + let root = unsafe { windows::Win32::UI::Shell::SHGetDesktopFolder() }.expect("desktop root"); + let view = DesktopView::find().expect("desktop view"); + let name = |i: usize| view.item(i).map(|pidl| ShellItem { parent: root.clone(), pidl }.display_name()); + for i in 0.. { + match name(i) { + Ok(n) => println!("item {i}: {n}"), + Err(_) => break, + } + } + let list = find_list().expect("desktop list window"); + println!("selection: {:?}", DesktopView::selection(list)); + println!("icon size: {:?}", view.icon_size()); + for (name, f) in rexplorer_core::desktop::ARRANGE { + println!("{name}: {:?}", view.has_flag(f)); + } + if std::env::args().any(|a| a == "--refresh") { + println!("refresh: {:?}", view.refresh()); + } + if let Some(n) = std::env::args().skip_while(|a| a != "--select").nth(1).and_then(|s| s.parse::().ok()) { + println!("select_for_menu({n}): {:?}", view.select_for_menu(list, Some(n))); + println!("selection after: {:?}", DesktopView::selection(list)); + } + for i in [0usize, 5] { + let p = view.item_point(list, i).expect("item position"); + let probe = POINT { x: p.x + 20, y: p.y + 20 }; + println!("item {i} at ({},{}); hit test at ({},{}) -> {:?}", p.x, p.y, probe.x, probe.y, DesktopView::index_at(list, probe)); + } + let r = view.item_point(list, 0).unwrap(); + println!("hit test far below item 0 -> {:?}", DesktopView::index_at(list, POINT { x: r.x + 3000, y: r.y + 3000 })); +} + +/// The desktop's list: Progman's, or the WorkerW that hosts it once the wallpaper has animated. +fn find_list() -> Option { + use windows::Win32::UI::WindowsAndMessaging::{FindWindowExW, FindWindowW}; + use windows::core::w; + unsafe { + let mut host = FindWindowW(w!("Progman"), None).ok(); + while let Some(h) = host { + if let Ok(dv) = FindWindowExW(Some(h), None, w!("SHELLDLL_DefView"), None) { + return FindWindowExW(Some(dv), None, w!("SysListView32"), None).ok(); + } + host = FindWindowExW(None, Some(h), w!("WorkerW"), None).ok(); + } + None + } +} \ No newline at end of file diff --git a/crates/core/src/cli.rs b/crates/core/src/cli.rs index 3522b25..72377bd 100644 --- a/crates/core/src/cli.rs +++ b/crates/core/src/cli.rs @@ -26,6 +26,8 @@ pub struct Args { pub overwrite: bool, /// Keep an elevated token (drive-wide indexed search) instead of restarting under the desktop shell's. pub elevated: bool, + /// Run the desktop context-menu helper (install starts it at sign-in). + pub desktop: bool, /// Not ours to handle: pass the whole command line to Windows Explorer and exit. pub forward: bool, } @@ -156,6 +158,7 @@ pub fn parse>(args: I) -> std::result::Result out.import_explorer = true, "--overwrite" => out.overwrite = true, "--elevated" => out.elevated = true, + "--desktop" => out.desktop = true, "-h" | "--help" => out.help = true, _ if a.starts_with('-') => return Err(format!("unknown option: {a}")), _ if a.starts_with('/') && !explorer_switches(&a, &mut it, &mut out) => { @@ -187,6 +190,7 @@ pub fn usage() -> String { \x20 --import-explorer copy Windows Explorer's settings into the active profile (backs up settings.ini)\n\ \x20 --overwrite with --import-explorer: also replace values you changed in rexplorer\n\ \x20 --recover restore the previous session after a crash\n\ + \x20 --desktop run the helper that shows rexplorer's context menu on the desktop\n\ \x20 --elevated when started elevated, stay so (drive-wide indexed search) instead of restarting unelevated\n\ \x20 -h, --help show this help\n" .to_string() @@ -337,6 +341,7 @@ mod tests { let a = p(&["--import-explorer"]).unwrap(); assert!(a.import_explorer && !a.overwrite); assert!(p(&["--elevated"]).unwrap().elevated && !p(&[]).unwrap().elevated); + assert!(p(&["--desktop"]).unwrap().desktop && !p(&[]).unwrap().desktop); } #[test] @@ -356,7 +361,7 @@ mod tests { fn usage_mentions_every_option() { let u = usage(); assert!(u.starts_with("rexplorer [options] [folder...]\n")); - for o in ["--select", "/select,", "--profile", "--new-tab", "-t", "--register", "--unregister", "--install", "--uninstall", "--import-explorer", "--overwrite", "--recover", "--elevated", "--search", "-h", "--help"] { + for o in ["--select", "/select,", "--profile", "--new-tab", "-t", "--register", "--unregister", "--install", "--uninstall", "--import-explorer", "--overwrite", "--recover", "--elevated", "--desktop", "--search", "-h", "--help"] { assert!(u.contains(o), "{o}"); } } diff --git a/crates/core/src/desktop.rs b/crates/core/src/desktop.rs new file mode 100644 index 0000000..38fac84 --- /dev/null +++ b/crates/core/src/desktop.rs @@ -0,0 +1,159 @@ +//! Explorer's desktop icon view, driven from outside its process: which icon is at a point, the selection, and +//! selecting, and the View / Sort by settings of its background menu. The view lives in explorer.exe; +//! `IShellWindows` hands out proxies to it. + +use windows::Win32::Foundation::{HWND, POINT}; +use windows::Win32::System::Com::{CLSCTX_LOCAL_SERVER, CoCreateInstance, IServiceProvider}; +use windows::Win32::System::Variant::{VARIANT, VT_I4}; +use std::ffi::c_void; +use windows::Win32::Graphics::Gdi::ClientToScreen; +use windows::Win32::UI::Accessibility::{AccessibleObjectFromWindow, IAccessible}; +use windows::Win32::UI::Shell::{ + CSIDL_DESKTOP, IShellBrowser, IShellView, IShellWindows, SID_STopLevelBrowser, + SVSI_DESELECTOTHERS, SVSI_FOCUSED, SVSI_SELECT, SWC_DESKTOP, SWFO_NEEDDISPATCH, ShellWindows, +}; +use windows::Win32::Foundation::{LPARAM, WPARAM}; +use windows::Win32::UI::Controls::{LVM_GETNEXTITEM, LVNI_SELECTED}; +use windows::Win32::UI::WindowsAndMessaging::{GetClassNameW, GetParent, OBJID_CLIENT, SendMessageW}; +use windows::Win32::Foundation::PROPERTYKEY; +use windows::Win32::UI::Shell::{FOLDERFLAGS, FVM_ICON, FWF_AUTOARRANGE, FWF_SNAPTOGRID, IFolderView2, SORT_ASCENDING, SORTCOLUMN, SVSI_EDIT}; +use windows::core::{GUID, Interface, Result}; + +use crate::com::Pidl; + +/// The View menu's icon sizes, as Explorer offers them on the desktop. +pub const ICON_SIZES: [(&str, i32); 3] = [("Large icons", 96), ("Medium icons", 48), ("Small icons", 32)]; +/// The View menu's arrangement switches. +pub const ARRANGE: [(&str, FOLDERFLAGS); 2] = [("Auto arrange icons", FWF_AUTOARRANGE), ("Align icons to grid", FWF_SNAPTOGRID)]; + +const fn pkey(g: u128, pid: u32) -> PROPERTYKEY { + PROPERTYKEY { fmtid: GUID::from_u128(g), pid } +} +const STORAGE: u128 = 0xB725F130_47EF_101A_A5F1_02608C9EEBAC; +/// The Sort by menu: System.ItemNameDisplay, System.Size, System.ItemTypeText, System.DateModified. +pub const SORT_KEYS: [(&str, PROPERTYKEY); 4] = [ + ("Name", pkey(STORAGE, 10)), + ("Size", pkey(STORAGE, 12)), + ("Item type", pkey(0x28636AA6_953D_11D2_B5D6_00C04FD918D0, 11)), + ("Date modified", pkey(STORAGE, 14)), +]; + +pub struct DesktopView { + shell_view: IShellView, + view: IFolderView2, +} + +impl DesktopView { + /// The desktop's view in the running shell; fails when Explorer is not the shell or is restarting. + pub fn find() -> Result { + unsafe { + let windows: IShellWindows = CoCreateInstance(&ShellWindows, None, CLSCTX_LOCAL_SERVER)?; + let loc = VARIANT::from(CSIDL_DESKTOP as i32); + let mut hwnd = 0; + let disp = windows.FindWindowSW(&loc, &VARIANT::default(), SWC_DESKTOP, &mut hwnd, SWFO_NEEDDISPATCH)?; + let sp: IServiceProvider = disp.cast()?; + let browser: IShellBrowser = sp.QueryService(&SID_STopLevelBrowser)?; + let shell_view = browser.QueryActiveShellView()?; + let view = shell_view.cast()?; + Ok(DesktopView { shell_view, view }) + } + } + + /// The index of the item of desktop list `list` under screen point `pt`, or None over the background. + pub fn index_at(list: HWND, pt: POINT) -> Option { + unsafe { + let mut acc: Option = None; + AccessibleObjectFromWindow(list, OBJID_CLIENT.0 as u32, &IAccessible::IID, &mut acc as *mut _ as *mut *mut c_void).ok()?; + let child = acc?.accHitTest(pt.x, pt.y).ok()?; + // Child ids of a list view are its item indices plus one; 0 is the list itself. + let v = &child.Anonymous.Anonymous; + (v.vt == VT_I4 && v.Anonymous.lVal > 0).then(|| v.Anonymous.lVal as usize - 1) + } + } + + /// Screen rectangle's top-left corner of item `index` in desktop list `list` (for tests and menus). + pub fn item_point(&self, list: HWND, index: usize) -> Result { + unsafe { + let mut pt = self.view.GetItemPosition(self.item(index)?.as_ptr())?; + let _ = ClientToScreen(list, &mut pt); + Ok(pt) + } + } + + /// Child PIDL (relative to the desktop root) of item `index`. + pub fn item(&self, index: usize) -> Result { + unsafe { Ok(Pidl::from_raw(self.view.Item(index as i32)?)) } + } + + /// Indices of the selected items of desktop list `list`. Read from the list itself: the view's own selection + /// calls (ItemCount, GetSelectedItem) come back empty across the process boundary. + pub fn selection(list: HWND) -> Vec { + let mut out = Vec::new(); + let mut i = -1isize; + loop { + i = unsafe { SendMessageW(list, LVM_GETNEXTITEM, Some(WPARAM(i as usize)), Some(LPARAM(LVNI_SELECTED as isize))) }.0; + if i < 0 { + return out; + } + out.push(i as usize); + } + } + + /// What a right-button press does in Explorer: on an unselected icon, select just it; on a selected one, keep + /// the selection; on the background, clear it. + pub fn select_for_menu(&self, list: HWND, index: Option) -> Result<()> { + unsafe { + match index { + Some(i) if Self::selection(list).contains(&i) => Ok(()), + Some(i) => self.shell_view.SelectItem(self.item(i)?.as_ptr(), (SVSI_SELECT.0 | SVSI_DESELECTOTHERS.0 | SVSI_FOCUSED.0) as u32), + None => self.shell_view.SelectItem(std::ptr::null(), SVSI_DESELECTOTHERS.0 as u32), + } + } + } + + /// Starts Explorer's in-place rename of item `index`. + pub fn rename(&self, index: usize) -> Result<()> { + let flags = SVSI_EDIT.0 | SVSI_SELECT.0 | SVSI_DESELECTOTHERS.0 | SVSI_FOCUSED.0; + unsafe { self.shell_view.SelectItem(self.item(index)?.as_ptr(), flags as u32) } + } + + pub fn icon_size(&self) -> Result { + let (mut mode, mut size) = (Default::default(), 0); + unsafe { self.view.GetViewModeAndIconSize(&mut mode, &mut size)? }; + Ok(size) + } + + pub fn set_icon_size(&self, size: i32) -> Result<()> { + unsafe { self.view.SetViewModeAndIconSize(FVM_ICON, size) } + } + + pub fn has_flag(&self, flag: FOLDERFLAGS) -> Result { + Ok(unsafe { self.view.GetCurrentFolderFlags()? } & flag.0 as u32 != 0) + } + + pub fn toggle_flag(&self, flag: FOLDERFLAGS) -> Result<()> { + let on = self.has_flag(flag)?; + unsafe { self.view.SetCurrentFolderFlags(flag.0 as u32, if on { 0 } else { flag.0 as u32 }) } + } + + /// Arranges the icons by `key`, ascending, as the Sort by menu does. + pub fn sort_by(&self, key: PROPERTYKEY) -> Result<()> { + unsafe { self.view.SetSortColumns(&[SORTCOLUMN { propkey: key, direction: SORT_ASCENDING }]) } + } + + pub fn refresh(&self) -> Result<()> { + unsafe { self.shell_view.Refresh() } + } +} + +/// Whether `hwnd` is the desktop's icon list: a SysListView32 in a SHELLDLL_DefView hosted by Progman or WorkerW. +pub fn is_desktop_list(hwnd: HWND) -> bool { + let class = |h: HWND| { + let mut buf = [0u16; 32]; + let n = unsafe { GetClassNameW(h, &mut buf) }; + String::from_utf16_lossy(&buf[..n.max(0) as usize]) + }; + let Ok(view) = (unsafe { GetParent(hwnd) }) else { return false }; + let Ok(host) = (unsafe { GetParent(view) }) else { return false }; + class(hwnd) == "SysListView32" && class(view) == "SHELLDLL_DefView" && matches!(class(host).as_str(), "Progman" | "WorkerW") +} diff --git a/crates/core/src/folder.rs b/crates/core/src/folder.rs index 6a90828..63cb226 100644 --- a/crates/core/src/folder.rs +++ b/crates/core/src/folder.rs @@ -7,7 +7,7 @@ use windows::Win32::System::Registry::{ }; use windows::Win32::UI::Shell::Common::{ITEMIDLIST, SHCOLSTATE_ONBYDEFAULT, SHELLDETAILS}; use windows::Win32::UI::Shell::{ - BHID_SFObject, IEnumIDList, ILClone, ILCombine, ILFindLastID, ILRemoveLastID, IShellFolder, + BHID_SFObject, CSIDL_DESKTOP, IEnumIDList, SHGetFolderLocation, ILClone, ILCombine, ILFindLastID, ILRemoveLastID, IShellFolder, IShellFolder2, IShellItem, SHCONTF_FOLDERS, SHCONTF_INCLUDEHIDDEN, SHCONTF_NONFOLDERS, SHCreateItemFromIDList, SHCreateItemFromParsingName, SHGetIDListFromObject, SHParseDisplayName, SIGDN_DESKTOPABSOLUTEEDITING, SIGDN_DESKTOPABSOLUTEPARSING, @@ -49,6 +49,11 @@ impl ShellFolder { Self::from_item(item) } + /// The namespace root: the desktop as Explorer shows it (the user's and the public desktop folders merged). + pub fn desktop() -> Result { + Self::from_pidl(&unsafe { Pidl::from_raw(SHGetFolderLocation(None, CSIDL_DESKTOP as i32, None, 0)?) }) + } + pub fn from_pidl(pidl: &Pidl) -> Result { let item: IShellItem = unsafe { SHCreateItemFromIDList(pidl.as_ptr())? }; Self::from_item(item) diff --git a/crates/core/src/import_explorer.rs b/crates/core/src/import_explorer.rs index d0deeaf..a5c68e1 100644 --- a/crates/core/src/import_explorer.rs +++ b/crates/core/src/import_explorer.rs @@ -49,6 +49,11 @@ fn binary(key: &str, name: &str) -> Option> { Some(buf) } +/// Whether Windows' "app mode" is dark (Settings > Personalization > Colors), which Explorer's menus follow. +pub fn apps_use_dark() -> bool { + dword(r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", "AppsUseLightTheme") == Some(0) +} + /// Reads Explorer's values from the real HKCU (read only). pub fn read() -> Raw { Raw { diff --git a/crates/core/src/install.rs b/crates/core/src/install.rs index 8c9c997..89f4191 100644 --- a/crates/core/src/install.rs +++ b/crates/core/src/install.rs @@ -3,8 +3,9 @@ //! //! `install` copies the running exe to `\rexplorer.exe`, registers the copy (register.rs: Folder, Directory, //! Drive and File Explorer's CLSID verbs), points `App Paths\explorer.exe` at it (ShellExecute callers of -//! `explorer.exe` by bare name; CreateProcess callers still get Explorer), adds a Start menu shortcut (on the real -//! base only), and records every key, value and file it created in `\install.txt`. `uninstall` reverts what +//! `explorer.exe` by bare name; CreateProcess callers still get Explorer), adds a Start menu shortcut and a Startup +//! shortcut to `rexplorer --desktop` (the desktop's context menu; both on the real base only), and records every +//! key, value and file it created in `\install.txt`. `uninstall` reverts what //! install.txt lists and leaves other programs' values alone. Everything lives under HKCU: no elevation. //! //! `install` journals each change before making it (a value's prior data, the keys and folders it creates, a @@ -15,7 +16,9 @@ use std::path::{Path, PathBuf}; use windows::Win32::Foundation::{E_FAIL, ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND}; use windows::Win32::System::Com::{CLSCTX_INPROC_SERVER, CoCreateInstance, IPersistFile}; use windows::Win32::System::Registry::{HKEY_CURRENT_USER, RegDeleteKeyValueW}; -use windows::Win32::UI::Shell::{FOLDERID_Programs, IShellLinkW, KF_FLAG_DEFAULT, SHGetKnownFolderPath, ShellLink}; +use windows::Win32::UI::Shell::{ + FOLDERID_Programs, FOLDERID_Startup, IShellLinkW, KF_FLAG_DEFAULT, SHGetKnownFolderPath, ShellLink, +}; use windows::core::{Error, HSTRING, Interface, PCWSTR, Result, w}; use crate::com::{ComInit, take_cotaskmem_string}; @@ -65,11 +68,12 @@ pub fn plan(dir: &Path, reg_base: &str) -> String { app_paths(reg_base) ); if reg_base == REAL_REG_BASE { - s.push_str("\nAdd rexplorer to the Start menu.\n"); + s.push_str("\nAdd rexplorer to the Start menu, and show its context menu on the desktop from now on (a small helper \ + that starts at sign-in).\n"); } s.push_str( - "\nPer-user only: nothing under HKLM changes and no elevation is needed. The desktop, the taskbar and other \ - programs' Open / Save dialogs stay Explorer's. Undo with rexplorer --uninstall or More > Uninstall.", + "\nPer-user only: nothing under HKLM changes and no elevation is needed. The desktop itself, the taskbar and \ + other programs' Open / Save dialogs stay Explorer's. Undo with rexplorer --uninstall or More > Uninstall.", ); s } @@ -245,16 +249,17 @@ fn same_file(a: &Path, b: &Path) -> bool { matches!((a.canonicalize(), b.canonicalize()), (Ok(x), Ok(y)) if x == y) } -fn start_menu_programs() -> Result { - let p = unsafe { SHGetKnownFolderPath(&FOLDERID_Programs, KF_FLAG_DEFAULT, None)? }; +fn known_folder(id: &windows::core::GUID) -> Result { + let p = unsafe { SHGetKnownFolderPath(id, KF_FLAG_DEFAULT, None)? }; Ok(PathBuf::from(unsafe { take_cotaskmem_string(p) })) } -fn shortcut(target: &Path, lnk: &Path) -> Result<()> { +fn shortcut(target: &Path, args: &str, lnk: &Path) -> Result<()> { let _com = ComInit::sta(); unsafe { let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)?; link.SetPath(&HSTRING::from(target.as_os_str()))?; + link.SetArguments(&HSTRING::from(args))?; link.SetDescription(w!("rexplorer file manager"))?; link.cast::()?.Save(&HSTRING::from(lnk.as_os_str()), true) } @@ -321,10 +326,14 @@ fn apply(dir: &Path, reg_base: &str, journal: &mut Rollback) -> Result { summary.push("Programs that start explorer.exe by name get rexplorer".to_string()); // Under a test base the real Start menu is left alone. if reg_base == REAL_REG_BASE { - let lnk = start_menu_programs()?.join("rexplorer.lnk"); + let lnk = known_folder(&FOLDERID_Programs)?.join("rexplorer.lnk"); push(&mut rec, format!("file {}", lnk.display())); - journal.file(&lnk, || shortcut(&target, &lnk))?; + journal.file(&lnk, || shortcut(&target, "", &lnk))?; summary.push("Start menu shortcut added".to_string()); + let lnk = known_folder(&FOLDERID_Startup)?.join("rexplorer desktop menu.lnk"); + push(&mut rec, format!("file {}", lnk.display())); + journal.file(&lnk, || shortcut(&target, "--desktop", &lnk))?; + summary.push("The desktop shows rexplorer's context menu (started now and at every sign-in)".to_string()); } Ok(()) })(); diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index b10a072..c9704b0 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -5,6 +5,7 @@ pub mod cli; pub mod com; pub mod crash; pub mod dragdrop; +pub mod desktop; pub mod elevation; pub mod fallback; pub mod folder; diff --git a/scripts/smoke.ps1 b/scripts/smoke.ps1 index cde743b..b17210e 100644 --- a/scripts/smoke.ps1 +++ b/scripts/smoke.ps1 @@ -397,12 +397,14 @@ Run-Check 'tabs' { $strip = [W]::FindWindowEx($script:main, [IntPtr]::Zero, 'RexTabs', $null) if ($strip -eq [IntPtr]::Zero) { return @('FAIL', 'no RexTabs child window') } if (-not (Set-Foreground $script:main)) { return @('SKIP', 'could not activate') } + # Ctrl+T opens the start place (Home on a scratch profile; its name is localized: ask the shell), not a copy. + $want = (New-Object -ComObject Shell.Application).NameSpace('shell:::{F874310E-B6B7-47DC-BC84-B9E6B38F5903}').Self.Name [System.Windows.Forms.SendKeys]::SendWait('^t') Start-Sleep -Milliseconds 300 - $ok = Wait-Until { (Get-Count $script:main) -eq $c0 } 2000 + $ok = Wait-Until { (Get-Title $script:main) -eq "$want - rexplorer" -and (Get-Count $script:main) -gt 0 } 2000 $t = Get-Title $script:main; $c = Get-Count $script:main $alive = -not $script:cur.HasExited - if ($alive -and $ok -and $t -eq $orig) { @('PASS', "RexTabs present; after Ctrl+T title='$t' count=$c") } + if ($alive -and $ok) { @('PASS', "RexTabs present; after Ctrl+T title '$orig' -> '$t' (want '$want - rexplorer') count=$c") } elseif ([W]::GetForegroundWindow() -ne $script:main) { @('SKIP', 'could not activate (focus lost)') } else { @('FAIL', "alive=$alive title '$orig' -> '$t' count $c0 -> $c") } }