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