Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions crates/core-services/src/app_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,16 @@ impl Service for AppCacheService {
Self::collect(&h.join("Library/Application Support"), 4, &mut items);
}

// 6. Windows: Chromium/Electron app caches under %LOCALAPPDATA% and
// %APPDATA% (e.g. Chrome/Edge/Electron Cache, Code Cache, GPUCache).
// %LOCALAPPDATA% needs depth 5: Windows browsers nest the cache one
// level deeper than macOS — Google\Chrome\User Data\Default\Cache.
#[cfg(target_os = "windows")]
{
Self::collect(&h.join("AppData/Local"), 5, &mut items);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize Windows paths before overlap de-duping

On Windows, the cache paths emitted from this new root serialize with \ separators, while ui/src/lib/reclaim.ts only recognizes ancestry using / boundaries. Since BigFilesService still walks the same home tree and does not prune Cache directories, a browser cache directory returned here and a large descendant file returned by Big Files are no longer considered related, so the dashboard can double-count reclaimable bytes and build plans that try to delete both parent and child. Normalize separators in the overlap logic before adding Windows cache roots.

Useful? React with 👍 / 👎.

Self::collect(&h.join("AppData/Roaming"), 4, &mut items);
Comment on lines +140 to +141

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Read AppData roots from the environment

In redirected-profile or domain Windows setups, %LOCALAPPDATA% and especially %APPDATA% are not guaranteed to be USERPROFILE\AppData\Local/Roaming; they can point to another local path or a network share. Deriving both roots from home means the new Windows app-cache scan silently misses the real browser/Electron caches in those environments, so prefer LOCALAPPDATA/APPDATA with these paths only as fallbacks.

Useful? React with 👍 / 👎.

}

let total_bytes = items.iter().map(|item| item.size_bytes).sum();
ScanResult {
service: ServiceId::AppCache,
Expand Down
15 changes: 15 additions & 0 deletions crates/core-services/src/temp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ impl TempService {
path: home.join("Library/Caches"),
requires_root: false,
});
// Windows: %LOCALAPPDATA%\Temp (user) and %WINDIR%\Temp (privileged).
#[cfg(target_os = "windows")]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate Unix temp roots out on Windows

When this Windows arm runs, it appends to the shared root list that still contains /tmp and /var/tmp; on Windows those are drive-rooted paths such as C:\tmp, not guaranteed-missing POSIX paths. On machines with MSYS/Cygwin/dev temp directories there, the scan will surface unrelated entries as admin-only temp files, and selecting them routes through the non-macOS pkexec path instead of the Recycle Bin cleanup, so cleanup fails for those results. Build the temp roots per OS, or skip the POSIX roots under cfg(windows).

Useful? React with 👍 / 👎.

{
roots.push(TempRoot {
path: home.join("AppData/Local/Temp"),
requires_root: false,
Comment on lines +52 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the age cutoff to Windows temp directories

Adding %LOCALAPPDATA%\Temp as a non-root root means TempService::scan() will offer every immediate subdirectory for deletion, but the existing age filter only runs in the meta.is_file() branch. On Windows those subdirectories are often active installer/app temp workspaces, so a user selecting the new temp entries can trash fresh in-use data despite temp_min_age_days = 7; filter directory mtimes too before pushing them.

Useful? React with 👍 / 👎.

Comment on lines +52 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use TEMP/TMP for the user temp root

Windows' user temp directory is controlled by %TEMP%/%TMP%, and users or managed environments can move it away from %LOCALAPPDATA%\Temp. With this hard-coded root, those installs scan and clean the wrong location while leaving the actual temp directory untouched, so resolve the user temp root from TEMP/TMP first and fall back to this default only when they are absent.

Useful? React with 👍 / 👎.

});
let windir = std::env::var_os("WINDIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from("C:\\Windows"));
roots.push(TempRoot {
path: windir.join("Temp"),
requires_root: true,
Comment on lines +60 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid routing Windows Temp cleanup through pkexec

When the Windows temp scan surfaces %WINDIR%\Temp, marking its children requires_root sends selected items through execute::pkexec_helper; on Windows this code path is still under the non-macOS arm and tries to spawn pkexec, so selecting these entries cannot clean them and only returns a spawn failure. Gate this root out until Windows elevation is implemented, or add the Windows elevation path before marking it root-required.

Useful? React with 👍 / 👎.

});
}
Self {
roots,
min_age_days,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,320 @@
# Windows Port — Phase 1: Paths, Scan & Clean — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.

> **Plan series.** Phase **1 of 8**. Phase 0 (compile gate) is merged to `master`. Design spec:
> `docs/superpowers/specs/2026-06-26-windows-port-design.md`. Branch for this phase: `feat/win-phase-1`.

**Goal:** On Windows, scans target the real user home, find Windows app/temp caches, and the dashboard shows a real system footprint — so non-privileged scan + cleanup (Recycle Bin) is fully functional.

**Architecture:** Phase 0 left the Windows `not(macos)`→Linux code compiling but runtime-wrong for paths. Phase 1 fixes the runtime: adopt the `dirs` crate so `Config::detect` resolves `%USERPROFILE%` on Windows (the single foundational fix — every scan reads `Config.home`); replace the `du`-based `system_total` with an internal scan-engine sum on Windows; and add Windows cache/temp roots to the `app_cache` and `temp` services. `core-trash` already uses the Recycle Bin, so deletion needs no change.

**Tech Stack:** Rust (Tauri 2 workspace), `dirs` crate, `core-scan` (jwalk walker), `core-trash`.

## Global Constraints

- **Target:** Windows 10 (1803+) and 11, x64. Do NOT regress Linux or macOS.
- **License:** keep `// SPDX-License-Identifier: GPL-3.0-or-later` on every edited file.
- **cfg rule:** when you touch a `#[cfg(not(target_os = "macos"))]` site, split it into explicit `#[cfg(target_os = "linux")]` + a `#[cfg(target_os = "windows")]` arm. Never leave `not(macos)` meaning "Linux" in code you modify.
- **`dirs` placement:** add `dirs` only to `src-tauri/Cargo.toml`. The `core-services` crate stays dependency-free for paths — its Windows arms derive locations from the passed-in `home` (`home.join("AppData/Local")` = `%LOCALAPPDATA%`, `home.join("AppData/Roaming")` = `%APPDATA%`).
- **Behavior parity:** on Linux/macOS `dirs::home_dir()` resolves the same `$HOME` as before — confirm no path change.
- **Verification:** MANDATORY local gate per task = `cargo fmt --all --check` + `cargo clippy -p freeyourdisk --all-targets -- -D warnings` GREEN, and `cargo test -p <touched crate>` GREEN for crates with tests. Windows compilation is authoritatively gated by the existing `windows` CI job on the PR (local `x86_64-pc-windows-gnu` cross-check is best-effort; MinGW is absent → C-dep failure is expected, not our Rust).
- **Windows-arm tests** are `#[cfg(target_os="windows")]` and won't run in CI this phase (no Windows `cargo test` job yet) — keep cross-platform logic covered by existing OS-agnostic tests; add Windows tests only where they document intent.

---

### Task 1: Adopt `dirs`; resolve the user home cross-platform

`Config::detect` (the root every scan reads) hardcodes `$HOME` → on Windows `$HOME` is usually unset → home becomes `/`, so every scan targets the wrong place. Fix it with `dirs::home_dir()`.

**Files:**
- Modify: `src-tauri/Cargo.toml` (add `dirs` dependency)
- Modify: `src-tauri/src/state.rs:23-33` (`Config::detect`)

**Interfaces:**
- Produces: `Config { home, search_root, .. }` where `home` = `%USERPROFILE%` on Windows, `$HOME` on unix. Consumed by every scan command and by `services::make_service`. Signature unchanged.

- [ ] **Step 1: Add the `dirs` dependency**

In `src-tauri/Cargo.toml`, under `[dependencies]` (after the `tauri-plugin-global-shortcut = "2"` line), add:

```toml
dirs = "5"
```

- [ ] **Step 2: Resolve home via `dirs::home_dir()`**

Replace `Config::detect` (state.rs:22-34):

```rust
impl Config {
pub fn detect() -> Self {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/"));
Self {
search_root: home.clone(),
home,
temp_min_age_days: 7,
big_files_top: 50,
}
}
}
```

with:

```rust
impl Config {
pub fn detect() -> Self {
// `dirs::home_dir()` resolves $HOME on unix and %USERPROFILE% on Windows.
let home = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from(if cfg!(windows) { "C:\\" } else { "/" }));
Self {
search_root: home.clone(),
home,
temp_min_age_days: 7,
big_files_top: 50,
}
}
}
```

- [ ] **Step 3: Verify no Linux regression**

Run: `cargo fmt --all && cargo clippy -p freeyourdisk --all-targets -- -D warnings`
Expected: PASS (on Linux `dirs::home_dir()` returns `$HOME` — same root as before).

- [ ] **Step 4: Commit**

```bash
git add src-tauri/Cargo.toml Cargo.lock src-tauri/src/state.rs
git commit -m "feat(win): resolve user home via dirs::home_dir (fixes %USERPROFILE% on Windows)"
```

---

### Task 2: Replace the `du`-based `system_total` with an internal sum on Windows

`system_total` shells out to `du` over POSIX roots. On Windows `du` doesn't exist and the roots don't either (returns 0). Add a Windows path that sums the real system directories with the internal scan engine (`core_scan::cache::cached_dir_total`, already used by `home_total`). Refactor the per-OS logic into a `measure_system()` helper so the async command stays tiny.

**Files:**
- Modify: `src-tauri/src/commands.rs:220-285` (`system_total`)

**Interfaces:**
- Consumes: `core_scan::cache::cached_dir_total(&Path) -> u64` and `core_scan::cache::save(&Path)` (already used at commands.rs:213/216); `settings::config_dir()`.
- Produces: `system_total() -> Result<u64, String>` (unchanged signature); new private `measure_system() -> u64` with per-OS arms.

- [ ] **Step 1: Extract `measure_system()` and add the Windows arm**

Replace the whole `system_total` function (commands.rs:228-285, the `#[tauri::command] pub async fn system_total ...` block) with:

```rust
/// Measured OS footprint (real system size), as opposed to the `used − home`
/// residual which wrongly absorbs reserved blocks.
///
/// unix: delegates to `du` (hardlink-dedup, true blocks, single-fs `-x`).
/// Windows: `du` is absent, so we sum the system roots with the internal
/// mtime-cached walker (same engine as `home_total`). Unreadable subtrees
/// (ACL-locked) are skipped by the walker — an approximation, like `du`.
#[tauri::command]
pub async fn system_total() -> Result<u64, String> {
tauri::async_runtime::spawn_blocking(measure_system)
.await
.map_err(|e| e.to_string())
}

#[cfg(not(target_os = "windows"))]
fn measure_system() -> u64 {
#[cfg(target_os = "linux")]
const ROOTS: &[&str] = &["/usr", "/var", "/opt", "/boot", "/srv", "/root", "/swapfile"];
#[cfg(target_os = "macos")]
const ROOTS: &[&str] =
&["/System", "/Library", "/usr", "/private/var", "/opt", "/Applications"];

let present: Vec<&str> = ROOTS
.iter()
.copied()
.filter(|p| std::path::Path::new(p).exists())
.collect();
if present.is_empty() {
return 0;
}

// GNU du reports bytes (`--block-size=1`); BSD du (macOS) only does
// 1024-byte blocks (`-k`), so scale there.
#[cfg(target_os = "linux")]
let (du_args, mult): (&[&str], u64) = (&["-scx", "--block-size=1"], 1);
#[cfg(target_os = "macos")]
let (du_args, mult): (&[&str], u64) = (&["-scxk"], 1024);

let Ok(out) = std::process::Command::new("du")
.args(du_args)
.args(&present)
.output()
else {
return 0;
};
// The last line is "<n>\ttotal".
String::from_utf8_lossy(&out.stdout)
.lines()
.last()
.and_then(|line| line.split_whitespace().next())
.and_then(|n| n.parse::<u64>().ok())
.map(|v| v * mult)
.unwrap_or(0)
}

#[cfg(target_os = "windows")]
fn measure_system() -> u64 {
// No `du` on Windows: sum the real system roots with the internal walker.
const ROOTS: &[&str] = &[
"C:\\Windows",
"C:\\Program Files",
"C:\\Program Files (x86)",
"C:\\ProgramData",
];
let total: u64 = ROOTS
.iter()
.map(std::path::Path::new)
.filter(|p| p.exists())
.map(core_scan::cache::cached_dir_total)
.sum();
core_scan::cache::save(&settings::config_dir().join("dir-cache.json"));
total
}
```

Note: the only behavioral change on unix is the `not(target_os="macos")` arm becoming `target_os="linux"` (explicit) — identical code, per the cfg rule.

- [ ] **Step 2: Verify the unix path is unchanged + compiles**

Run: `cargo fmt --all && cargo clippy -p freeyourdisk --all-targets -- -D warnings`
Expected: PASS. (Confirm `cached_dir_total` accepts `&Path` — it is called as `core_scan::cache::cached_dir_total(&home)` at commands.rs:213, so `.map(core_scan::cache::cached_dir_total)` over `&Path` items type-checks; if the signature is `&PathBuf`, change the map to `.map(|p| core_scan::cache::cached_dir_total(p))` with `p: &Path` via `std::path::Path::new`. Adjust only if the compiler complains.)

- [ ] **Step 3: Commit**

```bash
git add src-tauri/src/commands.rs
git commit -m "feat(win): system_total via internal scan of Windows system roots (du replacement)"
```

---

### Task 3: Windows app-cache roots in `app_cache`

The app-cache service scans XDG/Flatpak/Snap locations. On Windows, regenerable app/browser caches live under `%LOCALAPPDATA%` and `%APPDATA%` (Chrome/Edge/Electron `Cache`/`Code Cache`/`GPUCache` dirs). Add a Windows arm that runs the existing `collect()` over those roots. The npm/yarn/bun home-relative caches (`.npm/.yarn/.bun`) already work on Windows (under `%USERPROFILE%`).

**Files:**
- Modify: `crates/core-services/src/app_cache.rs:120-132` (the macOS block in `scan`)

**Interfaces:**
- Consumes: `Self::collect(root: &Path, depth, &mut Vec<ScanItem>)`, `self.home` (= `%USERPROFILE%` from Task 1). Produces additional cache `ScanItem`s on Windows.

- [ ] **Step 1: Add the Windows cache-roots block**

In `crates/core-services/src/app_cache.rs`, immediately after the existing macOS block (which ends at line 132 with its closing `}` for `#[cfg(target_os = "macos")] { ... }`), add:

```rust
// 6. Windows: Chromium/Electron app caches under %LOCALAPPDATA% and
// %APPDATA% (e.g. Chrome/Edge/Electron Cache, Code Cache, GPUCache).
#[cfg(target_os = "windows")]
{
Self::collect(&h.join("AppData/Local"), 4, &mut items);
Self::collect(&h.join("AppData/Roaming"), 4, &mut items);
}
```

(Place it after the macOS block and before the `let total_bytes = ...` line.)

- [ ] **Step 2: Verify no regression**

Run: `cargo fmt --all && cargo clippy -p freeyourdisk --all-targets -- -D warnings && cargo test -p core-services`
Expected: PASS (the existing `finds_nested_browser_cache` test is OS-agnostic and unaffected; the Windows block is inert on Linux).

- [ ] **Step 3: Commit**

```bash
git add crates/core-services/src/app_cache.rs
git commit -m "feat(win): scan Windows app caches under %LOCALAPPDATA%/%APPDATA%"
```

---

### Task 4: Windows temp roots in `temp`

`TempService::with_defaults` adds `/tmp`, `/var/tmp` (root) and `~/.cache` (user), plus a macOS arm. On Windows the equivalents are `%LOCALAPPDATA%\Temp` (user) and `%WINDIR%\Temp` (root). Add a Windows arm.

**Files:**
- Modify: `crates/core-services/src/temp.rs:26-53` (`with_defaults`)

**Interfaces:**
- Produces: `TempService::with_defaults(home: &Path, min_age_days) -> TempService` with Windows temp roots. Signature unchanged.

- [ ] **Step 1: Add the Windows temp-roots arm**

In `crates/core-services/src/temp.rs`, the macOS arm currently reads:

```rust
// macOS keeps user caches under ~/Library/Caches.
#[cfg(target_os = "macos")]
roots.push(TempRoot {
path: home.join("Library/Caches"),
requires_root: false,
});
```

Add, immediately after it (before `Self { roots, min_age_days }`):

```rust
// Windows: %LOCALAPPDATA%\Temp (user) and %WINDIR%\Temp (privileged).
#[cfg(target_os = "windows")]
{
roots.push(TempRoot {
path: home.join("AppData/Local/Temp"),
requires_root: false,
});
let windir = std::env::var_os("WINDIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from("C:\\Windows"));
roots.push(TempRoot {
path: windir.join("Temp"),
requires_root: true,
});
}
```

Note: the existing `/tmp` and `/var/tmp` `TempRoot`s remain in the vec on Windows but are harmless — they don't exist, so `scan()` skips them (its `read_dir` returns `Err` and `continue`s). Leaving them avoids restructuring the shared base vec; the Windows roots are simply appended, mirroring the macOS pattern.

- [ ] **Step 2: Verify no regression**

Run: `cargo fmt --all && cargo clippy -p freeyourdisk --all-targets -- -D warnings && cargo test -p core-services`
Expected: PASS (the `#[allow(unused_mut)]` on `roots` already covers the case; existing temp tests are OS-agnostic).

- [ ] **Step 3: Commit**

```bash
git add crates/core-services/src/temp.rs
git commit -m "feat(win): scan Windows temp roots (%LOCALAPPDATA%\\Temp + %WINDIR%\\Temp)"
```

---

## Self-Review

**1. Spec coverage (Phase 1 = spec §10 step 1 + §5.1/§5.2):**
- Paths: `dirs` adoption + `%USERPROFILE%` home — Task 1. ✓ (settings.rs `config_dir` already Windows-correct from Phase 0; left as-is to avoid changing Linux/macOS paths — documented deviation: full `dirs::config_dir` unification deferred as unnecessary.)
- Scan engine: portable already (no task needed). ✓
- `du` replacement (internal sum) — Task 2. ✓
- Windows caches (`%LOCALAPPDATA%`) — Task 3. ✓
- Temp roots — Task 4. ✓
- Recycle Bin deletion: `core-trash` already handles it — no task (verified Phase 0). ✓
- Headless cache path (spec §5.1 mentions): DEFERRED to Phase 6 (headless is only invoked by the scheduler, which doesn't exist on Windows until Phase 6 — out of Phase 1's interactive scan/clean scope). Documented.

**2. Placeholder scan:** No TBD/TODO. Task 2 Step 2 contains a conditional fallback instruction (adjust the `.map` form only if the compiler complains about `&Path` vs `&PathBuf`) — this is a concrete, bounded contingency, not a placeholder; the primary form is given in full.

**3. Type consistency:** `Config.home: PathBuf` (Task 1) feeds `services::make_service(&cfg.home)` → `AppCacheService::new(home)` and `TempService::with_defaults(&home)` (Tasks 3/4) and `system_total` reads system roots independently (Task 2). `measure_system() -> u64` matches `spawn_blocking`'s expected return. All consistent.

## Notes for later phases (not this plan)
- Phase 6: Windows headless cache path + Task Scheduler + autostart Run-key + toasts.
- Optional: unify `settings::config_dir` via `dirs::config_dir()` (would collapse the 3 cfg arms) — only if it does not change the existing Linux `freeyourdisk` / macOS `FreeYourDisk` directory names.
1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ core-scan = { path = "../crates/core-scan" }
sysinfo = "0.39"
jwalk = { workspace = true }
tauri-plugin-global-shortcut = "2"
dirs = "6"

[target.'cfg(unix)'.dependencies]
libc = "0.2"
Expand Down
Loading
Loading