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

Large diffs are not rendered by default.

63 changes: 61 additions & 2 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ pub async fn disk_usage() -> Result<Vec<MountUsage>, String> {
}

/// Whether the weekly cleanup timer is enabled (systemd on Linux, launchd on macOS).
#[cfg(not(target_os = "macos"))]
#[cfg(target_os = "linux")]
#[tauri::command]
pub fn schedule_enabled() -> bool {
std::process::Command::new("systemctl")
Expand All @@ -116,7 +116,7 @@ pub fn schedule_enabled() -> bool {
}

/// Enable or disable (and start/stop) the weekly cleanup timer.
#[cfg(not(target_os = "macos"))]
#[cfg(target_os = "linux")]
#[tauri::command]
pub fn set_schedule(enabled: bool) -> Result<bool, String> {
let action = if enabled { "enable" } else { "disable" };
Expand All @@ -131,6 +131,65 @@ pub fn set_schedule(enabled: bool) -> Result<bool, String> {
}
}

#[cfg(target_os = "windows")]
const CLEANUP_TASK_NAME: &str = "FreeYourDisk Cleanup";

#[cfg(target_os = "windows")]
#[tauri::command]
pub fn schedule_enabled() -> bool {
std::process::Command::new("schtasks")
.args(["/Query", "/TN", CLEANUP_TASK_NAME])
.output()
.map(|out| out.status.success())
.unwrap_or(false)
}

#[cfg(target_os = "windows")]
#[tauri::command]
pub fn set_schedule(enabled: bool) -> Result<bool, String> {
if enabled {
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
let action = format!("\"{}\" --headless --service=temp --apply", exe.display());
let out = std::process::Command::new("schtasks")
.args([
"/Create",
"/TN",
CLEANUP_TASK_NAME,
"/TR",
&action,
"/SC",
"WEEKLY",
"/D",
"SUN",
"/ST",
"03:00",
"/F",
])
.output()
.map_err(|e| e.to_string())?;
if out.status.success() {
Ok(true)
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
} else {
// Idempotent: deleting an absent task means "already disabled", not an
// error (mirrors the autostart disable). Real /Delete failures still surface.
if !schedule_enabled() {
return Ok(false);
}
let out = std::process::Command::new("schtasks")
.args(["/Delete", "/TN", CLEANUP_TASK_NAME, "/F"])
.output()
.map_err(|e| e.to_string())?;
if out.status.success() {
Ok(false)
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
}

/// macOS: a LaunchAgent that runs the headless temp/cache cleanup weekly.
#[cfg(target_os = "macos")]
fn cleanup_plist_path() -> std::path::PathBuf {
Expand Down
80 changes: 71 additions & 9 deletions src-tauri/src/headless.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use core_services::{Service, TempRoot, TempService};
use core_trash::{to_trash, Zones};
use std::path::{Path, PathBuf};
#[cfg(any(target_os = "linux", target_os = "macos"))]
use std::process::Command;

/// Outcome of a headless run.
Expand All @@ -21,12 +22,17 @@ pub struct HeadlessOutcome {
/// Default age threshold for the scheduled cache cleanup.
const MIN_AGE_DAYS: u32 = 7;

/// Scan (and optionally trash) old files under `~/.cache`. User-only by
/// construction: the single root is the user's cache, marked non-root.
pub fn cache_cleanup(home: &Path, min_age_days: u32, apply: bool) -> HeadlessOutcome {
/// Core scan-and-optionally-trash for an arbitrary root.
/// `cache_root` is scanned; `zone_root` is the safety zone for trash.
fn clean_root(
cache_root: &Path,
zone_root: &Path,
min_age_days: u32,
apply: bool,
) -> HeadlessOutcome {
let service = TempService {
roots: vec![TempRoot {
path: home.join(".cache"),
path: cache_root.to_path_buf(),
requires_root: false,
}],
min_age_days,
Expand All @@ -43,7 +49,7 @@ pub fn cache_cleanup(home: &Path, min_age_days: u32, apply: bool) -> HeadlessOut
};
}

let zones = Zones(vec![home.to_path_buf()]);
let zones = Zones(vec![zone_root.to_path_buf()]);
let paths: Vec<PathBuf> = items.iter().map(|item| item.path.clone()).collect();
let report = to_trash(&paths, &zones);

Expand All @@ -55,6 +61,13 @@ pub fn cache_cleanup(home: &Path, min_age_days: u32, apply: bool) -> HeadlessOut
}
}

/// Scan (and optionally trash) old files under `~/.cache`. User-only by
/// construction: the single root is the user's cache, marked non-root.
#[cfg(any(not(target_os = "windows"), test))]
pub fn cache_cleanup(home: &Path, min_age_days: u32, apply: bool) -> HeadlessOutcome {
clean_root(&home.join(".cache"), home, min_age_days, apply)
}

fn humanize(bytes: u64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
if bytes == 0 {
Expand Down Expand Up @@ -87,11 +100,27 @@ fn notify(freed_bytes: u64, count: usize) {
} else {
format!("{} freed · {count} items", humanize(freed_bytes))
};

#[cfg(target_os = "linux")]
let _ = Command::new("notify-send")
.arg("--app-name=FreeYourDisk")
.arg("FreeYourDisk")
.arg(body)
.status();

#[cfg(target_os = "windows")]
crate::toast::show("FreeYourDisk", &body);

#[cfg(target_os = "macos")]
{
let safe = body.replace('"', "");
let _ = Command::new("osascript")
.args([
"-e",
&format!("display notification \"{safe}\" with title \"FreeYourDisk\""),
])
.status();
}
}

/// CLI entry point for `--headless`. Returns a process exit code.
Expand All @@ -107,11 +136,29 @@ pub fn run(args: &[String]) -> i32 {
}

let apply = args.iter().any(|a| a == "--apply");
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/"));

let outcome = cache_cleanup(&home, MIN_AGE_DAYS, apply);
#[cfg(not(target_os = "windows"))]
let outcome = {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/"));
cache_cleanup(&home, MIN_AGE_DAYS, apply)
};

#[cfg(target_os = "windows")]
let outcome = {
let local = std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(|| {
std::env::var_os("USERPROFILE")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("C:\\"))
.join("AppData")
.join("Local")
});
let root = local.join("Temp");
clean_root(&root, &root, MIN_AGE_DAYS, apply)

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 Filter Temp directories before scheduled cleanup

When the Windows scheduled task runs with --apply, this path scans %LOCALAPPDATA%\Temp through TempService::scan(), whose directory branch adds every non-empty immediate child directory without applying the min_age_days cutoff (only loose files are age-filtered). With a fresh installer/app temp directory present at the weekly run, clean_root will pass that directory to to_trash, so the scheduled cleanup can remove active recent temp trees rather than only stale files.

Useful? React with 👍 / 👎.

};

if apply {
if outcome.deleted_count > 0 {
Expand Down Expand Up @@ -287,6 +334,21 @@ mod tests {
.unwrap();
}

#[test]
fn clean_root_scans_an_arbitrary_root() {
let root = tempfile::tempdir().unwrap();
let f = root.path().join("stale.tmp");
std::fs::write(&f, vec![0u8; 100]).unwrap();
backdate(&f, 30);
let outcome = clean_root(root.path(), root.path(), 7, false);
assert!(!outcome.applied);
assert!(
outcome.considered >= 1,
"old file in an arbitrary root should be a candidate"
);
assert!(f.exists(), "dry-run must not delete");
}

#[test]
fn dry_run_frees_nothing() {
let home = tempfile::tempdir().unwrap();
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ mod smartdeps;
mod snapshot;
mod state;
mod taskmgr;
#[cfg(target_os = "windows")]
mod toast;
mod tray;

use state::AppState;
Expand Down
6 changes: 5 additions & 1 deletion src-tauri/src/monitor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use crate::settings;
use serde::Serialize;
use std::collections::HashSet;
#[cfg(any(target_os = "linux", target_os = "macos"))]
use std::process::Command;
use std::time::Duration;
use sysinfo::Disks;
Expand Down Expand Up @@ -75,14 +76,17 @@ fn raise_and_alert(app: &AppHandle, alert: LowSpaceAlert) {
alert.mount, alert.free_percent
);

#[cfg(not(target_os = "macos"))]
#[cfg(target_os = "linux")]
let _ = Command::new("notify-send")
.arg("--app-name=FreeYourDisk")
.arg("--urgency=critical")
.arg("FreeYourDisk")
.arg(&body)
.status();

#[cfg(target_os = "windows")]
crate::toast::show("FreeYourDisk", &body);

#[cfg(target_os = "macos")]
{
let safe = body.replace('"', "");
Expand Down
33 changes: 30 additions & 3 deletions src-tauri/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,36 @@ pub fn apply_autostart(enabled: bool) -> Result<(), String> {
Ok(())
}

// Windows autostart (HKCU\...\Run) lands in Phase 6; no-op for now so the
// settings save path succeeds.
/// Create or remove the launch-at-login entry under
/// `HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run`.
/// Per-user (HKCU), so no elevation is required.
#[cfg(target_os = "windows")]
pub fn apply_autostart(_enabled: bool) -> Result<(), String> {
pub fn apply_autostart(enabled: bool) -> Result<(), String> {
use winreg::enums::{HKEY_CURRENT_USER, KEY_SET_VALUE};
use winreg::RegKey;

let hkcu = RegKey::predef(HKEY_CURRENT_USER);
// create_subkey_with_flags creates the key if missing, opens it otherwise.
let (run, _) = hkcu
.create_subkey_with_flags(
r"Software\Microsoft\Windows\CurrentVersion\Run",
KEY_SET_VALUE,
)
.map_err(|e| e.to_string())?;

if enabled {
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
// Quote the path so an install dir with spaces (e.g. "Program Files")
// is parsed as a single argument by the shell at login.
run.set_value("FreeYourDisk", &format!("\"{}\"", exe.display()))
.map_err(|e| e.to_string())?;
} else {
// Disabling when the value is absent must succeed (idempotent).
match run.delete_value("FreeYourDisk") {
Ok(()) => {}
Err(ref e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.to_string()),
}
}
Ok(())
}
51 changes: 51 additions & 0 deletions src-tauri/src/toast.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Windows desktop notifications via the WinRT `ToastNotificationManager`,
//! driven through PowerShell. No extra crate, no `unsafe`. Best-effort: any
//! failure is swallowed — a missing toast must never break a cleanup or the
//! low-space monitor.

/// Show a Windows toast with the given title and body. Best-effort (errors are
/// ignored).
pub(crate) fn show(title: &str, body: &str) {
const PS: &str = r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe";
const APP_ID: &str =
r"{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe";

let script = format!(
"[Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,ContentType=WindowsRuntime]|Out-Null;\
$x=[Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02);\
$t=$x.GetElementsByTagName('text');\
$t.Item(0).AppendChild($x.CreateTextNode('{title}'))|Out-Null;\
$t.Item(1).AppendChild($x.CreateTextNode('{body}'))|Out-Null;\
$n=[Windows.UI.Notifications.ToastNotification]::new($x);\
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{app}').Show($n)",
title = escape_ps_literal(title),
body = escape_ps_literal(body),
app = APP_ID,
);

let _ = std::process::Command::new(PS)
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.status();
}

/// Double single quotes so a string is safe inside a PowerShell single-quoted
/// literal (`'...'`) — the only escaping such literals require.
fn escape_ps_literal(s: &str) -> String {
s.replace('\'', "''")
}

#[cfg(test)]
mod tests {
use super::escape_ps_literal;

#[test]
fn doubles_single_quotes() {
assert_eq!(escape_ps_literal("it's a 'test'"), "it''s a ''test''");
}

#[test]
fn leaves_plain_text_unchanged() {
assert_eq!(escape_ps_literal("12.3 GB freed"), "12.3 GB freed");
}
}
Loading