DEV Community

Cover image for Your Mac Has 80GB of Hidden Junk. Here's How to Find It.
Dmitry
Dmitry

Posted on

Your Mac Has 80GB of Hidden Junk. Here's How to Find It.

After years of using CleanMyMac and watching it beg me for a paid upgrade every time I clicked something useful, I decided to build my own. Meet Trashly — a macOS disk cleaner written in Rust with a React frontend, open-source (AGPL-3), and with zero upsells.

This post is about why I built it, the interesting technical choices I made, and the one thing most cleaners get completely wrong.


The Problem: Cleaners Don't Know About Developers

Mainstream Mac cleaners do a decent job on browser caches and basic junk. But if you're a developer? They leave gigabytes on the table.

On my MacBook after about a year of development, I had:

  • ~40 GB of Xcode DerivedData
  • ~15 GB of iOS DeviceSupport (archived device frameworks for debugging)
  • ~20 GB across node_modules folders in abandoned projects
  • ~8 GB of Android emulator images
  • ~6 GB of JetBrains caches
  • Docker volumes I forgot existed

Generic cleaners found maybe 2 GB of browser cache and called it a day.


The Stack

I wanted something that felt native and was actually fast. The choice was easy:

  • Rust for the backend — memory safety, real parallelism, direct OS access
  • Tauri 2 as the bridge — gives you a native window with WebView, IPC, tray icon, and auto-updates with no Electron overhead (~8 MB app bundle vs ~200 MB)
  • React 19 + TypeScript for the UI — familiar tooling, great ecosystem, and Tauri's WebView means I don't have to learn SwiftUI

The Interesting Parts

Safety-First Deletion

The thing that terrifies me about disk cleaners is "what if it deletes the wrong thing." So the first thing I built was a strict allowlist guard in Rust:

fn is_safe_path(path: &Path) -> bool {
    let allowed_roots = [
        home_dir().join("Library/Caches"),
        home_dir().join("Library/Logs"),
        home_dir().join(".Trash"),
        // developer paths...
    ];

    allowed_roots.iter().any(|root| {
        path.starts_with(root) && path != root.as_path()
    })
}
Enter fullscreen mode Exit fullscreen mode

The key detail: path != root — you can't delete the root itself, only things inside it. And .. escapes are rejected before this check even runs. This guard lives in Rust, not JavaScript. Even if a malicious path somehow came from the UI layer, the backend would refuse it.

By default, everything goes to the Trash via NSFileManager — permanent deletion is opt-in.


Streaming Scan Results

Nobody wants to stare at a spinner for 30 seconds before seeing results. The scan fans out across all categories in parallel using rayon, and results stream to the UI as they're found:

pub async fn scan_caches() -> impl Stream<Item = ScanResult> {
    let (tx, rx) = tokio::sync::mpsc::channel(256);

    tokio::spawn(async move {
        rayon::scope(|s| {
            for scanner in SCANNERS.iter() {
                let tx = tx.clone();
                s.spawn(move |_| {
                    if let Ok(results) = scanner.scan() {
                        for r in results {
                            let _ = tx.blocking_send(r);
                        }
                    }
                });
            }
        });
    });

    ReceiverStream::new(rx)
}
Enter fullscreen mode Exit fullscreen mode

The UI renders file entries the moment they arrive. A 40 GB DerivedData scan starts showing results within 200ms.


Finding Dead node_modules

This is where Trashly gets opinionated. Rather than scanning for node_modules anywhere on the disk, it looks for project manifests first:

fn find_project_artifacts(root: &Path) -> Vec<ArtifactResult> {
    WalkDir::new(root)
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|e| is_manifest(e.path())) // package.json, Cargo.toml, etc.
        .flat_map(|manifest| {
            let project_dir = manifest.path().parent().unwrap();
            ARTIFACT_DIRS.iter()
                .map(|name| project_dir.join(name))
                .filter(|p| p.exists())
                .map(|p| ArtifactResult { path: p, .. })
                .collect::<Vec<_>>()
        })
        .collect()
}
Enter fullscreen mode Exit fullscreen mode

It finds node_modules, target, dist, .next — but only when they sit next to a real package.json, Cargo.toml, build.gradle, etc. No false positives from unrelated folders with the same name.


Perceptual Photo Deduplication

Exact duplicate detection (BLAKE3 hash matching) is table stakes. The interesting problem is similar photos — the same screenshot at two export sizes, the same photo edited in Lightroom, burst shots that are 95% identical.

I implemented dHash clustering:

  1. Decode image to 9×8 grayscale (via the image crate; HEIC files go through system sips)
  2. For each row, compute whether each pixel is brighter than its right neighbor → 64-bit hash
  3. Group images where Hamming distance < threshold into clusters
  4. Within each cluster, keep the largest file (assumed best quality) and offer the rest for deletion
fn dhash(img: &DynamicImage) -> u64 {
    let small = img.resize_exact(9, 8, FilterType::Lanczos3)
                   .grayscale();
    let pixels = small.to_luma8();
    let mut hash = 0u64;
    for y in 0..8 {
        for x in 0..8 {
            if pixels[(x, y)].0[0] > pixels[(x + 1, y)].0[0] {
                hash |= 1 << (y * 8 + x);
            }
        }
    }
    hash
}
Enter fullscreen mode Exit fullscreen mode

On a photo library of 12,000 images, this runs in about 8 seconds on an M1.


System Metrics That Actually Work on Recent macOS

The sysinfo crate — which most Rust system monitor tools reach for — reports zero memory pressure on macOS 14+. The kernel API it uses was deprecated.

I ended up parsing top -l 1 -n 0 and netstat -i -b directly:

fn memory_pressure() -> Option<MemPressure> {
    let output = Command::new("memory_pressure").output().ok()?;
    // parses "System-wide memory free percentage: 42%"
    // and "The system memory pressure is: CRITICAL"
    parse_memory_pressure_output(&output.stdout)
}
Enter fullscreen mode Exit fullscreen mode

Ugly? A little. But it returns the same values macOS shows in Activity Monitor, which is what users expect.


The App Uninstaller

This is the feature I'm most proud of. When you drag an app to Trash on macOS, you leave behind:

  • ~/Library/Application Support/<AppName>
  • ~/Library/Caches/com.example.app
  • ~/Library/Containers/com.example.app
  • ~/Library/Preferences/com.example.app.plist
  • Launch Agents
  • Browser extensions and profiles
  • Crash reports

Trashly hunts all of these down by bundle ID and developer name, lets you preview them, and optionally keep the app binary while wiping just its data (great for resetting an app to factory state).

For developer tools (Xcode, Android Studio, JetBrains, Docker), there are dedicated cleanup routines that know exactly where each tool stores its caches and how much space each component takes.


What I Learned

Tauri is genuinely great. The IPC ergonomics are excellent — you define a Rust function with #[tauri::command], and the TypeScript side gets a typed async function. Hot reload works. The app bundle is tiny.

Rust's safety guarantees matter for this use case. There are code paths in Trashly that walk the entire home directory and make deletion decisions. Having the compiler verify that I'm not accidentally aliasing mutable state across threads, and that every error is handled, is worth the learning curve.

Most disk usage surprises people. The feedback I consistently get is: "I had no idea DerivedData was this big." Xcode will accumulate build artifacts for every simulator OS version and every version of your app it's ever compiled. 50 GB is not unusual for active iOS developers.


Where to Find It

Trashly is open-source under AGPL-3 on GitHub. No telemetry, no accounts, no upsells. The cleanup history is a local-only audit log so you can always see what was removed and when.

If you're macOS user and you're regularly running out of disk space, give it a try. The first scan usually finds 30–80 GB on a machine that's been used for a year or more.

GitHub: github.com/AppsGanin/trashly


Built with Rust, Tauri 2, React 19. Feedback welcome — especially if you know a cache directory I haven't found yet.

Top comments (0)