From 2327022511922940dc94920f058cccd5a80afe13 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Fri, 23 Oct 2026 22:32:41 +0200 Subject: [PATCH] The installers bring the CLI and put it on the PATH (0.11.1) The desktop app's installers now carry the `diskghost` CLI, so one install gives both and updating the app updates the CLI. - bundle.externalBin: every bundle ships the CLI built for its target. scripts/build-cli.mjs builds it (release, or debug for `tauri dev`) and puts it at binaries/diskghost-; it runs as beforeBuildCommand / beforeDevCommand, so the release workflow needs no extra step. CI builds it before the plain cargo build, since tauri-build needs the file. - Windows setup (NSIS, per user): the CLI sits next to the app and the install folder goes on the user PATH. Hooks run the app with --add-to-path after installing and --remove-from-path before uninstalling; the app edits HKCU\Environment itself (path_env.rs), keeping the value's type (REG_EXPAND_SZ entries stay unexpanded), with no length limit (NSIS strings would cut a PATH over 1024 characters short), touching only its own entry, and announces the change so new terminals see it. - MSI (all users): a WiX fragment adds the folder to the system PATH and removes it on uninstall. It belongs to Tauri's existing "PATH Environment Variable" feature, which installs the CLI, so unticking that leaves the PATH alone too. - Linux .deb / .rpm put it at /usr/bin/diskghost; in the .dmg and AppImage it is inside the bundle. README says so and how to build the GUI now. Tested on Windows with the locally built setup: over 0.11.0 it installs diskghost.exe and appends the folder once (a second run changes nothing); a program started by Explorer afterwards runs `diskghost --version` -> 0.11.1 without signing out; the app still starts; uninstalling removes both files and the folder and leaves the user PATH (2115 characters, REG_EXPAND_SZ) byte-identical to before; app data is kept. The MSI was decompiled: diskghost.exe in INSTALLDIR, the PATH entry in the feature above. 3 Rust tests for the PATH editing (CI now runs the GUI's tests). --- .github/workflows/ci.yml | 11 +- .github/workflows/release.yml | 6 +- .gitignore | 2 + Cargo.lock | 4 +- Cargo.toml | 2 +- README.md | 17 ++ gui/src-tauri/Cargo.lock | 4 +- gui/src-tauri/Cargo.toml | 2 +- gui/src-tauri/scripts/build-cli.mjs | 32 +++ gui/src-tauri/src/main.rs | 12 ++ gui/src-tauri/src/path_env.rs | 316 ++++++++++++++++++++++++++++ gui/src-tauri/tauri.conf.json | 16 +- gui/src-tauri/windows/cli-path.wxs | 23 ++ gui/src-tauri/windows/hooks.nsh | 11 + 14 files changed, 446 insertions(+), 12 deletions(-) create mode 100644 gui/src-tauri/scripts/build-cli.mjs create mode 100644 gui/src-tauri/src/path_env.rs create mode 100644 gui/src-tauri/windows/cli-path.wxs create mode 100644 gui/src-tauri/windows/hooks.nsh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 650fd36..aac10e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,12 +59,19 @@ jobs: libgtk-3-dev - uses: Swatinem/rust-cache@v2 with: - workspaces: "gui/src-tauri -> target" - - name: Clippy + build GUI + workspaces: | + gui/src-tauri -> target + . -> target + # The setup ships the CLI next to the app (bundle.externalBin); tauri-build + # wants that file before it builds anything. + - name: Build the CLI sidecar + run: node gui/src-tauri/scripts/build-cli.mjs + - name: Clippy + build + test GUI working-directory: gui/src-tauri run: | cargo clippy --all-targets -- -D warnings cargo build --release + cargo test --release # The UI's pure helpers (formatting, breadcrumbs, treemap layout, diff # indexing) are plain JS with Node unit tests; no npm install needed. - name: UI unit tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 020480f..b3220f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -112,11 +112,15 @@ jobs: libgtk-3-dev - uses: Swatinem/rust-cache@v2 with: - workspaces: "gui/src-tauri -> target" + workspaces: | + gui/src-tauri -> target + . -> target - name: Install tauri-cli run: cargo install tauri-cli --version "^2.0" --locked + # beforeBuildCommand builds the CLI for the same target first; every + # bundle carries it (bundle.externalBin). - name: Build GUI bundles working-directory: gui/src-tauri run: cargo tauri build ${{ matrix.target != '' && format('--target {0}', matrix.target) || '' }} diff --git a/.gitignore b/.gitignore index 1a435cf..de02636 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ node_modules/ dist/ src-tauri/target/ +# The CLI sidecar, built by gui/src-tauri/scripts/build-cli.mjs +gui/src-tauri/binaries/ # OS / editor cruft .DS_Store diff --git a/Cargo.lock b/Cargo.lock index 1215e19..0798c16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -330,7 +330,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "diskghost-cli" -version = "0.11.0" +version = "0.11.1" dependencies = [ "clap", "diskghost-core", @@ -340,7 +340,7 @@ dependencies = [ [[package]] name = "diskghost-core" -version = "0.11.0" +version = "0.11.1" dependencies = [ "blake3", "criterion", diff --git a/Cargo.toml b/Cargo.toml index f50363d..10197b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["gui"] resolver = "2" [workspace.package] -version = "0.11.0" +version = "0.11.1" edition = "2021" license = "MIT" authors = ["kingchenc"] diff --git a/README.md b/README.md index 591ab14..0bab546 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,19 @@ Find where your space went, hunt down byte-identical duplicates, and reclaim the ## Install +The desktop app's installers bring the `diskghost` CLI along, so one install +gives you both, and updating the app updates the CLI: + +- **Windows setup (`*-setup.exe`)**: the CLI sits next to the app and its + folder goes on your user PATH (removed again on uninstall). Open a new + terminal and `diskghost` works. +- **Windows MSI**: the same, installed for all users, on the system PATH. +- **Linux `.deb` / `.rpm`**: `/usr/bin/diskghost`. +- **macOS `.dmg`, AppImage**: inside the app bundle, not on the PATH; use one of + the ways below. + +The CLI on its own: + ```bash # Install the `diskghost` command globally, straight from GitHub (no crates.io): cargo install --git https://github.com/kingchenc/diskghost diskghost-cli @@ -187,6 +200,10 @@ cargo tauri dev # run the app cargo tauri build # bundle an installer ``` +Both first build the CLI that the installers carry (`scripts/build-cli.mjs`, +needs Node). A plain `cargo build` / `cargo clippy` here needs that file too: +run `node scripts/build-cli.mjs` once before. + ## Why it's fast — and correct - Parallel directory walk on a thread pool per scan; a **single pass** counts diff --git a/gui/src-tauri/Cargo.lock b/gui/src-tauri/Cargo.lock index 56fd74b..fdd31b0 100644 --- a/gui/src-tauri/Cargo.lock +++ b/gui/src-tauri/Cargo.lock @@ -645,7 +645,7 @@ dependencies = [ [[package]] name = "diskghost-core" -version = "0.11.0" +version = "0.11.1" dependencies = [ "blake3", "fs4", @@ -659,7 +659,7 @@ dependencies = [ [[package]] name = "diskghost-gui" -version = "0.11.0" +version = "0.11.1" dependencies = [ "diskghost-core", "serde", diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index ef3fe0c..d3457b5 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -4,7 +4,7 @@ [package] name = "diskghost-gui" -version = "0.11.0" +version = "0.11.1" edition = "2021" license = "MIT" description = "Diskghost desktop GUI (Tauri) — modern disk usage & duplicate finder." diff --git a/gui/src-tauri/scripts/build-cli.mjs b/gui/src-tauri/scripts/build-cli.mjs new file mode 100644 index 0000000..3382469 --- /dev/null +++ b/gui/src-tauri/scripts/build-cli.mjs @@ -0,0 +1,32 @@ +// Build the `diskghost` CLI and put it where Tauri looks for the sidecar +// (`bundle.externalBin`): binaries/diskghost-[.exe]. Runs as +// beforeBuildCommand / beforeDevCommand; run it by hand before a plain +// `cargo build` here (tauri-build refuses to build without the file). +import { execFileSync } from "node:child_process"; +import { copyFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const tauriDir = join(dirname(fileURLToPath(import.meta.url)), ".."); +const root = join(tauriDir, "..", ".."); + +// Tauri passes the target it builds for; otherwise build for this machine. +const triple = + process.env.TAURI_ENV_TARGET_TRIPLE || + /^host: (\S+)$/m.exec(execFileSync("rustc", ["-vV"], { encoding: "utf8" }))[1]; +const debug = process.env.TAURI_ENV_DEBUG === "true"; +const profile = debug ? "debug" : "release"; + +const args = ["build", "-p", "diskghost-cli", "--target", triple]; +if (!debug) args.push("--release"); +execFileSync("cargo", args, { cwd: root, stdio: "inherit" }); + +const ext = triple.includes("windows") ? ".exe" : ""; +const targetDir = process.env.CARGO_TARGET_DIR || join(root, "target"); +const outDir = join(tauriDir, "binaries"); +mkdirSync(outDir, { recursive: true }); +copyFileSync( + join(targetDir, triple, profile, `diskghost${ext}`), + join(outDir, `diskghost-${triple}${ext}`), +); +console.log(`CLI sidecar: binaries/diskghost-${triple}${ext} (${profile})`); diff --git a/gui/src-tauri/src/main.rs b/gui/src-tauri/src/main.rs index 1656bd9..e7bcdd6 100644 --- a/gui/src-tauri/src/main.rs +++ b/gui/src-tauri/src/main.rs @@ -16,6 +16,7 @@ use tauri::{Emitter, LogicalSize, Manager}; use tauri_plugin_dialog::DialogExt; mod helper; +mod path_env; /// Sub-folders shown inside each folder on the map's second level. const NESTED_PER_FOLDER: usize = 12; @@ -949,6 +950,17 @@ fn main() { helper::serve(Path::new(&args[2]), &args[3]); return; } + // Run by the Windows setup: the CLI next to the app goes on/off the PATH. + if args.len() == 2 && (args[1] == "--add-to-path" || args[1] == "--remove-from-path") { + let code = match path_env::update(args[1] == "--add-to-path") { + Ok(()) => 0, + Err(e) => { + eprintln!("error: {e}"); + 1 + } + }; + std::process::exit(code); + } tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .setup(|app| { diff --git a/gui/src-tauri/src/path_env.rs b/gui/src-tauri/src/path_env.rs new file mode 100644 index 0000000..2b735b5 --- /dev/null +++ b/gui/src-tauri/src/path_env.rs @@ -0,0 +1,316 @@ +//! Put the install folder, which holds the `diskghost` CLI next to the app, on +//! the user's PATH. +//! +//! The Windows setup runs the app once with `--add-to-path` after installing +//! and with `--remove-from-path` before uninstalling. The entry is written +//! straight to `HKCU\Environment` with the value's own type kept +//! (`REG_EXPAND_SZ` entries like `%USERPROFILE%\bin` stay unexpanded) and no +//! length limit; only the entry for this folder is ever added or removed. + +/// True if a PATH entry names `dir` (case-insensitive, quotes and trailing +/// slashes ignored). +#[cfg(any(windows, test))] +fn same_dir(entry: &str, dir: &str) -> bool { + let norm = |s: &str| { + s.trim() + .trim_matches('"') + .trim_end_matches(['\\', '/']) + .to_lowercase() + }; + let entry = norm(entry); + !entry.is_empty() && entry == norm(dir) +} + +/// `path` with `dir` appended, or `None` when it already lists `dir`. +#[cfg(any(windows, test))] +fn with_dir(path: &str, dir: &str) -> Option { + if path.split(';').any(|e| same_dir(e, dir)) { + return None; + } + let rest = path.trim_end_matches(';'); + Some(if rest.is_empty() { + dir.to_string() + } else { + format!("{rest};{dir}") + }) +} + +/// `path` without the entries naming `dir`, or `None` when it lists none. +#[cfg(any(windows, test))] +fn without_dir(path: &str, dir: &str) -> Option { + if !path.split(';').any(|e| same_dir(e, dir)) { + return None; + } + Some( + path.split(';') + .filter(|e| !same_dir(e, dir)) + .collect::>() + .join(";"), + ) +} + +/// Add (`add`) or remove the folder of this executable to/from the user's +/// PATH. Adding is skipped when the CLI is not next to the app (a dev build). +#[cfg(windows)] +pub fn update(add: bool) -> Result<(), String> { + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let dir = exe.parent().ok_or("the app has no folder")?; + if add && !dir.join("diskghost.exe").is_file() { + return Ok(()); + } + let dir = dir + .to_str() + .ok_or("the app's folder is not valid Unicode")?; + let key = reg::Key::environment()?; + let (old, kind) = key.path()?; + let new = if add { + with_dir(&old, dir) + } else { + without_dir(&old, dir) + }; + let Some(new) = new else { + return Ok(()); // already as wanted + }; + if new.trim_matches(';').is_empty() { + key.delete_path()?; + } else { + key.set_path(&new, kind)?; + } + reg::announce(); + Ok(()) +} + +#[cfg(not(windows))] +pub fn update(_add: bool) -> Result<(), String> { + Err("only the Windows setup puts the CLI on the PATH".into()) +} + +/// The few registry calls this needs (advapi32 / user32). +#[cfg(windows)] +mod reg { + use std::ffi::c_void; + + type Hkey = *mut c_void; + + // Sign-extended like the SDK's `(HKEY)(ULONG_PTR)(LONG)0x80000001`. + const HKEY_CURRENT_USER: Hkey = 0x8000_0001_u32 as i32 as isize as Hkey; + const KEY_QUERY_VALUE: u32 = 0x0001; + const KEY_SET_VALUE: u32 = 0x0002; + const REG_SZ: u32 = 1; + const REG_EXPAND_SZ: u32 = 2; + const ERROR_SUCCESS: i32 = 0; + const ERROR_FILE_NOT_FOUND: i32 = 2; + const ERROR_MORE_DATA: i32 = 234; + const HWND_BROADCAST: *mut c_void = 0xffff as *mut c_void; + const WM_SETTINGCHANGE: u32 = 0x001A; + const SMTO_ABORTIFHUNG: u32 = 0x0002; + + #[link(name = "advapi32")] + extern "system" { + fn RegOpenKeyExW(key: Hkey, sub: *const u16, opts: u32, sam: u32, out: *mut Hkey) -> i32; + fn RegQueryValueExW( + key: Hkey, + name: *const u16, + reserved: *mut u32, + kind: *mut u32, + data: *mut u8, + len: *mut u32, + ) -> i32; + fn RegSetValueExW( + key: Hkey, + name: *const u16, + reserved: u32, + kind: u32, + data: *const u8, + len: u32, + ) -> i32; + fn RegDeleteValueW(key: Hkey, name: *const u16) -> i32; + fn RegCloseKey(key: Hkey) -> i32; + } + + #[link(name = "user32")] + extern "system" { + fn SendMessageTimeoutW( + hwnd: *mut c_void, + msg: u32, + wparam: usize, + lparam: isize, + flags: u32, + timeout: u32, + result: *mut usize, + ) -> isize; + } + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() + } + + fn check(code: i32, what: &str) -> Result<(), String> { + if code == ERROR_SUCCESS { + Ok(()) + } else { + Err(format!( + "{what}: {}", + std::io::Error::from_raw_os_error(code) + )) + } + } + + /// `HKCU\Environment`, open for reading and writing. + pub struct Key(Hkey); + + impl Drop for Key { + fn drop(&mut self) { + unsafe { RegCloseKey(self.0) }; + } + } + + impl Key { + pub fn environment() -> Result { + let mut key: Hkey = std::ptr::null_mut(); + let sub = wide("Environment"); + let code = unsafe { + RegOpenKeyExW( + HKEY_CURRENT_USER, + sub.as_ptr(), + 0, + KEY_QUERY_VALUE | KEY_SET_VALUE, + &mut key, + ) + }; + check(code, "cannot open HKCU\\Environment")?; + Ok(Key(key)) + } + + /// The raw (unexpanded) `Path` value and its type; empty and + /// `REG_EXPAND_SZ` when there is none. + pub fn path(&self) -> Result<(String, u32), String> { + let name = wide("Path"); + let mut buf: Vec = vec![0; 1024]; + loop { + let mut kind = 0u32; + let mut len = (buf.len() * 2) as u32; + let code = unsafe { + RegQueryValueExW( + self.0, + name.as_ptr(), + std::ptr::null_mut(), + &mut kind, + buf.as_mut_ptr().cast(), + &mut len, + ) + }; + match code { + ERROR_FILE_NOT_FOUND => return Ok((String::new(), REG_EXPAND_SZ)), + ERROR_MORE_DATA => buf.resize((len as usize).div_ceil(2) + 1, 0), + _ => { + check(code, "cannot read the user PATH")?; + if kind != REG_SZ && kind != REG_EXPAND_SZ { + return Err(format!("the user PATH has registry type {kind}")); + } + buf.truncate((len as usize) / 2); + while buf.last() == Some(&0) { + buf.pop(); + } + return Ok((String::from_utf16_lossy(&buf), kind)); + } + } + } + } + + pub fn set_path(&self, value: &str, kind: u32) -> Result<(), String> { + let name = wide("Path"); + let data = wide(value); + let code = unsafe { + RegSetValueExW( + self.0, + name.as_ptr(), + 0, + kind, + data.as_ptr().cast(), + (data.len() * 2) as u32, + ) + }; + check(code, "cannot write the user PATH") + } + + pub fn delete_path(&self) -> Result<(), String> { + let name = wide("Path"); + let code = unsafe { RegDeleteValueW(self.0, name.as_ptr()) }; + if code == ERROR_FILE_NOT_FOUND { + return Ok(()); + } + check(code, "cannot remove the user PATH") + } + } + + /// Tell running programs (Explorer, so new terminals) that the + /// environment changed. + pub fn announce() { + let what = wide("Environment"); + let mut result = 0usize; + unsafe { + SendMessageTimeoutW( + HWND_BROADCAST, + WM_SETTINGCHANGE, + 0, + what.as_ptr() as isize, + SMTO_ABORTIFHUNG, + 5000, + &mut result, + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DIR: &str = r"C:\Users\me\AppData\Local\Diskghost"; + + #[test] + fn adds_once() { + assert_eq!(with_dir("", DIR).as_deref(), Some(DIR)); + assert_eq!( + with_dir(r"%USERPROFILE%\bin;C:\tools", DIR).unwrap(), + format!(r"%USERPROFILE%\bin;C:\tools;{DIR}") + ); + // A trailing separator does not leave an empty entry behind. + assert_eq!( + with_dir(r"C:\tools;", DIR).unwrap(), + format!(r"C:\tools;{DIR}") + ); + // Already listed, in any spelling: nothing to do. + let listed = format!(r"C:\tools;{DIR}"); + assert_eq!(with_dir(&listed, DIR), None); + assert_eq!(with_dir(r"c:\users\ME\appdata\local\diskghost\", DIR), None); + assert_eq!(with_dir(&format!("\"{DIR}\""), DIR), None); + } + + #[test] + fn removes_only_this_folder() { + assert_eq!(without_dir(r"C:\tools", DIR), None); + assert_eq!(without_dir("", DIR), None); + assert_eq!( + without_dir(&format!(r"%USERPROFILE%\bin;{DIR};C:\tools"), DIR).unwrap(), + r"%USERPROFILE%\bin;C:\tools" + ); + // Every copy goes, in any spelling; other entries stay as they were. + assert_eq!( + without_dir(&format!(r"{DIR};C:\a;;{}\", DIR.to_uppercase()), DIR).unwrap(), + r"C:\a;" + ); + assert_eq!(without_dir(DIR, DIR).unwrap(), ""); + // A folder that merely starts the same is someone else's. + assert_eq!(without_dir(&format!(r"{DIR}2"), DIR), None); + } + + #[test] + fn add_then_remove_restores() { + for path in ["", r"C:\a", r"C:\a;%X%\b"] { + let added = with_dir(path, DIR).unwrap(); + assert_eq!(without_dir(&added, DIR).unwrap(), path); + } + } +} diff --git a/gui/src-tauri/tauri.conf.json b/gui/src-tauri/tauri.conf.json index 3d0be86..c7d02f9 100644 --- a/gui/src-tauri/tauri.conf.json +++ b/gui/src-tauri/tauri.conf.json @@ -1,10 +1,12 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Diskghost", - "version": "0.11.0", + "version": "0.11.1", "identifier": "org.diskghost.app", "build": { - "frontendDist": "../ui" + "frontendDist": "../ui", + "beforeDevCommand": { "script": "node scripts/build-cli.mjs", "cwd": ".", "wait": true }, + "beforeBuildCommand": { "script": "node scripts/build-cli.mjs", "cwd": "." } }, "app": { "withGlobalTauri": true, @@ -33,6 +35,14 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "externalBin": ["binaries/diskghost"], + "windows": { + "nsis": { "installerHooks": "windows/hooks.nsh" }, + "wix": { + "fragmentPaths": ["windows/cli-path.wxs"], + "featureGroupRefs": ["CliOnPathLink"] + } + } } } diff --git a/gui/src-tauri/windows/cli-path.wxs b/gui/src-tauri/windows/cli-path.wxs new file mode 100644 index 0000000..19def4e --- /dev/null +++ b/gui/src-tauri/windows/cli-path.wxs @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/gui/src-tauri/windows/hooks.nsh b/gui/src-tauri/windows/hooks.nsh new file mode 100644 index 0000000..140808b --- /dev/null +++ b/gui/src-tauri/windows/hooks.nsh @@ -0,0 +1,11 @@ +; The setup installs the `diskghost` CLI next to the app (bundle.externalBin) +; and puts that folder on the user's PATH. The app edits HKCU\Environment +; itself (src/path_env.rs): NSIS strings would cut a long PATH short. + +!macro NSIS_HOOK_POSTINSTALL + ExecWait '"$INSTDIR\${MAINBINARYNAME}.exe" --add-to-path' +!macroend + +!macro NSIS_HOOK_PREUNINSTALL + ExecWait '"$INSTDIR\${MAINBINARYNAME}.exe" --remove-from-path' +!macroend