diff --git a/Cargo.lock b/Cargo.lock index 7b07709..0a94351 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3617,7 +3617,7 @@ dependencies = [ [[package]] name = "null-e-cli" -version = "0.3.0" +version = "0.4.0" dependencies = [ "chrono", "clap", @@ -3640,7 +3640,7 @@ dependencies = [ [[package]] name = "null-e-core" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "assert_fs", @@ -3677,8 +3677,9 @@ dependencies = [ [[package]] name = "null-e-gui" -version = "0.3.0" +version = "0.4.0" dependencies = [ + "dirs", "null-e-core", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index de725ce..e4f567c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/null-e-core", "crates/null-e-cli", "tauri"] resolver = "2" [workspace.package] -version = "0.4.0" +version = "0.4.1" edition = "2021" authors = ["us"] license = "WTFPL" diff --git a/tauri/Cargo.toml b/tauri/Cargo.toml index ea02712..9d0108d 100644 --- a/tauri/Cargo.toml +++ b/tauri/Cargo.toml @@ -18,6 +18,7 @@ serde_json = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } +dirs = { workspace = true } [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/tauri/src/commands/cache.rs b/tauri/src/commands/cache.rs index 0b78553..2501a9a 100644 --- a/tauri/src/commands/cache.rs +++ b/tauri/src/commands/cache.rs @@ -17,15 +17,15 @@ pub async fn detect_caches( } #[tauri::command] -pub async fn clean_cache(_state: tauri::State<'_, AppState>, id: String) -> Result<(), String> { +pub async fn clean_cache(_state: tauri::State<'_, AppState>, id: String) -> Result { tokio::task::spawn_blocking(move || { let caches = null_e_core::caches::detect_caches().map_err(|e| e.to_string())?; let cache = caches .iter() .find(|c| c.id == id) .ok_or_else(|| format!("Cache '{}' not found", id))?; - null_e_core::caches::clean_cache(cache, true).map_err(|e| e.to_string())?; - Ok::<(), String>(()) + let result = null_e_core::caches::clean_cache(cache, true).map_err(|e| e.to_string())?; + Ok::(result.bytes_freed) }) .await .map_err(|e| e.to_string())? diff --git a/tauri/src/commands/clean.rs b/tauri/src/commands/clean.rs index 5a7389c..0c9788d 100644 --- a/tauri/src/commands/clean.rs +++ b/tauri/src/commands/clean.rs @@ -1,5 +1,6 @@ -use crate::dto::{CleanConfigDto, CleanProgressDto, CleanSummaryDto}; +use crate::dto::{CleanConfigDto, CleanFailureDto, CleanProgressDto, CleanSummaryDto}; use crate::state::AppState; +use null_e_core::error::NullEError; use null_e_core::trash::{delete_path, DeleteMethod}; use std::path::PathBuf; use tauri::{AppHandle, Emitter}; @@ -29,6 +30,7 @@ pub async fn start_clean( let mut succeeded = 0usize; let mut failed = 0usize; let mut bytes_freed = 0u64; + let mut failures = Vec::new(); for (i, target) in targets.iter().enumerate() { let path = PathBuf::from(target); @@ -46,8 +48,14 @@ pub async fn start_clean( succeeded += 1; bytes_freed += freed; } - Err(_) => { + Err(err) => { failed += 1; + let (reason, is_tcc) = classify_delete_error(&err); + failures.push(CleanFailureDto { + path: target.clone(), + reason, + is_tcc, + }); } } } @@ -58,6 +66,11 @@ pub async fn start_clean( failed, bytes_freed, used_trash: method == DeleteMethod::Trash, + method_label: match method { + DeleteMethod::Trash => "Trash".to_string(), + DeleteMethod::Permanent | DeleteMethod::DryRun => "Deleted".to_string(), + }, + failures, }; let _ = app_handle.emit("clean:complete", &summary); }); @@ -72,3 +85,23 @@ pub async fn cancel_clean(state: tauri::State<'_, AppState>) -> Result<(), Strin } Ok(()) } + +fn classify_delete_error(err: &NullEError) -> (String, bool) { + match err { + NullEError::Io(io_err) => { + let is_tcc = io_err.raw_os_error() == Some(1); + (io_err.to_string(), is_tcc) + } + NullEError::PermissionDenied(path) => { + (format!("Permission denied: {}", path.display()), false) + } + NullEError::Trash(message) => { + let lower = message.to_lowercase(); + let is_tcc = lower.contains("operation not permitted") + || lower.contains("eperm") + || lower.contains("os error 1"); + (message.clone(), is_tcc) + } + _ => (err.to_string(), false), + } +} diff --git a/tauri/src/commands/system.rs b/tauri/src/commands/system.rs index 5e85b58..f33dcd3 100644 --- a/tauri/src/commands/system.rs +++ b/tauri/src/commands/system.rs @@ -1,5 +1,7 @@ -use crate::dto::DiskInfoDto; +use crate::dto::{DiskInfoDto, FdaStatusDto}; use crate::state::AppState; +use std::fs; +use std::io::ErrorKind; use std::process::Command; #[tauri::command] @@ -39,3 +41,41 @@ pub async fn get_disk_info(_state: tauri::State<'_, AppState>) -> Result) -> Result { Ok(null_e_core::VERSION.to_string()) } + +#[tauri::command] +pub async fn check_fda_status(_state: tauri::State<'_, AppState>) -> Result { + Ok(check_fda_status_inner()) +} + +#[cfg(target_os = "macos")] +fn check_fda_status_inner() -> FdaStatusDto { + let platform = std::env::consts::OS.to_string(); + let Some(home) = dirs::home_dir() else { + return FdaStatusDto { + status: "unknown".to_string(), + platform, + }; + }; + + let tcc_dir = home.join("Library/Application Support/com.apple.TCC"); + let status = match fs::read_dir(tcc_dir) { + Ok(_) => "granted", + Err(err) if err.kind() == ErrorKind::PermissionDenied || err.raw_os_error() == Some(1) => { + "not_granted" + } + Err(_) => "unknown", + }; + + FdaStatusDto { + status: status.to_string(), + platform, + } +} + +#[cfg(not(target_os = "macos"))] +fn check_fda_status_inner() -> FdaStatusDto { + FdaStatusDto { + status: "granted".to_string(), + platform: std::env::consts::OS.to_string(), + } +} diff --git a/tauri/src/dto.rs b/tauri/src/dto.rs index 7cb6cf4..45bd63c 100644 --- a/tauri/src/dto.rs +++ b/tauri/src/dto.rs @@ -126,6 +126,15 @@ pub struct CleanSummaryDto { pub failed: usize, pub bytes_freed: u64, pub used_trash: bool, + pub method_label: String, + pub failures: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CleanFailureDto { + pub path: String, + pub reason: String, + pub is_tcc: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -196,3 +205,9 @@ pub struct DiskInfoDto { pub available: u64, pub mount_point: String, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FdaStatusDto { + pub status: String, + pub platform: String, +} diff --git a/tauri/src/lib.rs b/tauri/src/lib.rs index 00e8e43..8e1553e 100644 --- a/tauri/src/lib.rs +++ b/tauri/src/lib.rs @@ -32,6 +32,7 @@ pub fn run() { commands::config::save_config, commands::system::get_disk_info, commands::system::get_app_version, + commands::system::check_fda_status, ]) .setup(|app| { // System tray menu diff --git a/tauri/tauri.conf.json b/tauri/tauri.conf.json index fab1b51..d621280 100644 --- a/tauri/tauri.conf.json +++ b/tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json", "productName": "null-e", - "version": "0.4.0", + "version": "0.4.1", "identifier": "com.null-e.disk-cleaner", "build": { "frontendDist": "../ui/dist", @@ -63,7 +63,7 @@ }, "plugins": { "shell": { - "open": true + "open": "^(https?://.+|mailto:.+|tel:.+|x-apple\\.systempreferences:.+)$" }, "updater": { "endpoints": [ diff --git a/ui/src/App.tsx b/ui/src/App.tsx index aa1da98..210a8c6 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -11,12 +11,24 @@ import { useUiStore } from '@/stores/ui-store'; import { useScanStore } from '@/stores/scan-store'; import { useTheme } from '@/hooks/useTheme'; import { useScanProgress } from '@/hooks/useScanProgress'; +import { useFdaCheck } from '@/hooks/useFdaCheck'; export function App() { useTheme(); - useScanProgress(); const disclaimerAccepted = useUiStore((s) => s.disclaimerAccepted); + + if (!disclaimerAccepted) { + return ; + } + + return ; +} + +function AppMain() { + useScanProgress(); + useFdaCheck(); + const appState = useUiStore((s) => s.appState); const result = useScanStore((s) => s.result); const scanError = useScanStore((s) => s.error); @@ -35,10 +47,6 @@ export function App() { } }, [scanError, appState]); - if (!disclaimerAccepted) { - return ; - } - return (
diff --git a/ui/src/components/CelebrationView.tsx b/ui/src/components/CelebrationView.tsx index fc5e073..4b1e4b6 100644 --- a/ui/src/components/CelebrationView.tsx +++ b/ui/src/components/CelebrationView.tsx @@ -1,6 +1,14 @@ -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useRef, useCallback, useMemo, useState } from 'react'; import { motion } from 'framer-motion'; -import { PartyPopper, RotateCcw, AlertTriangle } from 'lucide-react'; +import { + PartyPopper, + RotateCcw, + AlertTriangle, + ChevronDown, + ChevronRight, + ExternalLink, +} from 'lucide-react'; +import { open } from '@tauri-apps/plugin-shell'; import { useCleanStore } from '@/stores/clean-store'; import { useScanStore } from '@/stores/scan-store'; import { useUiStore } from '@/stores/ui-store'; @@ -10,9 +18,16 @@ import { formatSize } from '@/lib/format'; export function CelebrationView() { const summary = useCleanStore((s) => s.summary); const canvasRef = useRef(null); + const [showFailures, setShowFailures] = useState(true); const hasFailed = summary != null && summary.failed > 0; const isFullSuccess = summary != null && summary.failed === 0; + const visibleFailures = useMemo( + () => summary?.failures.slice(0, 10) ?? [], + [summary] + ); + const hiddenFailureCount = Math.max(0, (summary?.failures.length ?? 0) - visibleFailures.length); + const hasTccFailures = summary?.failures.some((failure) => failure.is_tcc) ?? false; // Confetti effect — only on full success useEffect(() => { @@ -90,6 +105,14 @@ export function CelebrationView() { if (!summary) return null; + const openFdaSettings = async () => { + try { + await open('x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles'); + } catch (err) { + console.error('Failed to open Full Disk Access settings:', err); + } + }; + return (
{isFullSuccess && } @@ -142,12 +165,84 @@ export function CelebrationView() { )}

- {summary.used_trash ? 'Trash' : 'Deleted'} + {summary.method_label}

Method

+ {hasTccFailures && ( +
+
+ +
+

+ Full Disk Access required for some items +

+

+ Some deletions were blocked by macOS privacy protections. Grant Full Disk Access + to null-e, then try those items again. +

+
+ +
+
+ )} + + {summary.failures.length > 0 && ( +
+ + + {showFailures && ( +
+
+ {visibleFailures.map((failure) => ( +
+

+ {failure.path} +

+

+ {failure.reason} +

+
+ ))} +
+ {hiddenFailureCount > 0 && ( +

+ and {hiddenFailureCount} more +

+ )} +
+ )} +
+ )} + {/* Actions */}
+ +
+
+ + + )} + {/* Error message */} {scanError && ( - Items were moved to Trash + Method: {summary.method_label} +

+ )} + {!summary.used_trash && ( +

+ Method: {summary.method_label}

)} diff --git a/ui/src/hooks/useFdaCheck.ts b/ui/src/hooks/useFdaCheck.ts new file mode 100644 index 0000000..80cb7ce --- /dev/null +++ b/ui/src/hooks/useFdaCheck.ts @@ -0,0 +1,36 @@ +import { useEffect } from 'react'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { commands } from '@/lib/tauri'; +import { useUiStore } from '@/stores/ui-store'; + +export function useFdaCheck() { + useEffect(() => { + let cancelled = false; + + const refreshStatus = async () => { + try { + const status = await commands.checkFdaStatus(); + if (!cancelled) { + useUiStore.getState().setFdaStatus(status.status); + } + } catch { + if (!cancelled) { + useUiStore.getState().setFdaStatus('unknown'); + } + } + }; + + void refreshStatus(); + + const unlistenPromise = getCurrentWindow().onFocusChanged(({ payload: focused }) => { + if (focused) { + void refreshStatus(); + } + }); + + return () => { + cancelled = true; + void unlistenPromise.then((unlisten) => unlisten()); + }; + }, []); +} diff --git a/ui/src/lib/tauri.ts b/ui/src/lib/tauri.ts index 73888c1..2348aa6 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -57,12 +57,20 @@ export interface CleanProgressDto { is_complete: boolean; } +export interface CleanFailureDto { + path: string; + reason: string; + is_tcc: boolean; +} + export interface CleanSummaryDto { total_items: number; succeeded: number; failed: number; bytes_freed: number; used_trash: boolean; + method_label: string; + failures: CleanFailureDto[]; } // Cache types @@ -98,6 +106,11 @@ export interface DiskInfoDto { mount_point: string; } +export interface FdaStatusDto { + status: 'granted' | 'not_granted' | 'unknown'; + platform: string; +} + // Commands export const commands = { startScan: (config: ScanConfigDto) => @@ -114,7 +127,7 @@ export const commands = { detectCaches: () => invoke('detect_caches'), - cleanCache: (id: string) => invoke('clean_cache', { id }), + cleanCache: (id: string) => invoke('clean_cache', { id }), detectCleaners: () => invoke('detect_cleaners'), @@ -126,6 +139,8 @@ export const commands = { getDiskInfo: () => invoke('get_disk_info'), getAppVersion: () => invoke('get_app_version'), + + checkFdaStatus: () => invoke('check_fda_status'), }; // Events diff --git a/ui/src/stores/system-store.ts b/ui/src/stores/system-store.ts index 01757cb..4e204d0 100644 --- a/ui/src/stores/system-store.ts +++ b/ui/src/stores/system-store.ts @@ -1,6 +1,9 @@ import { create } from 'zustand'; import { commands, type CleanableItemDto, type GlobalCacheDto } from '@/lib/tauri'; +let currentDetection: Promise | null = null; +let queuedDetection: Promise | null = null; + interface SystemState { cleaners: CleanableItemDto[]; caches: GlobalCacheDto[]; @@ -18,20 +21,32 @@ export const useSystemStore = create((set, get) => ({ error: null, detectSystem: async () => { - if (get().isDetecting) return; - set({ isDetecting: true, error: null }); - try { - const [cleaners, caches] = await Promise.all([ - commands.detectCleaners(), - commands.detectCaches(), - ]); - set({ cleaners, caches, isDetecting: false }); - } catch (err) { - set({ - error: err instanceof Error ? err.message : String(err), - isDetecting: false, - }); + if (currentDetection) { + queuedDetection ??= currentDetection.finally(() => { + queuedDetection = null; + }).then(() => get().detectSystem()); + return queuedDetection; } + + currentDetection = (async () => { + set({ isDetecting: true, error: null }); + try { + const [cleaners, caches] = await Promise.all([ + commands.detectCleaners(), + commands.detectCaches(), + ]); + set({ cleaners, caches, isDetecting: false }); + } catch (err) { + set({ + error: err instanceof Error ? err.message : String(err), + isDetecting: false, + }); + } finally { + currentDetection = null; + } + })(); + + return currentDetection; }, reset: () => set({ cleaners: [], caches: [], isDetecting: false, error: null }), diff --git a/ui/src/stores/ui-store.ts b/ui/src/stores/ui-store.ts index b619e85..573c0af 100644 --- a/ui/src/stores/ui-store.ts +++ b/ui/src/stores/ui-store.ts @@ -4,6 +4,7 @@ export type AppState = 'welcome' | 'scanning' | 'results' | 'cleaning' | 'done'; export type Theme = 'dark' | 'light' | 'system'; export type ViewMode = 'grouped' | 'flat'; export type FlatSortBy = 'size' | 'name' | 'technology'; +export type FdaStatus = 'granted' | 'not_granted' | 'unknown' | 'unchecked'; interface UiState { appState: AppState; @@ -13,6 +14,8 @@ interface UiState { flatSortBy: FlatSortBy; searchQuery: string; disclaimerAccepted: boolean; + fdaStatus: FdaStatus; + fdaDismissed: boolean; setAppState: (state: AppState) => void; toggleTheme: () => void; @@ -22,6 +25,8 @@ interface UiState { setFlatSortBy: (sortBy: FlatSortBy) => void; setSearchQuery: (query: string) => void; acceptDisclaimer: () => void; + setFdaStatus: (status: FdaStatus) => void; + dismissFda: () => void; } export const useUiStore = create((set) => ({ @@ -35,6 +40,11 @@ export const useUiStore = create((set) => ({ try { return localStorage.getItem('null-e:disclaimer-accepted') !== null; } catch { return false; } })(), + fdaStatus: 'unchecked', + fdaDismissed: (() => { + try { return localStorage.getItem('null-e:fda-dismissed') !== null; } + catch { return false; } + })(), setAppState: (appState) => set({ appState }), @@ -59,4 +69,27 @@ export const useUiStore = create((set) => ({ localStorage.setItem('null-e:disclaimer-accepted', new Date().toISOString()); set({ disclaimerAccepted: true }); }, + + setFdaStatus: (fdaStatus) => { + if (fdaStatus === 'granted') { + try { + localStorage.removeItem('null-e:fda-dismissed'); + } catch { + // Ignore storage failures and still update in-memory state. + } + set({ fdaStatus, fdaDismissed: false }); + return; + } + + set({ fdaStatus }); + }, + + dismissFda: () => { + try { + localStorage.setItem('null-e:fda-dismissed', new Date().toISOString()); + } catch { + // Ignore storage failures and still update in-memory state. + } + set({ fdaDismissed: true }); + }, }));