1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-08 04:29:24 +00:00

chore: restructure windows branch (move windows/ content to root, remove macos files)

This commit is contained in:
Tw93
2026-01-10 13:23:29 +08:00
parent e84a457c2f
commit edf5ed09a9
140 changed files with 1472 additions and 34059 deletions

View File

@@ -1,235 +0,0 @@
#!/bin/bash
# User GUI Applications Cleanup Module (desktop apps, media, utilities).
set -euo pipefail
# Xcode and iOS tooling.
clean_xcode_tools() {
# Skip DerivedData/Archives while Xcode is running.
local xcode_running=false
if pgrep -x "Xcode" > /dev/null 2>&1; then
xcode_running=true
fi
safe_clean ~/Library/Developer/CoreSimulator/Caches/* "Simulator cache"
safe_clean ~/Library/Developer/CoreSimulator/Devices/*/data/tmp/* "Simulator temp files"
safe_clean ~/Library/Caches/com.apple.dt.Xcode/* "Xcode cache"
safe_clean ~/Library/Developer/Xcode/iOS\ Device\ Logs/* "iOS device logs"
safe_clean ~/Library/Developer/Xcode/watchOS\ Device\ Logs/* "watchOS device logs"
safe_clean ~/Library/Developer/Xcode/Products/* "Xcode build products"
if [[ "$xcode_running" == "false" ]]; then
safe_clean ~/Library/Developer/Xcode/DerivedData/* "Xcode derived data"
safe_clean ~/Library/Developer/Xcode/Archives/* "Xcode archives"
else
echo -e " ${YELLOW}${ICON_WARNING}${NC} Xcode is running, skipping DerivedData and Archives cleanup"
fi
}
# Code editors.
clean_code_editors() {
safe_clean ~/Library/Application\ Support/Code/logs/* "VS Code logs"
safe_clean ~/Library/Application\ Support/Code/Cache/* "VS Code cache"
safe_clean ~/Library/Application\ Support/Code/CachedExtensions/* "VS Code extension cache"
safe_clean ~/Library/Application\ Support/Code/CachedData/* "VS Code data cache"
safe_clean ~/Library/Caches/com.sublimetext.*/* "Sublime Text cache"
}
# Communication apps.
clean_communication_apps() {
safe_clean ~/Library/Application\ Support/discord/Cache/* "Discord cache"
safe_clean ~/Library/Application\ Support/legcord/Cache/* "Legcord cache"
safe_clean ~/Library/Application\ Support/Slack/Cache/* "Slack cache"
safe_clean ~/Library/Caches/us.zoom.xos/* "Zoom cache"
safe_clean ~/Library/Caches/com.tencent.xinWeChat/* "WeChat cache"
safe_clean ~/Library/Caches/ru.keepcoder.Telegram/* "Telegram cache"
safe_clean ~/Library/Caches/com.microsoft.teams2/* "Microsoft Teams cache"
safe_clean ~/Library/Caches/net.whatsapp.WhatsApp/* "WhatsApp cache"
safe_clean ~/Library/Caches/com.skype.skype/* "Skype cache"
safe_clean ~/Library/Caches/com.tencent.meeting/* "Tencent Meeting cache"
safe_clean ~/Library/Caches/com.tencent.WeWorkMac/* "WeCom cache"
safe_clean ~/Library/Caches/com.feishu.*/* "Feishu cache"
}
# DingTalk.
clean_dingtalk() {
safe_clean ~/Library/Caches/dd.work.exclusive4aliding/* "DingTalk iDingTalk cache"
safe_clean ~/Library/Caches/com.alibaba.AliLang.osx/* "AliLang security component"
safe_clean ~/Library/Application\ Support/iDingTalk/log/* "DingTalk logs"
safe_clean ~/Library/Application\ Support/iDingTalk/holmeslogs/* "DingTalk holmes logs"
}
# AI assistants.
clean_ai_apps() {
safe_clean ~/Library/Caches/com.openai.chat/* "ChatGPT cache"
safe_clean ~/Library/Caches/com.anthropic.claudefordesktop/* "Claude desktop cache"
safe_clean ~/Library/Logs/Claude/* "Claude logs"
}
# Design and creative tools.
clean_design_tools() {
safe_clean ~/Library/Caches/com.bohemiancoding.sketch3/* "Sketch cache"
safe_clean ~/Library/Application\ Support/com.bohemiancoding.sketch3/cache/* "Sketch app cache"
safe_clean ~/Library/Caches/Adobe/* "Adobe cache"
safe_clean ~/Library/Caches/com.adobe.*/* "Adobe app caches"
safe_clean ~/Library/Caches/com.figma.Desktop/* "Figma cache"
# Raycast cache is protected (clipboard history, images).
}
# Video editing tools.
clean_video_tools() {
safe_clean ~/Library/Caches/net.telestream.screenflow10/* "ScreenFlow cache"
safe_clean ~/Library/Caches/com.apple.FinalCut/* "Final Cut Pro cache"
safe_clean ~/Library/Caches/com.blackmagic-design.DaVinciResolve/* "DaVinci Resolve cache"
safe_clean ~/Library/Caches/com.adobe.PremierePro.*/* "Premiere Pro cache"
}
# 3D and CAD tools.
clean_3d_tools() {
safe_clean ~/Library/Caches/org.blenderfoundation.blender/* "Blender cache"
safe_clean ~/Library/Caches/com.maxon.cinema4d/* "Cinema 4D cache"
safe_clean ~/Library/Caches/com.autodesk.*/* "Autodesk cache"
safe_clean ~/Library/Caches/com.sketchup.*/* "SketchUp cache"
}
# Productivity apps.
clean_productivity_apps() {
safe_clean ~/Library/Caches/com.tw93.MiaoYan/* "MiaoYan cache"
safe_clean ~/Library/Caches/com.klee.desktop/* "Klee cache"
safe_clean ~/Library/Caches/klee_desktop/* "Klee desktop cache"
safe_clean ~/Library/Caches/com.orabrowser.app/* "Ora browser cache"
safe_clean ~/Library/Caches/com.filo.client/* "Filo cache"
safe_clean ~/Library/Caches/com.flomoapp.mac/* "Flomo cache"
safe_clean ~/Library/Application\ Support/Quark/Cache/videoCache/* "Quark video cache"
}
# Music/media players (protect Spotify offline music).
clean_media_players() {
local spotify_cache="$HOME/Library/Caches/com.spotify.client"
local spotify_data="$HOME/Library/Application Support/Spotify"
local has_offline_music=false
# Heuristics: offline DB or large cache.
if [[ -f "$spotify_data/PersistentCache/Storage/offline.bnk" ]] ||
[[ -d "$spotify_data/PersistentCache/Storage" && -n "$(find "$spotify_data/PersistentCache/Storage" -type f -name "*.file" 2> /dev/null | head -1)" ]]; then
has_offline_music=true
elif [[ -d "$spotify_cache" ]]; then
local cache_size_kb
cache_size_kb=$(get_path_size_kb "$spotify_cache")
if [[ $cache_size_kb -ge 512000 ]]; then
has_offline_music=true
fi
fi
if [[ "$has_offline_music" == "true" ]]; then
echo -e " ${YELLOW}${ICON_WARNING}${NC} Spotify cache protected · offline music detected"
note_activity
else
safe_clean ~/Library/Caches/com.spotify.client/* "Spotify cache"
fi
safe_clean ~/Library/Caches/com.apple.Music "Apple Music cache"
safe_clean ~/Library/Caches/com.apple.podcasts "Apple Podcasts cache"
safe_clean ~/Library/Caches/com.apple.TV/* "Apple TV cache"
safe_clean ~/Library/Caches/tv.plex.player.desktop "Plex cache"
safe_clean ~/Library/Caches/com.netease.163music "NetEase Music cache"
safe_clean ~/Library/Caches/com.tencent.QQMusic/* "QQ Music cache"
safe_clean ~/Library/Caches/com.kugou.mac/* "Kugou Music cache"
safe_clean ~/Library/Caches/com.kuwo.mac/* "Kuwo Music cache"
}
# Video players.
clean_video_players() {
safe_clean ~/Library/Caches/com.colliderli.iina "IINA cache"
safe_clean ~/Library/Caches/org.videolan.vlc "VLC cache"
safe_clean ~/Library/Caches/io.mpv "MPV cache"
safe_clean ~/Library/Caches/com.iqiyi.player "iQIYI cache"
safe_clean ~/Library/Caches/com.tencent.tenvideo "Tencent Video cache"
safe_clean ~/Library/Caches/tv.danmaku.bili/* "Bilibili cache"
safe_clean ~/Library/Caches/com.douyu.*/* "Douyu cache"
safe_clean ~/Library/Caches/com.huya.*/* "Huya cache"
}
# Download managers.
clean_download_managers() {
safe_clean ~/Library/Caches/net.xmac.aria2gui "Aria2 cache"
safe_clean ~/Library/Caches/org.m0k.transmission "Transmission cache"
safe_clean ~/Library/Caches/com.qbittorrent.qBittorrent "qBittorrent cache"
safe_clean ~/Library/Caches/com.downie.Downie-* "Downie cache"
safe_clean ~/Library/Caches/com.folx.*/* "Folx cache"
safe_clean ~/Library/Caches/com.charlessoft.pacifist/* "Pacifist cache"
}
# Gaming platforms.
clean_gaming_platforms() {
safe_clean ~/Library/Caches/com.valvesoftware.steam/* "Steam cache"
safe_clean ~/Library/Application\ Support/Steam/htmlcache/* "Steam web cache"
safe_clean ~/Library/Caches/com.epicgames.EpicGamesLauncher/* "Epic Games cache"
safe_clean ~/Library/Caches/com.blizzard.Battle.net/* "Battle.net cache"
safe_clean ~/Library/Application\ Support/Battle.net/Cache/* "Battle.net app cache"
safe_clean ~/Library/Caches/com.ea.*/* "EA Origin cache"
safe_clean ~/Library/Caches/com.gog.galaxy/* "GOG Galaxy cache"
safe_clean ~/Library/Caches/com.riotgames.*/* "Riot Games cache"
}
# Translation/dictionary apps.
clean_translation_apps() {
safe_clean ~/Library/Caches/com.youdao.YoudaoDict "Youdao Dictionary cache"
safe_clean ~/Library/Caches/com.eudic.* "Eudict cache"
safe_clean ~/Library/Caches/com.bob-build.Bob "Bob Translation cache"
}
# Screenshot/recording tools.
clean_screenshot_tools() {
safe_clean ~/Library/Caches/com.cleanshot.* "CleanShot cache"
safe_clean ~/Library/Caches/com.reincubate.camo "Camo cache"
safe_clean ~/Library/Caches/com.xnipapp.xnip "Xnip cache"
}
# Email clients.
clean_email_clients() {
safe_clean ~/Library/Caches/com.readdle.smartemail-Mac "Spark cache"
safe_clean ~/Library/Caches/com.airmail.* "Airmail cache"
}
# Task management apps.
clean_task_apps() {
safe_clean ~/Library/Caches/com.todoist.mac.Todoist "Todoist cache"
safe_clean ~/Library/Caches/com.any.do.* "Any.do cache"
}
# Shell/terminal utilities.
clean_shell_utils() {
safe_clean ~/.zcompdump* "Zsh completion cache"
safe_clean ~/.lesshst "less history"
safe_clean ~/.viminfo.tmp "Vim temporary files"
safe_clean ~/.wget-hsts "wget HSTS cache"
}
# Input methods and system utilities.
clean_system_utils() {
safe_clean ~/Library/Caches/com.runjuu.Input-Source-Pro/* "Input Source Pro cache"
safe_clean ~/Library/Caches/macos-wakatime.WakaTime/* "WakaTime cache"
}
# Note-taking apps.
clean_note_apps() {
safe_clean ~/Library/Caches/notion.id/* "Notion cache"
safe_clean ~/Library/Caches/md.obsidian/* "Obsidian cache"
safe_clean ~/Library/Caches/com.logseq.*/* "Logseq cache"
safe_clean ~/Library/Caches/com.bear-writer.*/* "Bear cache"
safe_clean ~/Library/Caches/com.evernote.*/* "Evernote cache"
safe_clean ~/Library/Caches/com.yinxiang.*/* "Yinxiang Note cache"
}
# Launchers and automation tools.
clean_launcher_apps() {
safe_clean ~/Library/Caches/com.runningwithcrayons.Alfred/* "Alfred cache"
safe_clean ~/Library/Caches/cx.c3.theunarchiver/* "The Unarchiver cache"
}
# Remote desktop tools.
clean_remote_desktop() {
safe_clean ~/Library/Caches/com.teamviewer.*/* "TeamViewer cache"
safe_clean ~/Library/Caches/com.anydesk.*/* "AnyDesk cache"
safe_clean ~/Library/Caches/com.todesk.*/* "ToDesk cache"
safe_clean ~/Library/Caches/com.sunlogin.*/* "Sunlogin cache"
}
# Main entry for GUI app cleanup.
clean_user_gui_applications() {
stop_section_spinner
clean_xcode_tools
clean_code_editors
clean_communication_apps
clean_dingtalk
clean_ai_apps
clean_design_tools
clean_video_tools
clean_3d_tools
clean_productivity_apps
clean_media_players
clean_video_players
clean_download_managers
clean_gaming_platforms
clean_translation_apps
clean_screenshot_tools
clean_email_clients
clean_task_apps
clean_shell_utils
clean_system_utils
clean_note_apps
clean_launcher_apps
clean_remote_desktop
}

442
lib/clean/apps.ps1 Normal file
View File

@@ -0,0 +1,442 @@
# Mole - Application-Specific Cleanup Module
# Cleans leftover data from uninstalled apps and app-specific caches
#Requires -Version 5.1
Set-StrictMode -Version Latest
# Prevent multiple sourcing
if ((Get-Variable -Name 'MOLE_CLEAN_APPS_LOADED' -Scope Script -ErrorAction SilentlyContinue) -and $script:MOLE_CLEAN_APPS_LOADED) { return }
$script:MOLE_CLEAN_APPS_LOADED = $true
# Import dependencies
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. "$scriptDir\..\core\base.ps1"
. "$scriptDir\..\core\log.ps1"
. "$scriptDir\..\core\file_ops.ps1"
# ============================================================================
# Orphaned App Data Detection
# ============================================================================
function Get-InstalledPrograms {
<#
.SYNOPSIS
Get list of installed programs from registry
#>
$programs = @()
$registryPaths = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*"
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"
"HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*"
)
foreach ($path in $registryPaths) {
$items = Get-ItemProperty -Path $path -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName } |
Select-Object DisplayName, InstallLocation, Publisher
if ($items) {
$programs += $items
}
}
# Also check UWP apps
try {
$uwpApps = Get-AppxPackage -ErrorAction SilentlyContinue |
Select-Object @{N='DisplayName';E={$_.Name}}, @{N='InstallLocation';E={$_.InstallLocation}}, Publisher
if ($uwpApps) {
$programs += $uwpApps
}
}
catch {
Write-Debug "Could not enumerate UWP apps: $_"
}
return $programs
}
function Find-OrphanedAppData {
<#
.SYNOPSIS
Find app data folders for apps that are no longer installed
#>
param([int]$DaysOld = 60)
$installedPrograms = Get-InstalledPrograms
$installedNames = $installedPrograms | ForEach-Object { $_.DisplayName.ToLower() }
$orphanedPaths = @()
$cutoffDate = (Get-Date).AddDays(-$DaysOld)
# Check common app data locations
$appDataPaths = @(
@{ Path = $env:APPDATA; Type = "Roaming" }
@{ Path = $env:LOCALAPPDATA; Type = "Local" }
)
foreach ($location in $appDataPaths) {
if (-not (Test-Path $location.Path)) { continue }
$folders = Get-ChildItem -Path $location.Path -Directory -ErrorAction SilentlyContinue
foreach ($folder in $folders) {
# Skip system folders
$skipFolders = @('Microsoft', 'Windows', 'Packages', 'Programs', 'Temp', 'Roaming')
if ($folder.Name -in $skipFolders) { continue }
# Skip if recently modified
if ($folder.LastWriteTime -gt $cutoffDate) { continue }
# Check if app is installed using stricter matching
# Require exact match or that folder name is a clear prefix/suffix of app name
$isInstalled = $false
$folderLower = $folder.Name.ToLower()
foreach ($name in $installedNames) {
# Exact match
if ($name -eq $folderLower) {
$isInstalled = $true
break
}
# Folder is prefix of app name (e.g., "chrome" matches "chrome browser")
if ($name.StartsWith($folderLower) -and $folderLower.Length -ge 4) {
$isInstalled = $true
break
}
# App name is prefix of folder (e.g., "vscode" matches "vscode-data")
if ($folderLower.StartsWith($name) -and $name.Length -ge 4) {
$isInstalled = $true
break
}
}
if (-not $isInstalled) {
$orphanedPaths += @{
Path = $folder.FullName
Name = $folder.Name
Type = $location.Type
Size = (Get-PathSize -Path $folder.FullName)
LastModified = $folder.LastWriteTime
}
}
}
}
return $orphanedPaths
}
function Clear-OrphanedAppData {
<#
.SYNOPSIS
Clean orphaned application data
#>
param([int]$DaysOld = 60)
Start-Section "Orphaned app data"
$orphaned = Find-OrphanedAppData -DaysOld $DaysOld
if ($orphaned.Count -eq 0) {
Write-Info "No orphaned app data found"
Stop-Section
return
}
# Filter by size (only clean if > 10MB to avoid noise)
$significantOrphans = $orphaned | Where-Object { $_.Size -gt 10MB }
if ($significantOrphans.Count -gt 0) {
$totalSize = ($significantOrphans | Measure-Object -Property Size -Sum).Sum
$sizeHuman = Format-ByteSize -Bytes $totalSize
Write-Info "Found $($significantOrphans.Count) orphaned folders ($sizeHuman)"
foreach ($orphan in $significantOrphans) {
$orphanSize = Format-ByteSize -Bytes $orphan.Size
Remove-SafeItem -Path $orphan.Path -Description "$($orphan.Name) ($orphanSize)" -Recurse
}
}
Stop-Section
}
# ============================================================================
# Specific Application Cleanup
# ============================================================================
function Clear-OfficeCache {
<#
.SYNOPSIS
Clean Microsoft Office caches and temp files
#>
$officeCachePaths = @(
# Office 365 / 2019 / 2021
"$env:LOCALAPPDATA\Microsoft\Office\16.0\OfficeFileCache"
"$env:LOCALAPPDATA\Microsoft\Office\16.0\Wef"
"$env:LOCALAPPDATA\Microsoft\Outlook\RoamCache"
"$env:LOCALAPPDATA\Microsoft\Outlook\Offline Address Books"
# Older Office versions
"$env:LOCALAPPDATA\Microsoft\Office\15.0\OfficeFileCache"
# Office temp files
"$env:APPDATA\Microsoft\Templates\*.tmp"
"$env:APPDATA\Microsoft\Word\*.tmp"
"$env:APPDATA\Microsoft\Excel\*.tmp"
"$env:APPDATA\Microsoft\PowerPoint\*.tmp"
)
foreach ($path in $officeCachePaths) {
if ($path -like "*.tmp") {
$parent = Split-Path -Parent $path
if (Test-Path $parent) {
$tmpFiles = Get-ChildItem -Path $parent -Filter "*.tmp" -File -ErrorAction SilentlyContinue
if ($tmpFiles) {
$paths = $tmpFiles | ForEach-Object { $_.FullName }
Remove-SafeItems -Paths $paths -Description "Office temp files"
}
}
}
elseif (Test-Path $path) {
Clear-DirectoryContents -Path $path -Description "Office $(Split-Path -Leaf $path)"
}
}
}
function Clear-OneDriveCache {
<#
.SYNOPSIS
Clean OneDrive cache
#>
$oneDriveCachePaths = @(
"$env:LOCALAPPDATA\Microsoft\OneDrive\logs"
"$env:LOCALAPPDATA\Microsoft\OneDrive\setup\logs"
)
foreach ($path in $oneDriveCachePaths) {
if (Test-Path $path) {
Remove-OldFiles -Path $path -DaysOld 7 -Description "OneDrive logs"
}
}
}
function Clear-DropboxCache {
<#
.SYNOPSIS
Clean Dropbox cache
#>
# Dropbox cache is typically in the Dropbox folder itself
$dropboxInfoPath = "$env:LOCALAPPDATA\Dropbox\info.json"
if (Test-Path $dropboxInfoPath) {
try {
$dropboxInfo = Get-Content $dropboxInfoPath | ConvertFrom-Json
$dropboxPath = $dropboxInfo.personal.path
if ($dropboxPath) {
$dropboxCachePath = "$dropboxPath\.dropbox.cache"
if (Test-Path $dropboxCachePath) {
Clear-DirectoryContents -Path $dropboxCachePath -Description "Dropbox cache"
}
}
}
catch {
Write-Debug "Could not read Dropbox config: $_"
}
}
}
function Clear-GoogleDriveCache {
<#
.SYNOPSIS
Clean Google Drive cache
#>
$googleDriveCachePaths = @(
"$env:LOCALAPPDATA\Google\DriveFS\Logs"
"$env:LOCALAPPDATA\Google\DriveFS\*.tmp"
)
foreach ($path in $googleDriveCachePaths) {
if ($path -like "*.tmp") {
$parent = Split-Path -Parent $path
if (Test-Path $parent) {
$tmpFiles = Get-ChildItem -Path $parent -Filter "*.tmp" -ErrorAction SilentlyContinue
if ($tmpFiles) {
$paths = $tmpFiles | ForEach-Object { $_.FullName }
Remove-SafeItems -Paths $paths -Description "Google Drive temp"
}
}
}
elseif (Test-Path $path) {
Remove-OldFiles -Path $path -DaysOld 7 -Description "Google Drive logs"
}
}
}
function Clear-AdobeData {
<#
.SYNOPSIS
Clean Adobe application caches and temp files
#>
$adobeCachePaths = @(
"$env:APPDATA\Adobe\Common\Media Cache Files"
"$env:APPDATA\Adobe\Common\Peak Files"
"$env:APPDATA\Adobe\Common\Team Projects Cache"
"$env:LOCALAPPDATA\Adobe\*\Cache"
"$env:LOCALAPPDATA\Adobe\*\CameraRaw\Cache"
"$env:LOCALAPPDATA\Temp\Adobe"
)
foreach ($pattern in $adobeCachePaths) {
$paths = Resolve-Path $pattern -ErrorAction SilentlyContinue
foreach ($path in $paths) {
if (Test-Path $path.Path) {
Clear-DirectoryContents -Path $path.Path -Description "Adobe cache"
}
}
}
}
function Clear-AutodeskData {
<#
.SYNOPSIS
Clean Autodesk application caches
#>
$autodeskCachePaths = @(
"$env:LOCALAPPDATA\Autodesk\*\Cache"
"$env:APPDATA\Autodesk\*\cache"
)
foreach ($pattern in $autodeskCachePaths) {
$paths = Resolve-Path $pattern -ErrorAction SilentlyContinue
foreach ($path in $paths) {
if (Test-Path $path.Path) {
Clear-DirectoryContents -Path $path.Path -Description "Autodesk cache"
}
}
}
}
# ============================================================================
# Gaming Platform Cleanup
# ============================================================================
function Clear-GamingPlatformCaches {
<#
.SYNOPSIS
Clean gaming platform caches (Steam, Epic, Origin, etc.)
#>
# Steam
$steamPaths = @(
"${env:ProgramFiles(x86)}\Steam\appcache\httpcache"
"${env:ProgramFiles(x86)}\Steam\appcache\librarycache"
"${env:ProgramFiles(x86)}\Steam\logs"
)
foreach ($path in $steamPaths) {
if (Test-Path $path) {
Clear-DirectoryContents -Path $path -Description "Steam $(Split-Path -Leaf $path)"
}
}
# Epic Games Launcher
$epicPaths = @(
"$env:LOCALAPPDATA\EpicGamesLauncher\Saved\webcache"
"$env:LOCALAPPDATA\EpicGamesLauncher\Saved\Logs"
)
foreach ($path in $epicPaths) {
if (Test-Path $path) {
Clear-DirectoryContents -Path $path -Description "Epic Games $(Split-Path -Leaf $path)"
}
}
# EA App (Origin replacement)
$eaPaths = @(
"$env:LOCALAPPDATA\Electronic Arts\EA Desktop\cache"
"$env:APPDATA\Origin\*\cache"
)
foreach ($pattern in $eaPaths) {
$paths = Resolve-Path $pattern -ErrorAction SilentlyContinue
foreach ($path in $paths) {
if (Test-Path $path.Path) {
Clear-DirectoryContents -Path $path.Path -Description "EA/Origin cache"
}
}
}
# GOG Galaxy
$gogPaths = @(
"$env:LOCALAPPDATA\GOG.com\Galaxy\webcache"
"$env:PROGRAMDATA\GOG.com\Galaxy\logs"
)
foreach ($path in $gogPaths) {
if (Test-Path $path) {
Clear-DirectoryContents -Path $path -Description "GOG Galaxy $(Split-Path -Leaf $path)"
}
}
# Ubisoft Connect
$ubiPaths = @(
"$env:LOCALAPPDATA\Ubisoft Game Launcher\cache"
"$env:LOCALAPPDATA\Ubisoft Game Launcher\logs"
)
foreach ($path in $ubiPaths) {
if (Test-Path $path) {
Clear-DirectoryContents -Path $path -Description "Ubisoft $(Split-Path -Leaf $path)"
}
}
# Battle.net
$battlenetPaths = @(
"$env:APPDATA\Battle.net\Cache"
"$env:APPDATA\Battle.net\Logs"
)
foreach ($path in $battlenetPaths) {
if (Test-Path $path) {
Clear-DirectoryContents -Path $path -Description "Battle.net $(Split-Path -Leaf $path)"
}
}
}
# ============================================================================
# Main Application Cleanup Function
# ============================================================================
function Invoke-AppCleanup {
<#
.SYNOPSIS
Run all application-specific cleanup tasks
#>
param([switch]$IncludeOrphaned)
Start-Section "Applications"
# Productivity apps
Clear-OfficeCache
Clear-OneDriveCache
Clear-DropboxCache
Clear-GoogleDriveCache
# Creative apps
Clear-AdobeData
Clear-AutodeskData
# Gaming platforms
Clear-GamingPlatformCaches
Stop-Section
# Orphaned app data (separate section)
if ($IncludeOrphaned) {
Clear-OrphanedAppData -DaysOld 60
}
}
# ============================================================================
# Exports
# ============================================================================
# Functions: Get-InstalledPrograms, Find-OrphanedAppData, Clear-OfficeCache, etc.

View File

@@ -1,313 +0,0 @@
#!/bin/bash
# Application Data Cleanup Module
set -euo pipefail
# Args: $1=target_dir, $2=label
clean_ds_store_tree() {
local target="$1"
local label="$2"
[[ -d "$target" ]] || return 0
local file_count=0
local total_bytes=0
local spinner_active="false"
if [[ -t 1 ]]; then
MOLE_SPINNER_PREFIX=" "
start_inline_spinner "Cleaning Finder metadata..."
spinner_active="true"
fi
local -a exclude_paths=(
-path "*/Library/Application Support/MobileSync" -prune -o
-path "*/Library/Developer" -prune -o
-path "*/.Trash" -prune -o
-path "*/node_modules" -prune -o
-path "*/.git" -prune -o
-path "*/Library/Caches" -prune -o
)
local -a find_cmd=("command" "find" "$target")
if [[ "$target" == "$HOME" ]]; then
find_cmd+=("-maxdepth" "5")
fi
find_cmd+=("${exclude_paths[@]}" "-type" "f" "-name" ".DS_Store" "-print0")
while IFS= read -r -d '' ds_file; do
local size
size=$(get_file_size "$ds_file")
total_bytes=$((total_bytes + size))
((file_count++))
if [[ "$DRY_RUN" != "true" ]]; then
rm -f "$ds_file" 2> /dev/null || true
fi
if [[ $file_count -ge $MOLE_MAX_DS_STORE_FILES ]]; then
break
fi
done < <("${find_cmd[@]}" 2> /dev/null || true)
if [[ "$spinner_active" == "true" ]]; then
stop_section_spinner
fi
if [[ $file_count -gt 0 ]]; then
local size_human
size_human=$(bytes_to_human "$total_bytes")
if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $label ${YELLOW}($file_count files, $size_human dry)${NC}"
else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label ${GREEN}($file_count files, $size_human)${NC}"
fi
local size_kb=$(((total_bytes + 1023) / 1024))
((files_cleaned += file_count))
((total_size_cleaned += size_kb))
((total_items++))
note_activity
fi
}
# Orphaned app data (60+ days inactive). Env: ORPHAN_AGE_THRESHOLD, DRY_RUN
# Usage: scan_installed_apps "output_file"
scan_installed_apps() {
local installed_bundles="$1"
# Cache installed app scan briefly to speed repeated runs.
local cache_file="$HOME/.cache/mole/installed_apps_cache"
local cache_age_seconds=300 # 5 minutes
if [[ -f "$cache_file" ]]; then
local cache_mtime=$(get_file_mtime "$cache_file")
local current_time
current_time=$(get_epoch_seconds)
local age=$((current_time - cache_mtime))
if [[ $age -lt $cache_age_seconds ]]; then
debug_log "Using cached app list (age: ${age}s)"
if [[ -r "$cache_file" ]] && [[ -s "$cache_file" ]]; then
if cat "$cache_file" > "$installed_bundles" 2> /dev/null; then
return 0
else
debug_log "Warning: Failed to read cache, rebuilding"
fi
else
debug_log "Warning: Cache file empty or unreadable, rebuilding"
fi
fi
fi
debug_log "Scanning installed applications (cache expired or missing)"
local -a app_dirs=(
"/Applications"
"/System/Applications"
"$HOME/Applications"
# Homebrew Cask locations
"/opt/homebrew/Caskroom"
"/usr/local/Caskroom"
# Setapp applications
"$HOME/Library/Application Support/Setapp/Applications"
)
# Temp dir avoids write contention across parallel scans.
local scan_tmp_dir=$(create_temp_dir)
local pids=()
local dir_idx=0
for app_dir in "${app_dirs[@]}"; do
[[ -d "$app_dir" ]] || continue
(
local -a app_paths=()
while IFS= read -r app_path; do
[[ -n "$app_path" ]] && app_paths+=("$app_path")
done < <(find "$app_dir" -name '*.app' -maxdepth 3 -type d 2> /dev/null)
local count=0
for app_path in "${app_paths[@]:-}"; do
local plist_path="$app_path/Contents/Info.plist"
[[ ! -f "$plist_path" ]] && continue
local bundle_id=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$plist_path" 2> /dev/null || echo "")
if [[ -n "$bundle_id" ]]; then
echo "$bundle_id"
((count++))
fi
done
) > "$scan_tmp_dir/apps_${dir_idx}.txt" &
pids+=($!)
((dir_idx++))
done
# Collect running apps and LaunchAgents to avoid false orphan cleanup.
(
local running_apps=$(run_with_timeout 5 osascript -e 'tell application "System Events" to get bundle identifier of every application process' 2> /dev/null || echo "")
echo "$running_apps" | tr ',' '\n' | sed -e 's/^ *//;s/ *$//' -e '/^$/d' > "$scan_tmp_dir/running.txt"
# Fallback: lsappinfo is more reliable than osascript
if command -v lsappinfo > /dev/null 2>&1; then
run_with_timeout 3 lsappinfo list 2> /dev/null | grep -o '"CFBundleIdentifier"="[^"]*"' | cut -d'"' -f4 >> "$scan_tmp_dir/running.txt" 2> /dev/null || true
fi
) &
pids+=($!)
(
run_with_timeout 5 find ~/Library/LaunchAgents /Library/LaunchAgents \
-name "*.plist" -type f 2> /dev/null |
xargs -I {} basename {} .plist > "$scan_tmp_dir/agents.txt" 2> /dev/null || true
) &
pids+=($!)
debug_log "Waiting for ${#pids[@]} background processes: ${pids[*]}"
for pid in "${pids[@]}"; do
wait "$pid" 2> /dev/null || true
done
debug_log "All background processes completed"
cat "$scan_tmp_dir"/*.txt >> "$installed_bundles" 2> /dev/null || true
safe_remove "$scan_tmp_dir" true
sort -u "$installed_bundles" -o "$installed_bundles"
ensure_user_dir "$(dirname "$cache_file")"
cp "$installed_bundles" "$cache_file" 2> /dev/null || true
local app_count=$(wc -l < "$installed_bundles" 2> /dev/null | tr -d ' ')
debug_log "Scanned $app_count unique applications"
}
# Sensitive data patterns that should never be treated as orphaned
# These patterns protect security-critical application data
readonly ORPHAN_NEVER_DELETE_PATTERNS=(
"*1password*" "*1Password*"
"*keychain*" "*Keychain*"
"*bitwarden*" "*Bitwarden*"
"*lastpass*" "*LastPass*"
"*keepass*" "*KeePass*"
"*dashlane*" "*Dashlane*"
"*enpass*" "*Enpass*"
"*ssh*" "*gpg*" "*gnupg*"
"com.apple.keychain*"
)
# Cache file for mdfind results (Bash 3.2 compatible, no associative arrays)
ORPHAN_MDFIND_CACHE_FILE=""
# Usage: is_bundle_orphaned "bundle_id" "directory_path" "installed_bundles_file"
is_bundle_orphaned() {
local bundle_id="$1"
local directory_path="$2"
local installed_bundles="$3"
# 1. Fast path: check protection list (in-memory, instant)
if should_protect_data "$bundle_id"; then
return 1
fi
# 2. Fast path: check sensitive data patterns (in-memory, instant)
local bundle_lower
bundle_lower=$(echo "$bundle_id" | LC_ALL=C tr '[:upper:]' '[:lower:]')
for pattern in "${ORPHAN_NEVER_DELETE_PATTERNS[@]}"; do
# shellcheck disable=SC2053
if [[ "$bundle_lower" == $pattern ]]; then
return 1
fi
done
# 3. Fast path: check installed bundles file (file read, fast)
if grep -Fxq "$bundle_id" "$installed_bundles" 2> /dev/null; then
return 1
fi
# 4. Fast path: hardcoded system components
case "$bundle_id" in
loginwindow | dock | systempreferences | systemsettings | settings | controlcenter | finder | safari)
return 1
;;
esac
# 5. Fast path: 60-day modification check (stat call, fast)
if [[ -e "$directory_path" ]]; then
local last_modified_epoch=$(get_file_mtime "$directory_path")
local current_epoch
current_epoch=$(get_epoch_seconds)
local days_since_modified=$(((current_epoch - last_modified_epoch) / 86400))
if [[ $days_since_modified -lt ${ORPHAN_AGE_THRESHOLD:-60} ]]; then
return 1
fi
fi
# 6. Slow path: mdfind fallback with file-based caching (Bash 3.2 compatible)
# This catches apps installed in non-standard locations
if [[ -n "$bundle_id" ]] && [[ "$bundle_id" =~ ^[a-zA-Z0-9._-]+$ ]] && [[ ${#bundle_id} -ge 5 ]]; then
# Initialize cache file if needed
if [[ -z "$ORPHAN_MDFIND_CACHE_FILE" ]]; then
ORPHAN_MDFIND_CACHE_FILE=$(mktemp "${TMPDIR:-/tmp}/mole_mdfind_cache.XXXXXX")
register_temp_file "$ORPHAN_MDFIND_CACHE_FILE"
fi
# Check cache first (grep is fast for small files)
if grep -Fxq "FOUND:$bundle_id" "$ORPHAN_MDFIND_CACHE_FILE" 2> /dev/null; then
return 1
fi
if grep -Fxq "NOTFOUND:$bundle_id" "$ORPHAN_MDFIND_CACHE_FILE" 2> /dev/null; then
# Already checked, not found - continue to return 0
:
else
# Query mdfind with strict timeout (2 seconds max)
local app_exists
app_exists=$(run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1 || echo "")
if [[ -n "$app_exists" ]]; then
echo "FOUND:$bundle_id" >> "$ORPHAN_MDFIND_CACHE_FILE"
return 1
else
echo "NOTFOUND:$bundle_id" >> "$ORPHAN_MDFIND_CACHE_FILE"
fi
fi
fi
# All checks passed - this is an orphan
return 0
}
# Orphaned app data sweep.
clean_orphaned_app_data() {
if ! ls "$HOME/Library/Caches" > /dev/null 2>&1; then
stop_section_spinner
echo -e " ${YELLOW}${ICON_WARNING}${NC} Skipped: No permission to access Library folders"
return 0
fi
start_section_spinner "Scanning installed apps..."
local installed_bundles=$(create_temp_file)
scan_installed_apps "$installed_bundles"
stop_section_spinner
local app_count=$(wc -l < "$installed_bundles" 2> /dev/null | tr -d ' ')
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Found $app_count active/installed apps"
local orphaned_count=0
local total_orphaned_kb=0
start_section_spinner "Scanning orphaned app resources..."
# CRITICAL: NEVER add LaunchAgents or LaunchDaemons (breaks login items/startup apps).
local -a resource_types=(
"$HOME/Library/Caches|Caches|com.*:org.*:net.*:io.*"
"$HOME/Library/Logs|Logs|com.*:org.*:net.*:io.*"
"$HOME/Library/Saved Application State|States|*.savedState"
"$HOME/Library/WebKit|WebKit|com.*:org.*:net.*:io.*"
"$HOME/Library/HTTPStorages|HTTP|com.*:org.*:net.*:io.*"
"$HOME/Library/Cookies|Cookies|*.binarycookies"
)
orphaned_count=0
for resource_type in "${resource_types[@]}"; do
IFS='|' read -r base_path label patterns <<< "$resource_type"
if [[ ! -d "$base_path" ]]; then
continue
fi
if ! ls "$base_path" > /dev/null 2>&1; then
continue
fi
local -a file_patterns=()
IFS=':' read -ra pattern_arr <<< "$patterns"
for pat in "${pattern_arr[@]}"; do
file_patterns+=("$base_path/$pat")
done
for item_path in "${file_patterns[@]}"; do
local iteration_count=0
for match in $item_path; do
[[ -e "$match" ]] || continue
((iteration_count++))
if [[ $iteration_count -gt $MOLE_MAX_ORPHAN_ITERATIONS ]]; then
break
fi
local bundle_id=$(basename "$match")
bundle_id="${bundle_id%.savedState}"
bundle_id="${bundle_id%.binarycookies}"
if is_bundle_orphaned "$bundle_id" "$match" "$installed_bundles"; then
local size_kb
size_kb=$(get_path_size_kb "$match")
if [[ -z "$size_kb" || "$size_kb" == "0" ]]; then
continue
fi
safe_clean "$match" "Orphaned $label: $bundle_id"
((orphaned_count++))
((total_orphaned_kb += size_kb))
fi
done
done
done
stop_section_spinner
if [[ $orphaned_count -gt 0 ]]; then
local orphaned_mb=$(echo "$total_orphaned_kb" | awk '{printf "%.1f", $1/1024}')
echo " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count items (~${orphaned_mb}MB)"
note_activity
fi
rm -f "$installed_bundles"
}

View File

@@ -1,117 +0,0 @@
#!/bin/bash
# Clean Homebrew caches and remove orphaned dependencies
# Env: DRY_RUN
# Skips if run within 7 days, runs cleanup/autoremove in parallel with 120s timeout
clean_homebrew() {
command -v brew > /dev/null 2>&1 || return 0
if [[ "${DRY_RUN:-false}" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Homebrew · would cleanup and autoremove"
return 0
fi
# Skip if cleaned recently to avoid repeated heavy operations.
local brew_cache_file="${HOME}/.cache/mole/brew_last_cleanup"
local cache_valid_days=7
local should_skip=false
if [[ -f "$brew_cache_file" ]]; then
local last_cleanup
last_cleanup=$(cat "$brew_cache_file" 2> /dev/null || echo "0")
local current_time
current_time=$(get_epoch_seconds)
local time_diff=$((current_time - last_cleanup))
local days_diff=$((time_diff / 86400))
if [[ $days_diff -lt $cache_valid_days ]]; then
should_skip=true
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew · cleaned ${days_diff}d ago, skipped"
fi
fi
[[ "$should_skip" == "true" ]] && return 0
# Skip cleanup if cache is small; still run autoremove.
local skip_cleanup=false
local brew_cache_size=0
if [[ -d ~/Library/Caches/Homebrew ]]; then
brew_cache_size=$(run_with_timeout 3 du -sk ~/Library/Caches/Homebrew 2> /dev/null | awk '{print $1}')
local du_exit=$?
if [[ $du_exit -eq 0 && -n "$brew_cache_size" && "$brew_cache_size" -lt 51200 ]]; then
skip_cleanup=true
fi
fi
# Spinner reflects whether cleanup is skipped.
if [[ -t 1 ]]; then
if [[ "$skip_cleanup" == "true" ]]; then
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Homebrew autoremove (cleanup skipped)..."
else
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Homebrew cleanup and autoremove..."
fi
fi
# Run cleanup/autoremove in parallel with timeout guard per command.
local timeout_seconds=120
local brew_tmp_file autoremove_tmp_file
local brew_pid autoremove_pid
local brew_exit=0
local autoremove_exit=0
if [[ "$skip_cleanup" == "false" ]]; then
brew_tmp_file=$(create_temp_file)
run_with_timeout "$timeout_seconds" brew cleanup > "$brew_tmp_file" 2>&1 &
brew_pid=$!
fi
autoremove_tmp_file=$(create_temp_file)
run_with_timeout "$timeout_seconds" brew autoremove > "$autoremove_tmp_file" 2>&1 &
autoremove_pid=$!
if [[ -n "$brew_pid" ]]; then
wait "$brew_pid" 2> /dev/null || brew_exit=$?
fi
wait "$autoremove_pid" 2> /dev/null || autoremove_exit=$?
local brew_success=false
if [[ "$skip_cleanup" == "false" && $brew_exit -eq 0 ]]; then
brew_success=true
fi
local autoremove_success=false
if [[ $autoremove_exit -eq 0 ]]; then
autoremove_success=true
fi
if [[ -t 1 ]]; then stop_inline_spinner; fi
# Process cleanup output and extract metrics
# Summarize cleanup results.
if [[ "$skip_cleanup" == "true" ]]; then
# Cleanup was skipped due to small cache size
local size_mb=$((brew_cache_size / 1024))
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew cleanup · cache ${size_mb}MB, skipped"
elif [[ "$brew_success" == "true" && -f "$brew_tmp_file" ]]; then
local brew_output
brew_output=$(cat "$brew_tmp_file" 2> /dev/null || echo "")
local removed_count freed_space
removed_count=$(printf '%s\n' "$brew_output" | grep -c "Removing:" 2> /dev/null || true)
freed_space=$(printf '%s\n' "$brew_output" | grep -o "[0-9.]*[KMGT]B freed" 2> /dev/null | tail -1 || true)
if [[ $removed_count -gt 0 ]] || [[ -n "$freed_space" ]]; then
if [[ -n "$freed_space" ]]; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew cleanup ${GREEN}($freed_space)${NC}"
else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew cleanup (${removed_count} items)"
fi
fi
elif [[ $brew_exit -eq 124 ]]; then
echo -e " ${YELLOW}${ICON_WARNING}${NC} Homebrew cleanup timed out · run ${GRAY}brew cleanup${NC} manually"
fi
# Process autoremove output - only show if packages were removed
# Only surface autoremove output when packages were removed.
if [[ "$autoremove_success" == "true" && -f "$autoremove_tmp_file" ]]; then
local autoremove_output
autoremove_output=$(cat "$autoremove_tmp_file" 2> /dev/null || echo "")
local removed_packages
removed_packages=$(printf '%s\n' "$autoremove_output" | grep -c "^Uninstalling" 2> /dev/null || true)
if [[ $removed_packages -gt 0 ]]; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed orphaned dependencies (${removed_packages} packages)"
fi
elif [[ $autoremove_exit -eq 124 ]]; then
echo -e " ${YELLOW}${ICON_WARNING}${NC} Autoremove timed out · run ${GRAY}brew autoremove${NC} manually"
fi
# Update cache timestamp on successful completion or when cleanup was intelligently skipped
# This prevents repeated cache size checks within the 7-day window
# Update cache timestamp when any work succeeded or was intentionally skipped.
if [[ "$skip_cleanup" == "true" ]] || [[ "$brew_success" == "true" ]] || [[ "$autoremove_success" == "true" ]]; then
ensure_user_file "$brew_cache_file"
get_epoch_seconds > "$brew_cache_file"
fi
}

385
lib/clean/caches.ps1 Normal file
View File

@@ -0,0 +1,385 @@
# Mole - Cache Cleanup Module
# Cleans Windows and application caches
#Requires -Version 5.1
Set-StrictMode -Version Latest
# Prevent multiple sourcing
if ((Get-Variable -Name 'MOLE_CLEAN_CACHES_LOADED' -Scope Script -ErrorAction SilentlyContinue) -and $script:MOLE_CLEAN_CACHES_LOADED) { return }
$script:MOLE_CLEAN_CACHES_LOADED = $true
# Import dependencies
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. "$scriptDir\..\core\base.ps1"
. "$scriptDir\..\core\log.ps1"
. "$scriptDir\..\core\file_ops.ps1"
# ============================================================================
# Windows System Caches
# ============================================================================
function Clear-WindowsUpdateCache {
<#
.SYNOPSIS
Clean Windows Update cache (requires admin)
#>
if (-not (Test-IsAdmin)) {
Write-Debug "Skipping Windows Update cache - requires admin"
return
}
$wuPath = "$env:WINDIR\SoftwareDistribution\Download"
if (Test-Path $wuPath) {
# Stop Windows Update service first
if (Test-DryRunMode) {
Write-DryRun "Windows Update cache"
Set-SectionActivity
return
}
try {
Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue
Clear-DirectoryContents -Path $wuPath -Description "Windows Update cache"
Start-Service -Name wuauserv -ErrorAction SilentlyContinue
}
catch {
Write-Debug "Could not clear Windows Update cache: $_"
Start-Service -Name wuauserv -ErrorAction SilentlyContinue
}
}
}
function Clear-DeliveryOptimizationCache {
<#
.SYNOPSIS
Clean Windows Delivery Optimization cache (requires admin)
#>
if (-not (Test-IsAdmin)) {
Write-Debug "Skipping Delivery Optimization cache - requires admin"
return
}
$doPath = "$env:WINDIR\ServiceProfiles\NetworkService\AppData\Local\Microsoft\Windows\DeliveryOptimization"
if (Test-Path $doPath) {
if (Test-DryRunMode) {
Write-DryRun "Delivery Optimization cache"
Set-SectionActivity
return
}
try {
Stop-Service -Name DoSvc -Force -ErrorAction SilentlyContinue
Clear-DirectoryContents -Path "$doPath\Cache" -Description "Delivery Optimization cache"
Start-Service -Name DoSvc -ErrorAction SilentlyContinue
}
catch {
Write-Debug "Could not clear Delivery Optimization cache: $_"
Start-Service -Name DoSvc -ErrorAction SilentlyContinue
}
}
}
function Clear-FontCache {
<#
.SYNOPSIS
Clean Windows font cache (requires admin)
#>
if (-not (Test-IsAdmin)) {
return
}
$fontCachePath = "$env:LOCALAPPDATA\Microsoft\Windows\Fonts\FontCache"
if (Test-Path $fontCachePath) {
Remove-SafeItem -Path $fontCachePath -Description "Font cache"
}
}
# ============================================================================
# Browser Caches
# ============================================================================
function Clear-BrowserCaches {
<#
.SYNOPSIS
Clean browser cache directories
#>
Start-Section "Browser caches"
# Chrome
$chromeCachePaths = @(
"$env:LOCALAPPDATA\Google\Chrome\User Data\Default\Cache"
"$env:LOCALAPPDATA\Google\Chrome\User Data\Default\Code Cache"
"$env:LOCALAPPDATA\Google\Chrome\User Data\Default\GPUCache"
"$env:LOCALAPPDATA\Google\Chrome\User Data\Default\Service Worker\CacheStorage"
"$env:LOCALAPPDATA\Google\Chrome\User Data\ShaderCache"
"$env:LOCALAPPDATA\Google\Chrome\User Data\GrShaderCache"
)
foreach ($path in $chromeCachePaths) {
if (Test-Path $path) {
Clear-DirectoryContents -Path $path -Description "Chrome $(Split-Path -Leaf $path)"
}
}
# Edge
$edgeCachePaths = @(
"$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\Cache"
"$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\Code Cache"
"$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\GPUCache"
"$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\Service Worker\CacheStorage"
"$env:LOCALAPPDATA\Microsoft\Edge\User Data\ShaderCache"
)
foreach ($path in $edgeCachePaths) {
if (Test-Path $path) {
Clear-DirectoryContents -Path $path -Description "Edge $(Split-Path -Leaf $path)"
}
}
# Firefox
$firefoxProfiles = "$env:APPDATA\Mozilla\Firefox\Profiles"
if (Test-Path $firefoxProfiles) {
$profiles = Get-ChildItem -Path $firefoxProfiles -Directory -ErrorAction SilentlyContinue
foreach ($profile in $profiles) {
$firefoxCachePaths = @(
"$($profile.FullName)\cache2"
"$($profile.FullName)\startupCache"
"$($profile.FullName)\shader-cache"
)
foreach ($path in $firefoxCachePaths) {
if (Test-Path $path) {
Clear-DirectoryContents -Path $path -Description "Firefox cache"
}
}
}
}
# Brave
$braveCachePath = "$env:LOCALAPPDATA\BraveSoftware\Brave-Browser\User Data\Default\Cache"
if (Test-Path $braveCachePath) {
Clear-DirectoryContents -Path $braveCachePath -Description "Brave cache"
}
# Opera
$operaCachePath = "$env:APPDATA\Opera Software\Opera Stable\Cache"
if (Test-Path $operaCachePath) {
Clear-DirectoryContents -Path $operaCachePath -Description "Opera cache"
}
Stop-Section
}
# ============================================================================
# Application Caches
# ============================================================================
function Clear-AppCaches {
<#
.SYNOPSIS
Clean common application caches
#>
Start-Section "Application caches"
# Spotify
$spotifyCachePaths = @(
"$env:LOCALAPPDATA\Spotify\Data"
"$env:LOCALAPPDATA\Spotify\Storage"
)
foreach ($path in $spotifyCachePaths) {
if (Test-Path $path) {
Clear-DirectoryContents -Path $path -Description "Spotify cache"
}
}
# Discord
$discordCachePaths = @(
"$env:APPDATA\discord\Cache"
"$env:APPDATA\discord\Code Cache"
"$env:APPDATA\discord\GPUCache"
)
foreach ($path in $discordCachePaths) {
if (Test-Path $path) {
Clear-DirectoryContents -Path $path -Description "Discord cache"
}
}
# Slack
$slackCachePaths = @(
"$env:APPDATA\Slack\Cache"
"$env:APPDATA\Slack\Code Cache"
"$env:APPDATA\Slack\GPUCache"
"$env:APPDATA\Slack\Service Worker\CacheStorage"
)
foreach ($path in $slackCachePaths) {
if (Test-Path $path) {
Clear-DirectoryContents -Path $path -Description "Slack cache"
}
}
# Teams
$teamsCachePaths = @(
"$env:APPDATA\Microsoft\Teams\Cache"
"$env:APPDATA\Microsoft\Teams\blob_storage"
"$env:APPDATA\Microsoft\Teams\databases"
"$env:APPDATA\Microsoft\Teams\GPUCache"
"$env:APPDATA\Microsoft\Teams\IndexedDB"
"$env:APPDATA\Microsoft\Teams\Local Storage"
"$env:APPDATA\Microsoft\Teams\tmp"
)
foreach ($path in $teamsCachePaths) {
if (Test-Path $path) {
Clear-DirectoryContents -Path $path -Description "Teams cache"
}
}
# VS Code
$vscodeCachePaths = @(
"$env:APPDATA\Code\Cache"
"$env:APPDATA\Code\CachedData"
"$env:APPDATA\Code\CachedExtensions"
"$env:APPDATA\Code\CachedExtensionVSIXs"
"$env:APPDATA\Code\Code Cache"
"$env:APPDATA\Code\GPUCache"
)
foreach ($path in $vscodeCachePaths) {
if (Test-Path $path) {
Clear-DirectoryContents -Path $path -Description "VS Code cache"
}
}
# Zoom
$zoomCachePath = "$env:APPDATA\Zoom\data"
if (Test-Path $zoomCachePath) {
Clear-DirectoryContents -Path $zoomCachePath -Description "Zoom cache"
}
# Adobe Creative Cloud
$adobeCachePaths = @(
"$env:LOCALAPPDATA\Adobe\*\Cache"
"$env:APPDATA\Adobe\Common\Media Cache Files"
"$env:APPDATA\Adobe\Common\Peak Files"
)
foreach ($pattern in $adobeCachePaths) {
$paths = Resolve-Path $pattern -ErrorAction SilentlyContinue
foreach ($path in $paths) {
if (Test-Path $path) {
Clear-DirectoryContents -Path $path.Path -Description "Adobe cache"
}
}
}
# Steam (download cache, not games)
$steamCachePath = "${env:ProgramFiles(x86)}\Steam\appcache"
if (Test-Path $steamCachePath) {
Clear-DirectoryContents -Path $steamCachePath -Description "Steam app cache"
}
# Epic Games Launcher
$epicCachePath = "$env:LOCALAPPDATA\EpicGamesLauncher\Saved\webcache"
if (Test-Path $epicCachePath) {
Clear-DirectoryContents -Path $epicCachePath -Description "Epic Games cache"
}
Stop-Section
}
# ============================================================================
# Windows Store / UWP App Caches
# ============================================================================
function Clear-StoreAppCaches {
<#
.SYNOPSIS
Clean Windows Store and UWP app caches
#>
# Microsoft Store cache
$storeCache = "$env:LOCALAPPDATA\Microsoft\Windows\WCN"
if (Test-Path $storeCache) {
Clear-DirectoryContents -Path $storeCache -Description "Windows Store cache"
}
# Store app temp files
$storeTemp = "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_*\LocalCache"
$storePaths = Resolve-Path $storeTemp -ErrorAction SilentlyContinue
foreach ($path in $storePaths) {
if (Test-Path $path.Path) {
Clear-DirectoryContents -Path $path.Path -Description "Store LocalCache"
}
}
}
# ============================================================================
# .NET / Runtime Caches
# ============================================================================
function Clear-DotNetCaches {
<#
.SYNOPSIS
Clean .NET runtime caches
#>
# .NET temp files
$dotnetTemp = "$env:LOCALAPPDATA\Temp\Microsoft.NET"
if (Test-Path $dotnetTemp) {
Clear-DirectoryContents -Path $dotnetTemp -Description ".NET temp files"
}
# NGen cache (don't touch - managed by Windows)
# Assembly cache (don't touch - managed by CLR)
}
# ============================================================================
# Main Cache Cleanup Function
# ============================================================================
function Invoke-CacheCleanup {
<#
.SYNOPSIS
Run all cache cleanup tasks
#>
param(
[switch]$IncludeWindowsUpdate,
[switch]$IncludeBrowsers,
[switch]$IncludeApps
)
Start-Section "System caches"
# Windows system caches (if admin)
if (Test-IsAdmin) {
if ($IncludeWindowsUpdate) {
Clear-WindowsUpdateCache
Clear-DeliveryOptimizationCache
}
Clear-FontCache
}
Clear-StoreAppCaches
Clear-DotNetCaches
Stop-Section
# Browser caches
if ($IncludeBrowsers) {
Clear-BrowserCaches
}
# Application caches
if ($IncludeApps) {
Clear-AppCaches
}
}
# ============================================================================
# Exports
# ============================================================================
# Functions: Clear-WindowsUpdateCache, Clear-BrowserCaches, Clear-AppCaches, etc.

View File

@@ -1,217 +0,0 @@
#!/bin/bash
# Cache Cleanup Module
set -euo pipefail
# Preflight TCC prompts once to avoid mid-run interruptions.
check_tcc_permissions() {
[[ -t 1 ]] || return 0
local permission_flag="$HOME/.cache/mole/permissions_granted"
[[ -f "$permission_flag" ]] && return 0
local -a tcc_dirs=(
"$HOME/Library/Caches"
"$HOME/Library/Logs"
"$HOME/Library/Application Support"
"$HOME/Library/Containers"
"$HOME/.cache"
)
# Quick permission probe (avoid deep scans).
local needs_permission_check=false
if ! ls "$HOME/Library/Caches" > /dev/null 2>&1; then
needs_permission_check=true
fi
if [[ "$needs_permission_check" == "true" ]]; then
echo ""
echo -e "${BLUE}First-time setup${NC}"
echo -e "${GRAY}macOS will request permissions to access Library folders.${NC}"
echo -e "${GRAY}You may see ${GREEN}${#tcc_dirs[@]} permission dialogs${NC}${GRAY} - please approve them all.${NC}"
echo ""
echo -ne "${PURPLE}${ICON_ARROW}${NC} Press ${GREEN}Enter${NC} to continue: "
read -r
MOLE_SPINNER_PREFIX="" start_inline_spinner "Requesting permissions..."
# Touch each directory to trigger prompts without deep scanning.
for dir in "${tcc_dirs[@]}"; do
[[ -d "$dir" ]] && command find "$dir" -maxdepth 1 -type d > /dev/null 2>&1
done
stop_inline_spinner
echo ""
fi
# Mark as granted to avoid repeat prompts.
ensure_user_file "$permission_flag"
return 0
}
# Args: $1=browser_name, $2=cache_path
# Clean Service Worker cache while protecting critical web editors.
clean_service_worker_cache() {
local browser_name="$1"
local cache_path="$2"
[[ ! -d "$cache_path" ]] && return 0
local cleaned_size=0
local protected_count=0
while IFS= read -r cache_dir; do
[[ ! -d "$cache_dir" ]] && continue
# Extract a best-effort domain name from cache folder.
local domain=$(basename "$cache_dir" | grep -oE '[a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z]{2,}' | head -1 || echo "")
local size=$(run_with_timeout 5 get_path_size_kb "$cache_dir")
local is_protected=false
for protected_domain in "${PROTECTED_SW_DOMAINS[@]}"; do
if [[ "$domain" == *"$protected_domain"* ]]; then
is_protected=true
protected_count=$((protected_count + 1))
break
fi
done
if [[ "$is_protected" == "false" ]]; then
if [[ "$DRY_RUN" != "true" ]]; then
safe_remove "$cache_dir" true || true
fi
cleaned_size=$((cleaned_size + size))
fi
done < <(run_with_timeout 10 sh -c "find '$cache_path' -type d -depth 2 2> /dev/null || true")
if [[ $cleaned_size -gt 0 ]]; then
local spinner_was_running=false
if [[ -t 1 && -n "${INLINE_SPINNER_PID:-}" ]]; then
stop_inline_spinner
spinner_was_running=true
fi
local cleaned_mb=$((cleaned_size / 1024))
if [[ "$DRY_RUN" != "true" ]]; then
if [[ $protected_count -gt 0 ]]; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $browser_name Service Worker (${cleaned_mb}MB, ${protected_count} protected)"
else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $browser_name Service Worker (${cleaned_mb}MB)"
fi
else
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $browser_name Service Worker (would clean ${cleaned_mb}MB, ${protected_count} protected)"
fi
note_activity
if [[ "$spinner_was_running" == "true" ]]; then
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning browser Service Worker caches..."
fi
fi
}
# Next.js/Python project caches with tight scan bounds and timeouts.
clean_project_caches() {
stop_inline_spinner 2> /dev/null || true
# Fast pre-check before scanning the whole home dir.
local has_dev_projects=false
local -a common_dev_dirs=(
"$HOME/Code"
"$HOME/Projects"
"$HOME/workspace"
"$HOME/github"
"$HOME/dev"
"$HOME/work"
"$HOME/src"
"$HOME/repos"
"$HOME/Development"
"$HOME/www"
"$HOME/golang"
"$HOME/go"
"$HOME/rust"
"$HOME/python"
"$HOME/ruby"
"$HOME/java"
"$HOME/dotnet"
"$HOME/node"
)
for dir in "${common_dev_dirs[@]}"; do
if [[ -d "$dir" ]]; then
has_dev_projects=true
break
fi
done
# Fallback: look for project markers near $HOME.
if [[ "$has_dev_projects" == "false" ]]; then
local -a project_markers=(
"node_modules"
".git"
"target"
"go.mod"
"Cargo.toml"
"package.json"
"pom.xml"
"build.gradle"
)
local spinner_active=false
if [[ -t 1 ]]; then
MOLE_SPINNER_PREFIX=" "
start_inline_spinner "Detecting dev projects..."
spinner_active=true
fi
for marker in "${project_markers[@]}"; do
if run_with_timeout 3 sh -c "find '$HOME' -maxdepth 2 -name '$marker' -not -path '*/Library/*' -not -path '*/.Trash/*' 2>/dev/null | head -1" | grep -q .; then
has_dev_projects=true
break
fi
done
if [[ "$spinner_active" == "true" ]]; then
stop_inline_spinner 2> /dev/null || true
fi
[[ "$has_dev_projects" == "false" ]] && return 0
fi
if [[ -t 1 ]]; then
MOLE_SPINNER_PREFIX=" "
start_inline_spinner "Searching project caches..."
fi
local nextjs_tmp_file
nextjs_tmp_file=$(create_temp_file)
local pycache_tmp_file
pycache_tmp_file=$(create_temp_file)
local find_timeout=10
# Parallel scans (Next.js and __pycache__).
(
command find "$HOME" -P -mount -type d -name ".next" -maxdepth 3 \
-not -path "*/Library/*" \
-not -path "*/.Trash/*" \
-not -path "*/node_modules/*" \
-not -path "*/.*" \
2> /dev/null || true
) > "$nextjs_tmp_file" 2>&1 &
local next_pid=$!
(
command find "$HOME" -P -mount -type d -name "__pycache__" -maxdepth 3 \
-not -path "*/Library/*" \
-not -path "*/.Trash/*" \
-not -path "*/node_modules/*" \
-not -path "*/.*" \
2> /dev/null || true
) > "$pycache_tmp_file" 2>&1 &
local py_pid=$!
local elapsed=0
local check_interval=0.2 # Check every 200ms instead of 1s for smoother experience
while [[ $(echo "$elapsed < $find_timeout" | awk '{print ($1 < $2)}') -eq 1 ]]; do
if ! kill -0 $next_pid 2> /dev/null && ! kill -0 $py_pid 2> /dev/null; then
break
fi
sleep $check_interval
elapsed=$(echo "$elapsed + $check_interval" | awk '{print $1 + $2}')
done
# Kill stuck scans after timeout.
for pid in $next_pid $py_pid; do
if kill -0 "$pid" 2> /dev/null; then
kill -TERM "$pid" 2> /dev/null || true
local grace_period=0
while [[ $grace_period -lt 20 ]]; do
if ! kill -0 "$pid" 2> /dev/null; then
break
fi
sleep 0.1
((grace_period++))
done
if kill -0 "$pid" 2> /dev/null; then
kill -KILL "$pid" 2> /dev/null || true
fi
wait "$pid" 2> /dev/null || true
else
wait "$pid" 2> /dev/null || true
fi
done
if [[ -t 1 ]]; then
stop_inline_spinner
fi
while IFS= read -r next_dir; do
[[ -d "$next_dir/cache" ]] && safe_clean "$next_dir/cache"/* "Next.js build cache" || true
done < "$nextjs_tmp_file"
while IFS= read -r pycache; do
[[ -d "$pycache" ]] && safe_clean "$pycache"/* "Python bytecode cache" || true
done < "$pycache_tmp_file"
}

537
lib/clean/dev.ps1 Normal file
View File

@@ -0,0 +1,537 @@
# Mole - Developer Tools Cleanup Module
# Cleans development tool caches and build artifacts
#Requires -Version 5.1
Set-StrictMode -Version Latest
# Prevent multiple sourcing
if ((Get-Variable -Name 'MOLE_CLEAN_DEV_LOADED' -Scope Script -ErrorAction SilentlyContinue) -and $script:MOLE_CLEAN_DEV_LOADED) { return }
$script:MOLE_CLEAN_DEV_LOADED = $true
# Import dependencies
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. "$scriptDir\..\core\base.ps1"
. "$scriptDir\..\core\log.ps1"
. "$scriptDir\..\core\file_ops.ps1"
# ============================================================================
# Node.js / JavaScript Ecosystem
# ============================================================================
function Clear-NpmCache {
<#
.SYNOPSIS
Clean npm, pnpm, yarn, and bun caches
#>
# npm cache
if (Get-Command npm -ErrorAction SilentlyContinue) {
if (Test-DryRunMode) {
Write-DryRun "npm cache"
}
else {
try {
$null = npm cache clean --force 2>&1
Write-Success "npm cache"
Set-SectionActivity
}
catch {
Write-Debug "npm cache clean failed: $_"
}
}
}
# npm cache directory (fallback)
$npmCachePath = "$env:APPDATA\npm-cache"
if (Test-Path $npmCachePath) {
Clear-DirectoryContents -Path $npmCachePath -Description "npm cache directory"
}
# pnpm store
$pnpmStorePath = "$env:LOCALAPPDATA\pnpm\store"
if (Test-Path $pnpmStorePath) {
if (Get-Command pnpm -ErrorAction SilentlyContinue) {
if (Test-DryRunMode) {
Write-DryRun "pnpm store"
}
else {
try {
$null = pnpm store prune 2>&1
Write-Success "pnpm store pruned"
Set-SectionActivity
}
catch {
Write-Debug "pnpm store prune failed: $_"
}
}
}
}
# Yarn cache
$yarnCachePaths = @(
"$env:LOCALAPPDATA\Yarn\Cache"
"$env:USERPROFILE\.yarn\cache"
)
foreach ($path in $yarnCachePaths) {
if (Test-Path $path) {
Clear-DirectoryContents -Path $path -Description "Yarn cache"
}
}
# Bun cache
$bunCachePath = "$env:USERPROFILE\.bun\install\cache"
if (Test-Path $bunCachePath) {
Clear-DirectoryContents -Path $bunCachePath -Description "Bun cache"
}
}
function Clear-NodeBuildCaches {
<#
.SYNOPSIS
Clean Node.js build-related caches
#>
# node-gyp
$nodeGypPath = "$env:LOCALAPPDATA\node-gyp\Cache"
if (Test-Path $nodeGypPath) {
Clear-DirectoryContents -Path $nodeGypPath -Description "node-gyp cache"
}
# Electron cache
$electronCachePath = "$env:LOCALAPPDATA\electron\Cache"
if (Test-Path $electronCachePath) {
Clear-DirectoryContents -Path $electronCachePath -Description "Electron cache"
}
# TypeScript cache
$tsCachePath = "$env:LOCALAPPDATA\TypeScript"
if (Test-Path $tsCachePath) {
Clear-DirectoryContents -Path $tsCachePath -Description "TypeScript cache"
}
}
# ============================================================================
# Python Ecosystem
# ============================================================================
function Clear-PythonCaches {
<#
.SYNOPSIS
Clean Python and pip caches
#>
# pip cache
if (Get-Command pip -ErrorAction SilentlyContinue) {
if (Test-DryRunMode) {
Write-DryRun "pip cache"
}
else {
try {
$null = pip cache purge 2>&1
Write-Success "pip cache"
Set-SectionActivity
}
catch {
Write-Debug "pip cache purge failed: $_"
}
}
}
# pip cache directory
$pipCachePath = "$env:LOCALAPPDATA\pip\Cache"
if (Test-Path $pipCachePath) {
Clear-DirectoryContents -Path $pipCachePath -Description "pip cache directory"
}
# Python bytecode caches (__pycache__)
# Note: These are typically in project directories, cleaned by purge command
# pyenv cache
$pyenvCachePath = "$env:USERPROFILE\.pyenv\cache"
if (Test-Path $pyenvCachePath) {
Clear-DirectoryContents -Path $pyenvCachePath -Description "pyenv cache"
}
# Poetry cache
$poetryCachePath = "$env:LOCALAPPDATA\pypoetry\Cache"
if (Test-Path $poetryCachePath) {
Clear-DirectoryContents -Path $poetryCachePath -Description "Poetry cache"
}
# conda packages
$condaCachePaths = @(
"$env:USERPROFILE\.conda\pkgs"
"$env:USERPROFILE\anaconda3\pkgs"
"$env:USERPROFILE\miniconda3\pkgs"
)
foreach ($path in $condaCachePaths) {
if (Test-Path $path) {
# Only clean index and temp files, not actual packages
$tempFiles = Get-ChildItem -Path $path -Filter "*.tmp" -ErrorAction SilentlyContinue
if ($tempFiles) {
$paths = $tempFiles | ForEach-Object { $_.FullName }
Remove-SafeItems -Paths $paths -Description "Conda temp files"
}
}
}
# Jupyter runtime
$jupyterRuntimePath = "$env:APPDATA\jupyter\runtime"
if (Test-Path $jupyterRuntimePath) {
Clear-DirectoryContents -Path $jupyterRuntimePath -Description "Jupyter runtime"
}
# pytest cache
$pytestCachePath = "$env:USERPROFILE\.pytest_cache"
if (Test-Path $pytestCachePath) {
Remove-SafeItem -Path $pytestCachePath -Description "pytest cache" -Recurse
}
}
# ============================================================================
# .NET / C# Ecosystem
# ============================================================================
function Clear-DotNetDevCaches {
<#
.SYNOPSIS
Clean .NET development caches
#>
# NuGet cache
$nugetCachePath = "$env:USERPROFILE\.nuget\packages"
# Don't clean packages by default - they're needed for builds
# Only clean http-cache and temp
$nugetHttpCache = "$env:LOCALAPPDATA\NuGet\v3-cache"
if (Test-Path $nugetHttpCache) {
Clear-DirectoryContents -Path $nugetHttpCache -Description "NuGet HTTP cache"
}
$nugetTempPath = "$env:LOCALAPPDATA\NuGet\plugins-cache"
if (Test-Path $nugetTempPath) {
Clear-DirectoryContents -Path $nugetTempPath -Description "NuGet plugins cache"
}
# MSBuild temp files
$msbuildTemp = "$env:LOCALAPPDATA\Microsoft\MSBuild"
if (Test-Path $msbuildTemp) {
$tempDirs = Get-ChildItem -Path $msbuildTemp -Directory -Filter "*temp*" -ErrorAction SilentlyContinue
foreach ($dir in $tempDirs) {
Clear-DirectoryContents -Path $dir.FullName -Description "MSBuild temp"
}
}
}
# ============================================================================
# Go Ecosystem
# ============================================================================
function Clear-GoCaches {
<#
.SYNOPSIS
Clean Go build and module caches
#>
if (Get-Command go -ErrorAction SilentlyContinue) {
if (Test-DryRunMode) {
Write-DryRun "Go cache"
}
else {
try {
$null = go clean -cache 2>&1
Write-Success "Go build cache"
Set-SectionActivity
}
catch {
Write-Debug "go clean -cache failed: $_"
}
}
}
# Go module cache
$goModCachePath = "$env:GOPATH\pkg\mod\cache"
if (-not $env:GOPATH) {
$goModCachePath = "$env:USERPROFILE\go\pkg\mod\cache"
}
if (Test-Path $goModCachePath) {
Clear-DirectoryContents -Path $goModCachePath -Description "Go module cache"
}
}
# ============================================================================
# Rust Ecosystem
# ============================================================================
function Clear-RustCaches {
<#
.SYNOPSIS
Clean Rust/Cargo caches
#>
# Cargo registry cache
$cargoRegistryCache = "$env:USERPROFILE\.cargo\registry\cache"
if (Test-Path $cargoRegistryCache) {
Clear-DirectoryContents -Path $cargoRegistryCache -Description "Cargo registry cache"
}
# Cargo git cache
$cargoGitCache = "$env:USERPROFILE\.cargo\git\checkouts"
if (Test-Path $cargoGitCache) {
Clear-DirectoryContents -Path $cargoGitCache -Description "Cargo git cache"
}
# Rustup downloads
$rustupDownloads = "$env:USERPROFILE\.rustup\downloads"
if (Test-Path $rustupDownloads) {
Clear-DirectoryContents -Path $rustupDownloads -Description "Rustup downloads"
}
}
# ============================================================================
# Java / JVM Ecosystem
# ============================================================================
function Clear-JvmCaches {
<#
.SYNOPSIS
Clean JVM ecosystem caches (Gradle, Maven, etc.)
#>
# Gradle caches
$gradleCachePaths = @(
"$env:USERPROFILE\.gradle\caches"
"$env:USERPROFILE\.gradle\daemon"
"$env:USERPROFILE\.gradle\wrapper\dists"
)
foreach ($path in $gradleCachePaths) {
if (Test-Path $path) {
# Only clean temp and old daemon logs
$tempFiles = Get-ChildItem -Path $path -Recurse -Filter "*.lock" -ErrorAction SilentlyContinue
if ($tempFiles) {
$paths = $tempFiles | ForEach-Object { $_.FullName }
Remove-SafeItems -Paths $paths -Description "Gradle lock files"
}
}
}
# Maven repository (only clean temp files)
$mavenRepoPath = "$env:USERPROFILE\.m2\repository"
if (Test-Path $mavenRepoPath) {
$tempFiles = Get-ChildItem -Path $mavenRepoPath -Recurse -Filter "*.lastUpdated" -ErrorAction SilentlyContinue
if ($tempFiles) {
$paths = $tempFiles | ForEach-Object { $_.FullName }
Remove-SafeItems -Paths $paths -Description "Maven update markers"
}
}
}
# ============================================================================
# Docker / Containers
# ============================================================================
function Clear-DockerCaches {
<#
.SYNOPSIS
Clean Docker build caches and unused data
#>
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
return
}
# Check if Docker daemon is running
$dockerRunning = $false
try {
$null = docker info 2>&1
$dockerRunning = $true
}
catch {
Write-Debug "Docker daemon not running"
}
if ($dockerRunning) {
if (Test-DryRunMode) {
Write-DryRun "Docker build cache"
}
else {
try {
$null = docker builder prune -af 2>&1
Write-Success "Docker build cache"
Set-SectionActivity
}
catch {
Write-Debug "docker builder prune failed: $_"
}
}
}
# Docker Desktop cache (Windows)
$dockerDesktopCache = "$env:LOCALAPPDATA\Docker\wsl\data"
# Note: Don't clean this - it's the WSL2 virtual disk
}
# ============================================================================
# Cloud CLI Tools
# ============================================================================
function Clear-CloudCliCaches {
<#
.SYNOPSIS
Clean cloud CLI tool caches (AWS, Azure, GCP)
#>
# AWS CLI cache
$awsCachePath = "$env:USERPROFILE\.aws\cli\cache"
if (Test-Path $awsCachePath) {
Clear-DirectoryContents -Path $awsCachePath -Description "AWS CLI cache"
}
# Azure CLI logs
$azureLogsPath = "$env:USERPROFILE\.azure\logs"
if (Test-Path $azureLogsPath) {
Clear-DirectoryContents -Path $azureLogsPath -Description "Azure CLI logs"
}
# Google Cloud logs
$gcloudLogsPath = "$env:APPDATA\gcloud\logs"
if (Test-Path $gcloudLogsPath) {
Clear-DirectoryContents -Path $gcloudLogsPath -Description "gcloud logs"
}
# Kubernetes cache
$kubeCachePath = "$env:USERPROFILE\.kube\cache"
if (Test-Path $kubeCachePath) {
Clear-DirectoryContents -Path $kubeCachePath -Description "Kubernetes cache"
}
# Terraform plugin cache
$terraformCachePath = "$env:APPDATA\terraform.d\plugin-cache"
if (Test-Path $terraformCachePath) {
Clear-DirectoryContents -Path $terraformCachePath -Description "Terraform plugin cache"
}
}
# ============================================================================
# IDE Caches
# ============================================================================
function Clear-IdeCaches {
<#
.SYNOPSIS
Clean IDE caches (VS, VSCode, JetBrains, etc.)
#>
# Visual Studio cache
$vsCachePaths = @(
"$env:LOCALAPPDATA\Microsoft\VisualStudio\*\ComponentModelCache"
"$env:LOCALAPPDATA\Microsoft\VisualStudio\*\ImageCache"
)
foreach ($pattern in $vsCachePaths) {
$paths = Resolve-Path $pattern -ErrorAction SilentlyContinue
foreach ($path in $paths) {
if (Test-Path $path.Path) {
Clear-DirectoryContents -Path $path.Path -Description "Visual Studio cache"
}
}
}
# JetBrains IDEs caches
$jetbrainsBasePaths = @(
"$env:LOCALAPPDATA\JetBrains"
"$env:APPDATA\JetBrains"
)
foreach ($basePath in $jetbrainsBasePaths) {
if (Test-Path $basePath) {
$ideFolders = Get-ChildItem -Path $basePath -Directory -ErrorAction SilentlyContinue
foreach ($ideFolder in $ideFolders) {
$cacheFolders = @("caches", "index", "tmp")
foreach ($cacheFolder in $cacheFolders) {
$cachePath = Join-Path $ideFolder.FullName $cacheFolder
if (Test-Path $cachePath) {
Clear-DirectoryContents -Path $cachePath -Description "$($ideFolder.Name) $cacheFolder"
}
}
}
}
}
}
# ============================================================================
# Git Caches
# ============================================================================
function Clear-GitCaches {
<#
.SYNOPSIS
Clean Git temporary files and lock files
#>
# Git config locks (stale)
$gitConfigLock = "$env:USERPROFILE\.gitconfig.lock"
if (Test-Path $gitConfigLock) {
Remove-SafeItem -Path $gitConfigLock -Description "Git config lock"
}
# GitHub CLI cache
$ghCachePath = "$env:APPDATA\GitHub CLI"
if (Test-Path $ghCachePath) {
$cacheFiles = Get-ChildItem -Path $ghCachePath -Filter "*.json" -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-7) }
if ($cacheFiles) {
$paths = $cacheFiles | ForEach-Object { $_.FullName }
Remove-SafeItems -Paths $paths -Description "GitHub CLI cache"
}
}
}
# ============================================================================
# Main Developer Tools Cleanup Function
# ============================================================================
function Invoke-DevToolsCleanup {
<#
.SYNOPSIS
Run all developer tools cleanup tasks
#>
Start-Section "Developer tools"
# JavaScript ecosystem
Clear-NpmCache
Clear-NodeBuildCaches
# Python ecosystem
Clear-PythonCaches
# .NET ecosystem
Clear-DotNetDevCaches
# Go ecosystem
Clear-GoCaches
# Rust ecosystem
Clear-RustCaches
# JVM ecosystem
Clear-JvmCaches
# Containers
Clear-DockerCaches
# Cloud CLI tools
Clear-CloudCliCaches
# IDEs
Clear-IdeCaches
# Git
Clear-GitCaches
Stop-Section
}
# ============================================================================
# Exports
# ============================================================================
# Functions: Clear-NpmCache, Clear-PythonCaches, Clear-DockerCaches, etc.

View File

@@ -1,296 +0,0 @@
#!/bin/bash
# Developer Tools Cleanup Module
set -euo pipefail
# Tool cache helper (respects DRY_RUN).
clean_tool_cache() {
local description="$1"
shift
if [[ "$DRY_RUN" != "true" ]]; then
if "$@" > /dev/null 2>&1; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $description"
fi
else
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $description · would clean"
fi
return 0
}
# npm/pnpm/yarn/bun caches.
clean_dev_npm() {
if command -v npm > /dev/null 2>&1; then
clean_tool_cache "npm cache" npm cache clean --force
note_activity
fi
# Clean pnpm store cache
local pnpm_default_store=~/Library/pnpm/store
# Check if pnpm is actually usable (not just Corepack shim)
if command -v pnpm > /dev/null 2>&1 && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 pnpm --version > /dev/null 2>&1; then
COREPACK_ENABLE_DOWNLOAD_PROMPT=0 clean_tool_cache "pnpm cache" pnpm store prune
local pnpm_store_path
start_section_spinner "Checking store path..."
pnpm_store_path=$(COREPACK_ENABLE_DOWNLOAD_PROMPT=0 run_with_timeout 2 pnpm store path 2> /dev/null) || pnpm_store_path=""
stop_section_spinner
if [[ -n "$pnpm_store_path" && "$pnpm_store_path" != "$pnpm_default_store" ]]; then
safe_clean "$pnpm_default_store"/* "Orphaned pnpm store"
fi
else
# pnpm not installed or not usable, just clean the default store directory
safe_clean "$pnpm_default_store"/* "pnpm store"
fi
note_activity
safe_clean ~/.tnpm/_cacache/* "tnpm cache directory"
safe_clean ~/.tnpm/_logs/* "tnpm logs"
safe_clean ~/.yarn/cache/* "Yarn cache"
safe_clean ~/.bun/install/cache/* "Bun cache"
}
# Python/pip ecosystem caches.
clean_dev_python() {
if command -v pip3 > /dev/null 2>&1; then
clean_tool_cache "pip cache" bash -c 'pip3 cache purge >/dev/null 2>&1 || true'
note_activity
fi
safe_clean ~/.pyenv/cache/* "pyenv cache"
safe_clean ~/.cache/poetry/* "Poetry cache"
safe_clean ~/.cache/uv/* "uv cache"
safe_clean ~/.cache/ruff/* "Ruff cache"
safe_clean ~/.cache/mypy/* "MyPy cache"
safe_clean ~/.pytest_cache/* "Pytest cache"
safe_clean ~/.jupyter/runtime/* "Jupyter runtime cache"
safe_clean ~/.cache/huggingface/* "Hugging Face cache"
safe_clean ~/.cache/torch/* "PyTorch cache"
safe_clean ~/.cache/tensorflow/* "TensorFlow cache"
safe_clean ~/.conda/pkgs/* "Conda packages cache"
safe_clean ~/anaconda3/pkgs/* "Anaconda packages cache"
safe_clean ~/.cache/wandb/* "Weights & Biases cache"
}
# Go build/module caches.
clean_dev_go() {
if command -v go > /dev/null 2>&1; then
clean_tool_cache "Go cache" bash -c 'go clean -modcache >/dev/null 2>&1 || true; go clean -cache >/dev/null 2>&1 || true'
note_activity
fi
}
# Rust/cargo caches.
clean_dev_rust() {
safe_clean ~/.cargo/registry/cache/* "Rust cargo cache"
safe_clean ~/.cargo/git/* "Cargo git cache"
safe_clean ~/.rustup/downloads/* "Rust downloads cache"
}
# Docker caches (guarded by daemon check).
clean_dev_docker() {
if command -v docker > /dev/null 2>&1; then
if [[ "$DRY_RUN" != "true" ]]; then
start_section_spinner "Checking Docker daemon..."
local docker_running=false
if run_with_timeout 3 docker info > /dev/null 2>&1; then
docker_running=true
fi
stop_section_spinner
if [[ "$docker_running" == "true" ]]; then
clean_tool_cache "Docker build cache" docker builder prune -af
else
debug_log "Docker daemon not running, skipping Docker cache cleanup"
fi
else
note_activity
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Docker build cache · would clean"
fi
fi
safe_clean ~/.docker/buildx/cache/* "Docker BuildX cache"
}
# Nix garbage collection.
clean_dev_nix() {
if command -v nix-collect-garbage > /dev/null 2>&1; then
if [[ "$DRY_RUN" != "true" ]]; then
clean_tool_cache "Nix garbage collection" nix-collect-garbage --delete-older-than 30d
else
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Nix garbage collection · would clean"
fi
note_activity
fi
}
# Cloud CLI caches.
clean_dev_cloud() {
safe_clean ~/.kube/cache/* "Kubernetes cache"
safe_clean ~/.local/share/containers/storage/tmp/* "Container storage temp"
safe_clean ~/.aws/cli/cache/* "AWS CLI cache"
safe_clean ~/.config/gcloud/logs/* "Google Cloud logs"
safe_clean ~/.azure/logs/* "Azure CLI logs"
}
# Frontend build caches.
clean_dev_frontend() {
safe_clean ~/.cache/typescript/* "TypeScript cache"
safe_clean ~/.cache/electron/* "Electron cache"
safe_clean ~/.cache/node-gyp/* "node-gyp cache"
safe_clean ~/.node-gyp/* "node-gyp build cache"
safe_clean ~/.turbo/cache/* "Turbo cache"
safe_clean ~/.vite/cache/* "Vite cache"
safe_clean ~/.cache/vite/* "Vite global cache"
safe_clean ~/.cache/webpack/* "Webpack cache"
safe_clean ~/.parcel-cache/* "Parcel cache"
safe_clean ~/.cache/eslint/* "ESLint cache"
safe_clean ~/.cache/prettier/* "Prettier cache"
}
# Mobile dev caches (can be large).
# Check for multiple Android NDK versions.
check_android_ndk() {
local ndk_dir="$HOME/Library/Android/sdk/ndk"
if [[ -d "$ndk_dir" ]]; then
local count
count=$(find "$ndk_dir" -mindepth 1 -maxdepth 1 -type d 2> /dev/null | wc -l | tr -d ' ')
if [[ "$count" -gt 1 ]]; then
note_activity
echo -e " Found ${GREEN}${count}${NC} Android NDK versions"
echo -e " You can delete unused versions manually: ${ndk_dir}"
fi
fi
}
clean_dev_mobile() {
check_android_ndk
if command -v xcrun > /dev/null 2>&1; then
debug_log "Checking for unavailable Xcode simulators"
if [[ "$DRY_RUN" == "true" ]]; then
clean_tool_cache "Xcode unavailable simulators" xcrun simctl delete unavailable
else
start_section_spinner "Checking unavailable simulators..."
if xcrun simctl delete unavailable > /dev/null 2>&1; then
stop_section_spinner
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators"
else
stop_section_spinner
fi
fi
note_activity
fi
# DeviceSupport caches/logs (preserve core support files).
safe_clean ~/Library/Developer/Xcode/iOS\ DeviceSupport/*/Symbols/System/Library/Caches/* "iOS device symbol cache"
safe_clean ~/Library/Developer/Xcode/iOS\ DeviceSupport/*.log "iOS device support logs"
safe_clean ~/Library/Developer/Xcode/watchOS\ DeviceSupport/*/Symbols/System/Library/Caches/* "watchOS device symbol cache"
safe_clean ~/Library/Developer/Xcode/tvOS\ DeviceSupport/*/Symbols/System/Library/Caches/* "tvOS device symbol cache"
# Simulator runtime caches.
safe_clean ~/Library/Developer/CoreSimulator/Profiles/Runtimes/*/Contents/Resources/RuntimeRoot/System/Library/Caches/* "Simulator runtime cache"
safe_clean ~/Library/Caches/Google/AndroidStudio*/* "Android Studio cache"
safe_clean ~/Library/Caches/CocoaPods/* "CocoaPods cache"
safe_clean ~/.cache/flutter/* "Flutter cache"
safe_clean ~/.android/build-cache/* "Android build cache"
safe_clean ~/.android/cache/* "Android SDK cache"
safe_clean ~/Library/Developer/Xcode/UserData/IB\ Support/* "Xcode Interface Builder cache"
safe_clean ~/.cache/swift-package-manager/* "Swift package manager cache"
}
# JVM ecosystem caches.
clean_dev_jvm() {
safe_clean ~/.gradle/caches/* "Gradle caches"
safe_clean ~/.gradle/daemon/* "Gradle daemon logs"
safe_clean ~/.sbt/* "SBT cache"
safe_clean ~/.ivy2/cache/* "Ivy cache"
}
# Other language tool caches.
clean_dev_other_langs() {
safe_clean ~/.bundle/cache/* "Ruby Bundler cache"
safe_clean ~/.composer/cache/* "PHP Composer cache"
safe_clean ~/.nuget/packages/* "NuGet packages cache"
safe_clean ~/.pub-cache/* "Dart Pub cache"
safe_clean ~/.cache/bazel/* "Bazel cache"
safe_clean ~/.cache/zig/* "Zig cache"
safe_clean ~/Library/Caches/deno/* "Deno cache"
}
# CI/CD and DevOps caches.
clean_dev_cicd() {
safe_clean ~/.cache/terraform/* "Terraform cache"
safe_clean ~/.grafana/cache/* "Grafana cache"
safe_clean ~/.prometheus/data/wal/* "Prometheus WAL cache"
safe_clean ~/.jenkins/workspace/*/target/* "Jenkins workspace cache"
safe_clean ~/.cache/gitlab-runner/* "GitLab Runner cache"
safe_clean ~/.github/cache/* "GitHub Actions cache"
safe_clean ~/.circleci/cache/* "CircleCI cache"
safe_clean ~/.sonar/* "SonarQube cache"
}
# Database tool caches.
clean_dev_database() {
safe_clean ~/Library/Caches/com.sequel-ace.sequel-ace/* "Sequel Ace cache"
safe_clean ~/Library/Caches/com.eggerapps.Sequel-Pro/* "Sequel Pro cache"
safe_clean ~/Library/Caches/redis-desktop-manager/* "Redis Desktop Manager cache"
safe_clean ~/Library/Caches/com.navicat.* "Navicat cache"
safe_clean ~/Library/Caches/com.dbeaver.* "DBeaver cache"
safe_clean ~/Library/Caches/com.redis.RedisInsight "Redis Insight cache"
}
# API/debugging tool caches.
clean_dev_api_tools() {
safe_clean ~/Library/Caches/com.postmanlabs.mac/* "Postman cache"
safe_clean ~/Library/Caches/com.konghq.insomnia/* "Insomnia cache"
safe_clean ~/Library/Caches/com.tinyapp.TablePlus/* "TablePlus cache"
safe_clean ~/Library/Caches/com.getpaw.Paw/* "Paw API cache"
safe_clean ~/Library/Caches/com.charlesproxy.charles/* "Charles Proxy cache"
safe_clean ~/Library/Caches/com.proxyman.NSProxy/* "Proxyman cache"
}
# Misc dev tool caches.
clean_dev_misc() {
safe_clean ~/Library/Caches/com.unity3d.*/* "Unity cache"
safe_clean ~/Library/Caches/com.mongodb.compass/* "MongoDB Compass cache"
safe_clean ~/Library/Caches/com.figma.Desktop/* "Figma cache"
safe_clean ~/Library/Caches/com.github.GitHubDesktop/* "GitHub Desktop cache"
safe_clean ~/Library/Caches/SentryCrash/* "Sentry crash reports"
safe_clean ~/Library/Caches/KSCrash/* "KSCrash reports"
safe_clean ~/Library/Caches/com.crashlytics.data/* "Crashlytics data"
}
# Shell and VCS leftovers.
clean_dev_shell() {
safe_clean ~/.gitconfig.lock "Git config lock"
safe_clean ~/.gitconfig.bak* "Git config backup"
safe_clean ~/.oh-my-zsh/cache/* "Oh My Zsh cache"
safe_clean ~/.config/fish/fish_history.bak* "Fish shell backup"
safe_clean ~/.bash_history.bak* "Bash history backup"
safe_clean ~/.zsh_history.bak* "Zsh history backup"
safe_clean ~/.cache/pre-commit/* "pre-commit cache"
}
# Network tool caches.
clean_dev_network() {
safe_clean ~/.cache/curl/* "curl cache"
safe_clean ~/.cache/wget/* "wget cache"
safe_clean ~/Library/Caches/curl/* "macOS curl cache"
safe_clean ~/Library/Caches/wget/* "macOS wget cache"
}
# Orphaned SQLite temp files (-shm/-wal). Disabled due to low ROI.
clean_sqlite_temp_files() {
return 0
}
# Main developer tools cleanup sequence.
clean_developer_tools() {
stop_section_spinner
clean_sqlite_temp_files
clean_dev_npm
clean_dev_python
clean_dev_go
clean_dev_rust
clean_dev_docker
clean_dev_cloud
clean_dev_nix
clean_dev_shell
clean_dev_frontend
clean_project_caches
clean_dev_mobile
clean_dev_jvm
clean_dev_other_langs
clean_dev_cicd
clean_dev_database
clean_dev_api_tools
clean_dev_network
clean_dev_misc
safe_clean ~/Library/Caches/Homebrew/* "Homebrew cache"
# Clean Homebrew locks without repeated sudo prompts.
local brew_lock_dirs=(
"/opt/homebrew/var/homebrew/locks"
"/usr/local/var/homebrew/locks"
)
for lock_dir in "${brew_lock_dirs[@]}"; do
if [[ -d "$lock_dir" && -w "$lock_dir" ]]; then
safe_clean "$lock_dir"/* "Homebrew lock files"
elif [[ -d "$lock_dir" ]]; then
if find "$lock_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then
debug_log "Skipping read-only Homebrew locks in $lock_dir"
fi
fi
done
clean_homebrew
}

View File

@@ -1,925 +0,0 @@
#!/bin/bash
# Project Purge Module (mo purge).
# Removes heavy project build artifacts and dependencies.
set -euo pipefail
PROJECT_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CORE_LIB_DIR="$(cd "$PROJECT_LIB_DIR/../core" && pwd)"
if ! command -v ensure_user_dir > /dev/null 2>&1; then
# shellcheck disable=SC1090
source "$CORE_LIB_DIR/common.sh"
fi
# Targets to look for (heavy build artifacts).
readonly PURGE_TARGETS=(
"node_modules"
"target" # Rust, Maven
"build" # Gradle, various
"dist" # JS builds
"venv" # Python
".venv" # Python
".pytest_cache" # Python (pytest)
".mypy_cache" # Python (mypy)
".tox" # Python (tox virtualenvs)
".nox" # Python (nox virtualenvs)
".ruff_cache" # Python (ruff)
".gradle" # Gradle local
"__pycache__" # Python
".next" # Next.js
".nuxt" # Nuxt.js
".output" # Nuxt.js
"vendor" # PHP Composer
"bin" # .NET build output (guarded; see is_protected_purge_artifact)
"obj" # C# / Unity
".turbo" # Turborepo cache
".parcel-cache" # Parcel bundler
".dart_tool" # Flutter/Dart build cache
".zig-cache" # Zig
"zig-out" # Zig
".angular" # Angular
".svelte-kit" # SvelteKit
".astro" # Astro
"coverage" # Code coverage reports
)
# Minimum age in days before considering for cleanup.
readonly MIN_AGE_DAYS=7
# Scan depth defaults (relative to search root).
readonly PURGE_MIN_DEPTH_DEFAULT=2
readonly PURGE_MAX_DEPTH_DEFAULT=8
# Search paths (default, can be overridden via config file).
readonly DEFAULT_PURGE_SEARCH_PATHS=(
"$HOME/www"
"$HOME/dev"
"$HOME/Projects"
"$HOME/GitHub"
"$HOME/Code"
"$HOME/Workspace"
"$HOME/Repos"
"$HOME/Development"
)
# Config file for custom purge paths.
readonly PURGE_CONFIG_FILE="$HOME/.config/mole/purge_paths"
# Resolved search paths.
PURGE_SEARCH_PATHS=()
# Project indicators for container detection.
readonly PROJECT_INDICATORS=(
"package.json"
"Cargo.toml"
"go.mod"
"pyproject.toml"
"requirements.txt"
"pom.xml"
"build.gradle"
"Gemfile"
"composer.json"
"pubspec.yaml"
"Makefile"
"build.zig"
"build.zig.zon"
".git"
)
# Check if a directory contains projects (directly or in subdirectories).
is_project_container() {
local dir="$1"
local max_depth="${2:-2}"
# Skip hidden/system directories.
local basename
basename=$(basename "$dir")
[[ "$basename" == .* ]] && return 1
[[ "$basename" == "Library" ]] && return 1
[[ "$basename" == "Applications" ]] && return 1
[[ "$basename" == "Movies" ]] && return 1
[[ "$basename" == "Music" ]] && return 1
[[ "$basename" == "Pictures" ]] && return 1
[[ "$basename" == "Public" ]] && return 1
# Single find expression for indicators.
local -a find_args=("$dir" "-maxdepth" "$max_depth" "(")
local first=true
for indicator in "${PROJECT_INDICATORS[@]}"; do
if [[ "$first" == "true" ]]; then
first=false
else
find_args+=("-o")
fi
find_args+=("-name" "$indicator")
done
find_args+=(")" "-print" "-quit")
if find "${find_args[@]}" 2> /dev/null | grep -q .; then
return 0
fi
return 1
}
# Discover project directories in $HOME.
discover_project_dirs() {
local -a discovered=()
for path in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do
if [[ -d "$path" ]]; then
discovered+=("$path")
fi
done
# Scan $HOME for other containers (depth 1).
local dir
for dir in "$HOME"/*/; do
[[ ! -d "$dir" ]] && continue
dir="${dir%/}" # Remove trailing slash
local already_found=false
for existing in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do
if [[ "$dir" == "$existing" ]]; then
already_found=true
break
fi
done
[[ "$already_found" == "true" ]] && continue
if is_project_container "$dir" 2; then
discovered+=("$dir")
fi
done
printf '%s\n' "${discovered[@]}" | sort -u
}
# Save discovered paths to config.
save_discovered_paths() {
local -a paths=("$@")
ensure_user_dir "$(dirname "$PURGE_CONFIG_FILE")"
cat > "$PURGE_CONFIG_FILE" << 'EOF'
# Mole Purge Paths - Auto-discovered project directories
# Edit this file to customize, or run: mo purge --paths
# Add one path per line (supports ~ for home directory)
EOF
printf '\n' >> "$PURGE_CONFIG_FILE"
for path in "${paths[@]}"; do
# Convert $HOME to ~ for portability
path="${path/#$HOME/~}"
echo "$path" >> "$PURGE_CONFIG_FILE"
done
}
# Load purge paths from config or auto-discover
load_purge_config() {
PURGE_SEARCH_PATHS=()
if [[ -f "$PURGE_CONFIG_FILE" ]]; then
while IFS= read -r line; do
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" || "$line" =~ ^# ]] && continue
line="${line/#\~/$HOME}"
PURGE_SEARCH_PATHS+=("$line")
done < "$PURGE_CONFIG_FILE"
fi
if [[ ${#PURGE_SEARCH_PATHS[@]} -eq 0 ]]; then
if [[ -t 1 ]] && [[ -z "${_PURGE_DISCOVERY_SILENT:-}" ]]; then
echo -e "${GRAY}First run: discovering project directories...${NC}" >&2
fi
local -a discovered=()
while IFS= read -r path; do
[[ -n "$path" ]] && discovered+=("$path")
done < <(discover_project_dirs)
if [[ ${#discovered[@]} -gt 0 ]]; then
PURGE_SEARCH_PATHS=("${discovered[@]}")
save_discovered_paths "${discovered[@]}"
if [[ -t 1 ]] && [[ -z "${_PURGE_DISCOVERY_SILENT:-}" ]]; then
echo -e "${GRAY}Found ${#discovered[@]} project directories, saved to config${NC}" >&2
fi
else
PURGE_SEARCH_PATHS=("${DEFAULT_PURGE_SEARCH_PATHS[@]}")
fi
fi
}
# Initialize paths on script load.
load_purge_config
# Args: $1 - path to check
# Safe cleanup requires the path be inside a project directory.
is_safe_project_artifact() {
local path="$1"
local search_path="$2"
if [[ "$path" != /* ]]; then
return 1
fi
# Must not be a direct child of the search root.
local relative_path="${path#"$search_path"/}"
local depth=$(echo "$relative_path" | LC_ALL=C tr -cd '/' | wc -c)
if [[ $depth -lt 1 ]]; then
return 1
fi
return 0
}
# Detect if directory is a Rails project root
is_rails_project_root() {
local dir="$1"
[[ -f "$dir/config/application.rb" ]] || return 1
[[ -f "$dir/Gemfile" ]] || return 1
[[ -f "$dir/bin/rails" || -f "$dir/config/environment.rb" ]]
}
# Detect if directory is a Go project root
is_go_project_root() {
local dir="$1"
[[ -f "$dir/go.mod" ]]
}
# Detect if directory is a PHP Composer project root
is_php_project_root() {
local dir="$1"
[[ -f "$dir/composer.json" ]]
}
# Decide whether a "bin" directory is a .NET directory
is_dotnet_bin_dir() {
local path="$1"
[[ "$(basename "$path")" == "bin" ]] || return 1
# Check if parent directory has a .csproj/.fsproj/.vbproj file
local parent_dir
parent_dir="$(dirname "$path")"
find "$parent_dir" -maxdepth 1 \( -name "*.csproj" -o -name "*.fsproj" -o -name "*.vbproj" \) 2> /dev/null | grep -q . || return 1
# Check if bin directory contains Debug/ or Release/ subdirectories
[[ -d "$path/Debug" || -d "$path/Release" ]] || return 1
return 0
}
# Check if a vendor directory should be protected from purge
# Expects path to be a vendor directory (basename == vendor)
# Strategy: Only clean PHP Composer vendor, protect all others
is_protected_vendor_dir() {
local path="$1"
local base
base=$(basename "$path")
[[ "$base" == "vendor" ]] || return 1
local parent_dir
parent_dir=$(dirname "$path")
# PHP Composer vendor can be safely regenerated with 'composer install'
# Do NOT protect it (return 1 = not protected = can be cleaned)
if is_php_project_root "$parent_dir"; then
return 1
fi
# Rails vendor (importmap dependencies) - should be protected
if is_rails_project_root "$parent_dir"; then
return 0
fi
# Go vendor (optional vendoring) - protect to avoid accidental deletion
if is_go_project_root "$parent_dir"; then
return 0
fi
# Unknown vendor type - protect by default (conservative approach)
return 0
}
# Check if an artifact should be protected from purge
is_protected_purge_artifact() {
local path="$1"
local base
base=$(basename "$path")
case "$base" in
bin)
# Only allow purging bin/ when we can detect .NET context.
if is_dotnet_bin_dir "$path"; then
return 1
fi
return 0
;;
vendor)
is_protected_vendor_dir "$path"
return $?
;;
esac
return 1
}
# Scan purge targets using fd (fast) or pruned find.
scan_purge_targets() {
local search_path="$1"
local output_file="$2"
local min_depth="$PURGE_MIN_DEPTH_DEFAULT"
local max_depth="$PURGE_MAX_DEPTH_DEFAULT"
if [[ ! "$min_depth" =~ ^[0-9]+$ ]]; then
min_depth="$PURGE_MIN_DEPTH_DEFAULT"
fi
if [[ ! "$max_depth" =~ ^[0-9]+$ ]]; then
max_depth="$PURGE_MAX_DEPTH_DEFAULT"
fi
if [[ "$max_depth" -lt "$min_depth" ]]; then
max_depth="$min_depth"
fi
if [[ ! -d "$search_path" ]]; then
return
fi
if command -v fd > /dev/null 2>&1; then
# Escape regex special characters in target names for fd patterns
local escaped_targets=()
for target in "${PURGE_TARGETS[@]}"; do
escaped_targets+=("$(printf '%s' "$target" | sed -e 's/[][(){}.^$*+?|\\]/\\&/g')")
done
local pattern="($(
IFS='|'
echo "${escaped_targets[*]}"
))"
local fd_args=(
"--absolute-path"
"--hidden"
"--no-ignore"
"--type" "d"
"--min-depth" "$min_depth"
"--max-depth" "$max_depth"
"--threads" "4"
"--exclude" ".git"
"--exclude" "Library"
"--exclude" ".Trash"
"--exclude" "Applications"
)
fd "${fd_args[@]}" "$pattern" "$search_path" 2> /dev/null | while IFS= read -r item; do
if is_safe_project_artifact "$item" "$search_path"; then
echo "$item"
fi
done | filter_nested_artifacts | filter_protected_artifacts > "$output_file"
else
# Pruned find avoids descending into heavy directories.
local prune_args=()
local prune_dirs=(".git" "Library" ".Trash" "Applications")
for dir in "${prune_dirs[@]}"; do
prune_args+=("-name" "$dir" "-prune" "-o")
done
for target in "${PURGE_TARGETS[@]}"; do
prune_args+=("-name" "$target" "-print" "-prune" "-o")
done
local find_expr=()
for dir in "${prune_dirs[@]}"; do
find_expr+=("-name" "$dir" "-prune" "-o")
done
local i=0
for target in "${PURGE_TARGETS[@]}"; do
find_expr+=("-name" "$target" "-print" "-prune")
if [[ $i -lt $((${#PURGE_TARGETS[@]} - 1)) ]]; then
find_expr+=("-o")
fi
((i++))
done
command find "$search_path" -mindepth "$min_depth" -maxdepth "$max_depth" -type d \
\( "${find_expr[@]}" \) 2> /dev/null | while IFS= read -r item; do
if is_safe_project_artifact "$item" "$search_path"; then
echo "$item"
fi
done | filter_nested_artifacts | filter_protected_artifacts > "$output_file"
fi
}
# Filter out nested artifacts (e.g. node_modules inside node_modules).
filter_nested_artifacts() {
while IFS= read -r item; do
local parent_dir=$(dirname "$item")
local is_nested=false
for target in "${PURGE_TARGETS[@]}"; do
if [[ "$parent_dir" == *"/$target/"* || "$parent_dir" == *"/$target" ]]; then
is_nested=true
break
fi
done
if [[ "$is_nested" == "false" ]]; then
echo "$item"
fi
done
}
filter_protected_artifacts() {
while IFS= read -r item; do
if ! is_protected_purge_artifact "$item"; then
echo "$item"
fi
done
}
# Args: $1 - path
# Check if a path was modified recently (safety check).
is_recently_modified() {
local path="$1"
local age_days=$MIN_AGE_DAYS
if [[ ! -e "$path" ]]; then
return 1
fi
local mod_time
mod_time=$(get_file_mtime "$path")
local current_time
current_time=$(get_epoch_seconds)
local age_seconds=$((current_time - mod_time))
local age_in_days=$((age_seconds / 86400))
if [[ $age_in_days -lt $age_days ]]; then
return 0 # Recently modified
else
return 1 # Old enough to clean
fi
}
# Args: $1 - path
# Get directory size in KB.
get_dir_size_kb() {
local path="$1"
if [[ -d "$path" ]]; then
du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0"
else
echo "0"
fi
}
# Purge category selector.
select_purge_categories() {
local -a categories=("$@")
local total_items=${#categories[@]}
local clear_line=$'\r\033[2K'
if [[ $total_items -eq 0 ]]; then
return 1
fi
# Calculate items per page based on terminal height.
_get_items_per_page() {
local term_height=24
if [[ -t 0 ]] || [[ -t 2 ]]; then
term_height=$(stty size < /dev/tty 2> /dev/null | awk '{print $1}')
fi
if [[ -z "$term_height" || $term_height -le 0 ]]; then
if command -v tput > /dev/null 2>&1; then
term_height=$(tput lines 2> /dev/null || echo "24")
else
term_height=24
fi
fi
local reserved=6
local available=$((term_height - reserved))
if [[ $available -lt 3 ]]; then
echo 3
elif [[ $available -gt 50 ]]; then
echo 50
else
echo "$available"
fi
}
local items_per_page=$(_get_items_per_page)
local cursor_pos=0
local top_index=0
# Initialize selection (all selected by default, except recent ones)
local -a selected=()
IFS=',' read -r -a recent_flags <<< "${PURGE_RECENT_CATEGORIES:-}"
for ((i = 0; i < total_items; i++)); do
# Default unselected if category has recent items
if [[ ${recent_flags[i]:-false} == "true" ]]; then
selected[i]=false
else
selected[i]=true
fi
done
local original_stty=""
if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then
original_stty=$(stty -g 2> /dev/null || echo "")
fi
# Terminal control functions
restore_terminal() {
trap - EXIT INT TERM
show_cursor
if [[ -n "${original_stty:-}" ]]; then
stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || true
fi
}
# shellcheck disable=SC2329
handle_interrupt() {
restore_terminal
exit 130
}
draw_menu() {
# Recalculate items_per_page dynamically to handle window resize
items_per_page=$(_get_items_per_page)
# Clamp pagination state to avoid cursor drifting out of view
local max_top_index=0
if [[ $total_items -gt $items_per_page ]]; then
max_top_index=$((total_items - items_per_page))
fi
if [[ $top_index -gt $max_top_index ]]; then
top_index=$max_top_index
fi
if [[ $top_index -lt 0 ]]; then
top_index=0
fi
local visible_count=$((total_items - top_index))
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
if [[ $cursor_pos -gt $((visible_count - 1)) ]]; then
cursor_pos=$((visible_count - 1))
fi
if [[ $cursor_pos -lt 0 ]]; then
cursor_pos=0
fi
printf "\033[H"
# Calculate total size of selected items for header
local selected_size=0
local selected_count=0
IFS=',' read -r -a sizes <<< "${PURGE_CATEGORY_SIZES:-}"
for ((i = 0; i < total_items; i++)); do
if [[ ${selected[i]} == true ]]; then
selected_size=$((selected_size + ${sizes[i]:-0}))
((selected_count++))
fi
done
local selected_gb
selected_gb=$(printf "%.1f" "$(echo "scale=2; $selected_size/1024/1024" | bc)")
# Show position indicator if scrolling is needed
local scroll_indicator=""
if [[ $total_items -gt $items_per_page ]]; then
local current_pos=$((top_index + cursor_pos + 1))
scroll_indicator=" ${GRAY}[${current_pos}/${total_items}]${NC}"
fi
printf "%s\n" "$clear_line"
printf "%s${PURPLE_BOLD}Select Categories to Clean${NC}%s ${GRAY}- ${selected_gb}GB ($selected_count selected)${NC}\n" "$clear_line" "$scroll_indicator"
printf "%s\n" "$clear_line"
IFS=',' read -r -a recent_flags <<< "${PURGE_RECENT_CATEGORIES:-}"
# Calculate visible range
local end_index=$((top_index + visible_count))
# Draw only visible items
for ((i = top_index; i < end_index; i++)); do
local checkbox="$ICON_EMPTY"
[[ ${selected[i]} == true ]] && checkbox="$ICON_SOLID"
local recent_marker=""
[[ ${recent_flags[i]:-false} == "true" ]] && recent_marker=" ${GRAY}| Recent${NC}"
local rel_pos=$((i - top_index))
if [[ $rel_pos -eq $cursor_pos ]]; then
printf "%s${CYAN}${ICON_ARROW} %s %s%s${NC}\n" "$clear_line" "$checkbox" "${categories[i]}" "$recent_marker"
else
printf "%s %s %s%s\n" "$clear_line" "$checkbox" "${categories[i]}" "$recent_marker"
fi
done
# Fill empty slots to clear previous content
local items_shown=$visible_count
for ((i = items_shown; i < items_per_page; i++)); do
printf "%s\n" "$clear_line"
done
printf "%s\n" "$clear_line"
printf "%s${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space Select | Enter Confirm | A All | I Invert | Q Quit${NC}\n" "$clear_line"
}
trap restore_terminal EXIT
trap handle_interrupt INT TERM
# Preserve interrupt character for Ctrl-C
stty -echo -icanon intr ^C 2> /dev/null || true
hide_cursor
if [[ -t 1 ]]; then
clear_screen
fi
# Main loop
while true; do
draw_menu
# Read key
IFS= read -r -s -n1 key || key=""
case "$key" in
$'\x1b')
# Arrow keys or ESC
# Read next 2 chars with timeout (bash 3.2 needs integer)
IFS= read -r -s -n1 -t 1 key2 || key2=""
if [[ "$key2" == "[" ]]; then
IFS= read -r -s -n1 -t 1 key3 || key3=""
case "$key3" in
A) # Up arrow
if [[ $cursor_pos -gt 0 ]]; then
((cursor_pos--))
elif [[ $top_index -gt 0 ]]; then
((top_index--))
fi
;;
B) # Down arrow
local absolute_index=$((top_index + cursor_pos))
local last_index=$((total_items - 1))
if [[ $absolute_index -lt $last_index ]]; then
local visible_count=$((total_items - top_index))
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then
((cursor_pos++))
elif [[ $((top_index + visible_count)) -lt $total_items ]]; then
((top_index++))
fi
fi
;;
esac
else
# ESC alone (no following chars)
restore_terminal
return 1
fi
;;
" ") # Space - toggle current item
local idx=$((top_index + cursor_pos))
if [[ ${selected[idx]} == true ]]; then
selected[idx]=false
else
selected[idx]=true
fi
;;
"a" | "A") # Select all
for ((i = 0; i < total_items; i++)); do
selected[i]=true
done
;;
"i" | "I") # Invert selection
for ((i = 0; i < total_items; i++)); do
if [[ ${selected[i]} == true ]]; then
selected[i]=false
else
selected[i]=true
fi
done
;;
"q" | "Q" | $'\x03') # Quit or Ctrl-C
restore_terminal
return 1
;;
"" | $'\n' | $'\r') # Enter - confirm
# Build result
PURGE_SELECTION_RESULT=""
for ((i = 0; i < total_items; i++)); do
if [[ ${selected[i]} == true ]]; then
[[ -n "$PURGE_SELECTION_RESULT" ]] && PURGE_SELECTION_RESULT+=","
PURGE_SELECTION_RESULT+="$i"
fi
done
restore_terminal
return 0
;;
esac
done
}
# Main cleanup function - scans and prompts user to select artifacts to clean
clean_project_artifacts() {
local -a all_found_items=()
local -a safe_to_clean=()
local -a recently_modified=()
# Set up cleanup on interrupt
# Note: Declared without 'local' so cleanup_scan trap can access them
scan_pids=()
scan_temps=()
# shellcheck disable=SC2329
cleanup_scan() {
# Kill all background scans
for pid in "${scan_pids[@]+"${scan_pids[@]}"}"; do
kill "$pid" 2> /dev/null || true
done
# Clean up temp files
for temp in "${scan_temps[@]+"${scan_temps[@]}"}"; do
rm -f "$temp" 2> /dev/null || true
done
if [[ -t 1 ]]; then
stop_inline_spinner
fi
echo ""
exit 130
}
trap cleanup_scan INT TERM
# Start parallel scanning of all paths at once
if [[ -t 1 ]]; then
start_inline_spinner "Scanning projects..."
fi
# Launch all scans in parallel
for path in "${PURGE_SEARCH_PATHS[@]}"; do
if [[ -d "$path" ]]; then
local scan_output
scan_output=$(mktemp)
scan_temps+=("$scan_output")
# Launch scan in background for true parallelism
scan_purge_targets "$path" "$scan_output" &
local scan_pid=$!
scan_pids+=("$scan_pid")
fi
done
# Wait for all scans to complete
for pid in "${scan_pids[@]+"${scan_pids[@]}"}"; do
wait "$pid" 2> /dev/null || true
done
if [[ -t 1 ]]; then
stop_inline_spinner
fi
# Collect all results
for scan_output in "${scan_temps[@]+"${scan_temps[@]}"}"; do
if [[ -f "$scan_output" ]]; then
while IFS= read -r item; do
if [[ -n "$item" ]]; then
all_found_items+=("$item")
fi
done < "$scan_output"
rm -f "$scan_output"
fi
done
# Clean up trap
trap - INT TERM
if [[ ${#all_found_items[@]} -eq 0 ]]; then
echo ""
echo -e "${GREEN}${ICON_SUCCESS}${NC} Great! No old project artifacts to clean"
printf '\n'
return 2 # Special code: nothing to clean
fi
# Mark recently modified items (for default selection state)
for item in "${all_found_items[@]}"; do
if is_recently_modified "$item"; then
recently_modified+=("$item")
fi
# Add all items to safe_to_clean, let user choose
safe_to_clean+=("$item")
done
# Build menu options - one per artifact
if [[ -t 1 ]]; then
start_inline_spinner "Calculating sizes..."
fi
local -a menu_options=()
local -a item_paths=()
local -a item_sizes=()
local -a item_recent_flags=()
# Helper to get project name from path
# For ~/www/pake/src-tauri/target -> returns "pake"
# For ~/work/code/MyProject/node_modules -> returns "MyProject"
# Strategy: Find the nearest ancestor directory containing a project indicator file
get_project_name() {
local path="$1"
local artifact_name
artifact_name=$(basename "$path")
# Start from the parent of the artifact and walk up
local current_dir
current_dir=$(dirname "$path")
while [[ "$current_dir" != "/" && "$current_dir" != "$HOME" && -n "$current_dir" ]]; do
# Check if current directory contains any project indicator
for indicator in "${PROJECT_INDICATORS[@]}"; do
if [[ -e "$current_dir/$indicator" ]]; then
# Found a project root, return its name
basename "$current_dir"
return 0
fi
done
# Move up one level
current_dir=$(dirname "$current_dir")
done
# Fallback: try the old logic (first directory under search root)
local search_roots=()
if [[ ${#PURGE_SEARCH_PATHS[@]} -gt 0 ]]; then
search_roots=("${PURGE_SEARCH_PATHS[@]}")
else
search_roots=("$HOME/www" "$HOME/dev" "$HOME/Projects")
fi
for root in "${search_roots[@]}"; do
root="${root%/}"
if [[ -n "$root" && "$path" == "$root/"* ]]; then
local relative_path="${path#"$root"/}"
echo "$relative_path" | cut -d'/' -f1
return 0
fi
done
# Final fallback: use grandparent directory
dirname "$(dirname "$path")" | xargs basename
}
# Format display with alignment (like app_selector)
format_purge_display() {
local project_name="$1"
local artifact_type="$2"
local size_str="$3"
# Terminal width for alignment
local terminal_width=$(tput cols 2> /dev/null || echo 80)
local fixed_width=28 # Reserve for type and size
local available_width=$((terminal_width - fixed_width))
# Bounds: 24-35 chars for project name
[[ $available_width -lt 24 ]] && available_width=24
[[ $available_width -gt 35 ]] && available_width=35
# Truncate project name if needed
local truncated_name=$(truncate_by_display_width "$project_name" "$available_width")
local current_width=$(get_display_width "$truncated_name")
local char_count=${#truncated_name}
local padding=$((available_width - current_width))
local printf_width=$((char_count + padding))
# Format: "project_name size | artifact_type"
printf "%-*s %9s | %-13s" "$printf_width" "$truncated_name" "$size_str" "$artifact_type"
}
# Build menu options - one line per artifact
for item in "${safe_to_clean[@]}"; do
local project_name=$(get_project_name "$item")
local artifact_type=$(basename "$item")
local size_kb=$(get_dir_size_kb "$item")
local size_human=$(bytes_to_human "$((size_kb * 1024))")
# Check if recent
local is_recent=false
for recent_item in "${recently_modified[@]+"${recently_modified[@]}"}"; do
if [[ "$item" == "$recent_item" ]]; then
is_recent=true
break
fi
done
menu_options+=("$(format_purge_display "$project_name" "$artifact_type" "$size_human")")
item_paths+=("$item")
item_sizes+=("$size_kb")
item_recent_flags+=("$is_recent")
done
if [[ -t 1 ]]; then
stop_inline_spinner
fi
# Set global vars for selector
export PURGE_CATEGORY_SIZES=$(
IFS=,
echo "${item_sizes[*]}"
)
export PURGE_RECENT_CATEGORIES=$(
IFS=,
echo "${item_recent_flags[*]}"
)
# Interactive selection (only if terminal is available)
PURGE_SELECTION_RESULT=""
if [[ -t 0 ]]; then
if ! select_purge_categories "${menu_options[@]}"; then
unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
return 1
fi
else
# Non-interactive: select all non-recent items
for ((i = 0; i < ${#menu_options[@]}; i++)); do
if [[ ${item_recent_flags[i]} != "true" ]]; then
[[ -n "$PURGE_SELECTION_RESULT" ]] && PURGE_SELECTION_RESULT+=","
PURGE_SELECTION_RESULT+="$i"
fi
done
fi
if [[ -z "$PURGE_SELECTION_RESULT" ]]; then
echo ""
echo -e "${GRAY}No items selected${NC}"
printf '\n'
unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
return 0
fi
# Clean selected items
echo ""
IFS=',' read -r -a selected_indices <<< "$PURGE_SELECTION_RESULT"
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
local cleaned_count=0
for idx in "${selected_indices[@]}"; do
local item_path="${item_paths[idx]}"
local artifact_type=$(basename "$item_path")
local project_name=$(get_project_name "$item_path")
local size_kb="${item_sizes[idx]}"
local size_human=$(bytes_to_human "$((size_kb * 1024))")
# Safety checks
if [[ -z "$item_path" || "$item_path" == "/" || "$item_path" == "$HOME" || "$item_path" != "$HOME/"* ]]; then
continue
fi
if [[ -t 1 ]]; then
start_inline_spinner "Cleaning $project_name/$artifact_type..."
fi
if [[ -e "$item_path" ]]; then
safe_remove "$item_path" true
if [[ ! -e "$item_path" ]]; then
local current_total=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0")
echo "$((current_total + size_kb))" > "$stats_dir/purge_stats"
((cleaned_count++))
fi
fi
if [[ -t 1 ]]; then
stop_inline_spinner
echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_name - $artifact_type ${GREEN}($size_human)${NC}"
fi
done
# Update count
echo "$cleaned_count" > "$stats_dir/purge_count"
unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
}

423
lib/clean/system.ps1 Normal file
View File

@@ -0,0 +1,423 @@
# Mole - System Cleanup Module
# Cleans Windows system files that require administrator access
#Requires -Version 5.1
Set-StrictMode -Version Latest
# Prevent multiple sourcing
if ((Get-Variable -Name 'MOLE_CLEAN_SYSTEM_LOADED' -Scope Script -ErrorAction SilentlyContinue) -and $script:MOLE_CLEAN_SYSTEM_LOADED) { return }
$script:MOLE_CLEAN_SYSTEM_LOADED = $true
# Import dependencies
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. "$scriptDir\..\core\base.ps1"
. "$scriptDir\..\core\log.ps1"
. "$scriptDir\..\core\file_ops.ps1"
# ============================================================================
# System Temp Files
# ============================================================================
function Clear-SystemTempFiles {
<#
.SYNOPSIS
Clean system-level temporary files (requires admin)
#>
if (-not (Test-IsAdmin)) {
Write-Debug "Skipping system temp cleanup - requires admin"
return
}
# Windows Temp folder
$winTemp = "$env:WINDIR\Temp"
if (Test-Path $winTemp) {
Remove-OldFiles -Path $winTemp -DaysOld 7 -Description "Windows temp files"
}
# System temp (different from Windows temp)
$systemTemp = "$env:SYSTEMROOT\Temp"
if ((Test-Path $systemTemp) -and ($systemTemp -ne $winTemp)) {
Remove-OldFiles -Path $systemTemp -DaysOld 7 -Description "System temp files"
}
}
# ============================================================================
# Windows Logs
# ============================================================================
function Clear-WindowsLogs {
<#
.SYNOPSIS
Clean Windows log files (requires admin)
#>
param([int]$DaysOld = 7)
if (-not (Test-IsAdmin)) {
Write-Debug "Skipping Windows logs cleanup - requires admin"
return
}
# Windows Logs directory
$logPaths = @(
"$env:WINDIR\Logs\CBS"
"$env:WINDIR\Logs\DISM"
"$env:WINDIR\Logs\DPX"
"$env:WINDIR\Logs\WindowsUpdate"
"$env:WINDIR\Logs\SIH"
"$env:WINDIR\Logs\waasmedia"
"$env:WINDIR\Debug"
"$env:WINDIR\Panther"
"$env:PROGRAMDATA\Microsoft\Windows\WER\ReportQueue"
"$env:PROGRAMDATA\Microsoft\Windows\WER\ReportArchive"
)
foreach ($path in $logPaths) {
if (Test-Path $path) {
Remove-OldFiles -Path $path -DaysOld $DaysOld -Description "$(Split-Path -Leaf $path) logs"
}
}
# Setup logs (*.log files in Windows directory)
$setupLogs = Get-ChildItem -Path "$env:WINDIR\*.log" -File -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$DaysOld) }
if ($setupLogs) {
$paths = $setupLogs | ForEach-Object { $_.FullName }
Remove-SafeItems -Paths $paths -Description "Windows setup logs"
}
}
# ============================================================================
# Windows Update Cleanup
# ============================================================================
function Clear-WindowsUpdateFiles {
<#
.SYNOPSIS
Clean Windows Update download cache (requires admin)
#>
if (-not (Test-IsAdmin)) {
Write-Debug "Skipping Windows Update cleanup - requires admin"
return
}
# Stop Windows Update service
$wuService = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
$wasRunning = $wuService.Status -eq 'Running'
if ($wasRunning) {
if (Test-DryRunMode) {
Write-DryRun "Windows Update cache (service would be restarted)"
return
}
try {
Stop-Service -Name wuauserv -Force -ErrorAction Stop
}
catch {
Write-Debug "Could not stop Windows Update service: $_"
return
}
}
try {
# Clean download cache
$wuDownloadPath = "$env:WINDIR\SoftwareDistribution\Download"
if (Test-Path $wuDownloadPath) {
Clear-DirectoryContents -Path $wuDownloadPath -Description "Windows Update download cache"
}
# Clean DataStore (old update history - be careful!)
# Only clean temp files, not the actual database
$wuDataStore = "$env:WINDIR\SoftwareDistribution\DataStore\Logs"
if (Test-Path $wuDataStore) {
Clear-DirectoryContents -Path $wuDataStore -Description "Windows Update logs"
}
}
finally {
# Always restart service if it was running, even if cleanup failed
if ($wasRunning) {
Start-Service -Name wuauserv -ErrorAction SilentlyContinue
}
}
}
# ============================================================================
# Installer Cleanup
# ============================================================================
function Clear-InstallerCache {
<#
.SYNOPSIS
Clean Windows Installer cache (orphaned patches)
#>
if (-not (Test-IsAdmin)) {
return
}
# Windows Installer patch cache
# WARNING: Be very careful here - only clean truly orphaned files
$installerPath = "$env:WINDIR\Installer"
# Only clean .tmp files and very old .msp files that are likely orphaned
if (Test-Path $installerPath) {
$tmpFiles = Get-ChildItem -Path $installerPath -Filter "*.tmp" -File -ErrorAction SilentlyContinue
if ($tmpFiles) {
$paths = $tmpFiles | ForEach-Object { $_.FullName }
Remove-SafeItems -Paths $paths -Description "Installer temp files"
}
}
# Installer logs in temp
$installerLogs = Get-ChildItem -Path $env:TEMP -Filter "MSI*.LOG" -File -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-30) }
if ($installerLogs) {
$paths = $installerLogs | ForEach-Object { $_.FullName }
Remove-SafeItems -Paths $paths -Description "Old MSI logs"
}
}
# ============================================================================
# Component Store Cleanup
# ============================================================================
function Invoke-ComponentStoreCleanup {
<#
.SYNOPSIS
Run Windows Component Store cleanup (DISM)
#>
if (-not (Test-IsAdmin)) {
Write-Debug "Skipping component store cleanup - requires admin"
return
}
if (Test-DryRunMode) {
Write-DryRun "Component Store cleanup (DISM)"
Set-SectionActivity
return
}
try {
Write-Info "Running Component Store cleanup (this may take a while)..."
# Run DISM cleanup
$result = Start-Process -FilePath "dism.exe" `
-ArgumentList "/Online", "/Cleanup-Image", "/StartComponentCleanup" `
-Wait -PassThru -NoNewWindow -ErrorAction Stop
if ($result.ExitCode -eq 0) {
Write-Success "Component Store cleanup"
Set-SectionActivity
}
else {
Write-Debug "DISM returned exit code: $($result.ExitCode)"
}
}
catch {
Write-Debug "Component Store cleanup failed: $_"
}
}
# ============================================================================
# Memory Dump Cleanup
# ============================================================================
function Clear-MemoryDumps {
<#
.SYNOPSIS
Clean Windows memory dumps
#>
$dumpPaths = @(
"$env:WINDIR\MEMORY.DMP"
"$env:WINDIR\Minidump"
"$env:LOCALAPPDATA\CrashDumps"
)
foreach ($path in $dumpPaths) {
if (Test-Path $path -PathType Leaf) {
# Single file (MEMORY.DMP)
Remove-SafeItem -Path $path -Description "Memory dump"
}
elseif (Test-Path $path -PathType Container) {
# Directory (Minidump, CrashDumps)
Clear-DirectoryContents -Path $path -Description "$(Split-Path -Leaf $path)"
}
}
}
# ============================================================================
# Font Cache
# ============================================================================
function Clear-SystemFontCache {
<#
.SYNOPSIS
Clear Windows font cache (requires admin and may need restart)
#>
if (-not (Test-IsAdmin)) {
return
}
$fontCacheService = Get-Service -Name "FontCache" -ErrorAction SilentlyContinue
if ($fontCacheService) {
if (Test-DryRunMode) {
Write-DryRun "System font cache"
return
}
try {
# Stop font cache service
Stop-Service -Name "FontCache" -Force -ErrorAction SilentlyContinue
# Clear font cache files
$fontCachePath = "$env:WINDIR\ServiceProfiles\LocalService\AppData\Local\FontCache"
if (Test-Path $fontCachePath) {
Clear-DirectoryContents -Path $fontCachePath -Description "System font cache"
}
# Restart font cache service
Start-Service -Name "FontCache" -ErrorAction SilentlyContinue
}
catch {
Write-Debug "Font cache cleanup failed: $_"
Start-Service -Name "FontCache" -ErrorAction SilentlyContinue
}
}
}
# ============================================================================
# Disk Cleanup Tool Integration
# ============================================================================
function Invoke-DiskCleanupTool {
<#
.SYNOPSIS
Run Windows built-in Disk Cleanup tool with predefined settings
#>
param([switch]$Full)
if (-not (Test-IsAdmin)) {
Write-Debug "Skipping Disk Cleanup tool - requires admin for full cleanup"
}
if (Test-DryRunMode) {
Write-DryRun "Windows Disk Cleanup tool"
return
}
# Set up registry keys for automated cleanup
$cleanupKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VolumeCaches"
$cleanupItems = @(
"Active Setup Temp Folders"
"Downloaded Program Files"
"Internet Cache Files"
"Old ChkDsk Files"
"Recycle Bin"
"Setup Log Files"
"System error memory dump files"
"System error minidump files"
"Temporary Files"
"Temporary Setup Files"
"Thumbnail Cache"
"Windows Error Reporting Archive Files"
"Windows Error Reporting Queue Files"
"Windows Error Reporting System Archive Files"
"Windows Error Reporting System Queue Files"
)
if ($Full -and (Test-IsAdmin)) {
$cleanupItems += @(
"Previous Installations"
"Temporary Windows installation files"
"Update Cleanup"
"Windows Defender"
"Windows Upgrade Log Files"
)
}
# Enable cleanup items in registry
foreach ($item in $cleanupItems) {
$itemPath = Join-Path $cleanupKey $item
if (Test-Path $itemPath) {
Set-ItemProperty -Path $itemPath -Name "StateFlags0100" -Value 2 -Type DWord -ErrorAction SilentlyContinue
}
}
try {
# Run disk cleanup
$process = Start-Process -FilePath "cleanmgr.exe" `
-ArgumentList "/sagerun:100" `
-Wait -PassThru -NoNewWindow -ErrorAction Stop
if ($process.ExitCode -eq 0) {
Write-Success "Windows Disk Cleanup"
Set-SectionActivity
}
}
catch {
Write-Debug "Disk Cleanup failed: $_"
}
}
# ============================================================================
# Main System Cleanup Function
# ============================================================================
function Invoke-SystemCleanup {
<#
.SYNOPSIS
Run all system-level cleanup tasks (requires admin for full effect)
#>
param(
[switch]$IncludeComponentStore,
[switch]$IncludeDiskCleanup
)
Start-Section "System cleanup"
if (-not (Test-IsAdmin)) {
Write-MoleWarning "Running without admin - some cleanup tasks will be skipped"
}
# System temp files
Clear-SystemTempFiles
# Windows logs
Clear-WindowsLogs -DaysOld 7
# Windows Update cache
Clear-WindowsUpdateFiles
# Installer cache
Clear-InstallerCache
# Memory dumps
Clear-MemoryDumps
# Font cache
Clear-SystemFontCache
# Optional: Component Store (can take a long time)
if ($IncludeComponentStore) {
Invoke-ComponentStoreCleanup
}
# Optional: Windows Disk Cleanup tool
if ($IncludeDiskCleanup) {
Invoke-DiskCleanupTool -Full
}
Stop-Section
}
# ============================================================================
# Exports
# ============================================================================
# Functions: Clear-SystemTempFiles, Clear-WindowsLogs, Invoke-SystemCleanup, etc.

View File

@@ -1,339 +0,0 @@
#!/bin/bash
# System-Level Cleanup Module (requires sudo).
set -euo pipefail
# System caches, logs, and temp files.
clean_deep_system() {
stop_section_spinner
local cache_cleaned=0
safe_sudo_find_delete "/Library/Caches" "*.cache" "$MOLE_TEMP_FILE_AGE_DAYS" "f" && cache_cleaned=1 || true
safe_sudo_find_delete "/Library/Caches" "*.tmp" "$MOLE_TEMP_FILE_AGE_DAYS" "f" && cache_cleaned=1 || true
safe_sudo_find_delete "/Library/Caches" "*.log" "$MOLE_LOG_AGE_DAYS" "f" && cache_cleaned=1 || true
[[ $cache_cleaned -eq 1 ]] && log_success "System caches"
local tmp_cleaned=0
safe_sudo_find_delete "/private/tmp" "*" "${MOLE_TEMP_FILE_AGE_DAYS}" "f" && tmp_cleaned=1 || true
safe_sudo_find_delete "/private/var/tmp" "*" "${MOLE_TEMP_FILE_AGE_DAYS}" "f" && tmp_cleaned=1 || true
[[ $tmp_cleaned -eq 1 ]] && log_success "System temp files"
safe_sudo_find_delete "/Library/Logs/DiagnosticReports" "*" "$MOLE_CRASH_REPORT_AGE_DAYS" "f" || true
log_success "System crash reports"
safe_sudo_find_delete "/private/var/log" "*.log" "$MOLE_LOG_AGE_DAYS" "f" || true
safe_sudo_find_delete "/private/var/log" "*.gz" "$MOLE_LOG_AGE_DAYS" "f" || true
log_success "System logs"
if [[ -d "/Library/Updates" && ! -L "/Library/Updates" ]]; then
if ! is_sip_enabled; then
local updates_cleaned=0
while IFS= read -r -d '' item; do
if [[ -z "$item" ]] || [[ ! "$item" =~ ^/Library/Updates/[^/]+$ ]]; then
debug_log "Skipping malformed path: $item"
continue
fi
local item_flags
item_flags=$($STAT_BSD -f%Sf "$item" 2> /dev/null || echo "")
if [[ "$item_flags" == *"restricted"* ]]; then
continue
fi
if safe_sudo_remove "$item"; then
((updates_cleaned++))
fi
done < <(find /Library/Updates -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true)
[[ $updates_cleaned -gt 0 ]] && log_success "System library updates"
fi
fi
if [[ -d "/macOS Install Data" ]]; then
local mtime=$(get_file_mtime "/macOS Install Data")
local age_days=$((($(get_epoch_seconds) - mtime) / 86400))
debug_log "Found macOS Install Data (age: ${age_days} days)"
if [[ $age_days -ge 30 ]]; then
local size_kb=$(get_path_size_kb "/macOS Install Data")
if [[ -n "$size_kb" && "$size_kb" -gt 0 ]]; then
local size_human=$(bytes_to_human "$((size_kb * 1024))")
debug_log "Cleaning macOS Install Data: $size_human (${age_days} days old)"
if safe_sudo_remove "/macOS Install Data"; then
log_success "macOS Install Data ($size_human)"
fi
fi
else
debug_log "Keeping macOS Install Data (only ${age_days} days old, needs 30+)"
fi
fi
start_section_spinner "Scanning system caches..."
local code_sign_cleaned=0
local found_count=0
local last_update_time
last_update_time=$(get_epoch_seconds)
local update_interval=2
while IFS= read -r -d '' cache_dir; do
if safe_remove "$cache_dir" true; then
((code_sign_cleaned++))
fi
((found_count++))
# Optimize: only check time every 50 files
if ((found_count % 50 == 0)); then
local current_time
current_time=$(get_epoch_seconds)
if [[ $((current_time - last_update_time)) -ge $update_interval ]]; then
start_section_spinner "Scanning system caches... ($found_count found)"
last_update_time=$current_time
fi
fi
done < <(run_with_timeout 5 command find /private/var/folders -type d -name "*.code_sign_clone" -path "*/X/*" -print0 2> /dev/null || true)
stop_section_spinner
[[ $code_sign_cleaned -gt 0 ]] && log_success "Browser code signature caches ($code_sign_cleaned items)"
safe_sudo_find_delete "/private/var/db/diagnostics/Special" "*" "$MOLE_LOG_AGE_DAYS" "f" || true
safe_sudo_find_delete "/private/var/db/diagnostics/Persist" "*" "$MOLE_LOG_AGE_DAYS" "f" || true
safe_sudo_find_delete "/private/var/db/DiagnosticPipeline" "*" "$MOLE_LOG_AGE_DAYS" "f" || true
log_success "System diagnostic logs"
safe_sudo_find_delete "/private/var/db/powerlog" "*" "$MOLE_LOG_AGE_DAYS" "f" || true
log_success "Power logs"
safe_sudo_find_delete "/private/var/db/reportmemoryexception/MemoryLimitViolations" "*" "30" "f" || true
log_success "Memory exception reports"
start_section_spinner "Cleaning diagnostic trace logs..."
local diag_logs_cleaned=0
safe_sudo_find_delete "/private/var/db/diagnostics/Persist" "*.tracev3" "30" "f" && diag_logs_cleaned=1 || true
safe_sudo_find_delete "/private/var/db/diagnostics/Special" "*.tracev3" "30" "f" && diag_logs_cleaned=1 || true
stop_section_spinner
[[ $diag_logs_cleaned -eq 1 ]] && log_success "System diagnostic trace logs"
}
# Incomplete Time Machine backups.
clean_time_machine_failed_backups() {
local tm_cleaned=0
if ! command -v tmutil > /dev/null 2>&1; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found"
return 0
fi
start_section_spinner "Checking Time Machine configuration..."
local spinner_active=true
local tm_info
tm_info=$(run_with_timeout 2 tmutil destinationinfo 2>&1 || echo "failed")
if [[ "$tm_info" == *"No destinations configured"* || "$tm_info" == "failed" ]]; then
if [[ "$spinner_active" == "true" ]]; then
stop_section_spinner
fi
echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found"
return 0
fi
if [[ ! -d "/Volumes" ]]; then
if [[ "$spinner_active" == "true" ]]; then
stop_section_spinner
fi
echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found"
return 0
fi
if tmutil status 2> /dev/null | grep -q "Running = 1"; then
if [[ "$spinner_active" == "true" ]]; then
stop_section_spinner
fi
echo -e " ${YELLOW}!${NC} Time Machine backup in progress, skipping cleanup"
return 0
fi
if [[ "$spinner_active" == "true" ]]; then
start_section_spinner "Checking backup volumes..."
fi
# Fast pre-scan for backup volumes to avoid slow tmutil checks.
local -a backup_volumes=()
for volume in /Volumes/*; do
[[ -d "$volume" ]] || continue
[[ "$volume" == "/Volumes/MacintoshHD" || "$volume" == "/" ]] && continue
[[ -L "$volume" ]] && continue
if [[ -d "$volume/Backups.backupdb" ]] || [[ -d "$volume/.MobileBackups" ]]; then
backup_volumes+=("$volume")
fi
done
if [[ ${#backup_volumes[@]} -eq 0 ]]; then
if [[ "$spinner_active" == "true" ]]; then
stop_section_spinner
fi
echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found"
return 0
fi
if [[ "$spinner_active" == "true" ]]; then
start_section_spinner "Scanning backup volumes..."
fi
for volume in "${backup_volumes[@]}"; do
local fs_type
fs_type=$(run_with_timeout 1 command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}' || echo "unknown")
case "$fs_type" in
nfs | smbfs | afpfs | cifs | webdav | unknown) continue ;;
esac
local backupdb_dir="$volume/Backups.backupdb"
if [[ -d "$backupdb_dir" ]]; then
while IFS= read -r inprogress_file; do
[[ -d "$inprogress_file" ]] || continue
# Only delete old incomplete backups (safety window).
local file_mtime=$(get_file_mtime "$inprogress_file")
local current_time
current_time=$(get_epoch_seconds)
local hours_old=$(((current_time - file_mtime) / 3600))
if [[ $hours_old -lt $MOLE_TM_BACKUP_SAFE_HOURS ]]; then
continue
fi
local size_kb=$(get_path_size_kb "$inprogress_file")
[[ "$size_kb" -le 0 ]] && continue
if [[ "$spinner_active" == "true" ]]; then
stop_section_spinner
spinner_active=false
fi
local backup_name=$(basename "$inprogress_file")
local size_human=$(bytes_to_human "$((size_kb * 1024))")
if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete backup: $backup_name ${YELLOW}($size_human dry)${NC}"
((tm_cleaned++))
note_activity
continue
fi
if ! command -v tmutil > /dev/null 2>&1; then
echo -e " ${YELLOW}!${NC} tmutil not available, skipping: $backup_name"
continue
fi
if tmutil delete "$inprogress_file" 2> /dev/null; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete backup: $backup_name ${GREEN}($size_human)${NC}"
((tm_cleaned++))
((files_cleaned++))
((total_size_cleaned += size_kb))
((total_items++))
note_activity
else
echo -e " ${YELLOW}!${NC} Could not delete: $backup_name · try manually with sudo"
fi
done < <(run_with_timeout 15 find "$backupdb_dir" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true)
fi
# APFS bundles.
for bundle in "$volume"/*.backupbundle "$volume"/*.sparsebundle; do
[[ -e "$bundle" ]] || continue
[[ -d "$bundle" ]] || continue
local bundle_name=$(basename "$bundle")
local mounted_path=$(hdiutil info 2> /dev/null | grep -A 5 "image-path.*$bundle_name" | grep "/Volumes/" | awk '{print $1}' | head -1 || echo "")
if [[ -n "$mounted_path" && -d "$mounted_path" ]]; then
while IFS= read -r inprogress_file; do
[[ -d "$inprogress_file" ]] || continue
local file_mtime=$(get_file_mtime "$inprogress_file")
local current_time
current_time=$(get_epoch_seconds)
local hours_old=$(((current_time - file_mtime) / 3600))
if [[ $hours_old -lt $MOLE_TM_BACKUP_SAFE_HOURS ]]; then
continue
fi
local size_kb=$(get_path_size_kb "$inprogress_file")
[[ "$size_kb" -le 0 ]] && continue
if [[ "$spinner_active" == "true" ]]; then
stop_section_spinner
spinner_active=false
fi
local backup_name=$(basename "$inprogress_file")
local size_human=$(bytes_to_human "$((size_kb * 1024))")
if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete APFS backup in $bundle_name: $backup_name ${YELLOW}($size_human dry)${NC}"
((tm_cleaned++))
note_activity
continue
fi
if ! command -v tmutil > /dev/null 2>&1; then
continue
fi
if tmutil delete "$inprogress_file" 2> /dev/null; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete APFS backup in $bundle_name: $backup_name ${GREEN}($size_human)${NC}"
((tm_cleaned++))
((files_cleaned++))
((total_size_cleaned += size_kb))
((total_items++))
note_activity
else
echo -e " ${YELLOW}!${NC} Could not delete from bundle: $backup_name"
fi
done < <(run_with_timeout 15 find "$mounted_path" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true)
fi
done
done
if [[ "$spinner_active" == "true" ]]; then
stop_section_spinner
fi
if [[ $tm_cleaned -eq 0 ]]; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found"
fi
}
# Local APFS snapshots (keep the most recent).
clean_local_snapshots() {
if ! command -v tmutil > /dev/null 2>&1; then
return 0
fi
start_section_spinner "Checking local snapshots..."
local snapshot_list
snapshot_list=$(tmutil listlocalsnapshots / 2> /dev/null)
stop_section_spinner
[[ -z "$snapshot_list" ]] && return 0
local cleaned_count=0
local total_cleaned_size=0 # Estimation not possible without thin
local newest_ts=0
local newest_name=""
local -a snapshots=()
while IFS= read -r line; do
if [[ "$line" =~ com\.apple\.TimeMachine\.([0-9]{4})-([0-9]{2})-([0-9]{2})-([0-9]{6}) ]]; then
local snap_name="${BASH_REMATCH[0]}"
snapshots+=("$snap_name")
local date_str="${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]} ${BASH_REMATCH[4]:0:2}:${BASH_REMATCH[4]:2:2}:${BASH_REMATCH[4]:4:2}"
local snap_ts=$(date -j -f "%Y-%m-%d %H:%M:%S" "$date_str" "+%s" 2> /dev/null || echo "0")
[[ "$snap_ts" == "0" ]] && continue
if [[ "$snap_ts" -gt "$newest_ts" ]]; then
newest_ts="$snap_ts"
newest_name="$snap_name"
fi
fi
done <<< "$snapshot_list"
[[ ${#snapshots[@]} -eq 0 ]] && return 0
[[ -z "$newest_name" ]] && return 0
local deletable_count=$((${#snapshots[@]} - 1))
[[ $deletable_count -le 0 ]] && return 0
if [[ "$DRY_RUN" != "true" ]]; then
if [[ ! -t 0 ]]; then
echo -e " ${YELLOW}!${NC} ${#snapshots[@]} local snapshot(s) found, skipping non-interactive mode"
echo -e " ${YELLOW}${ICON_WARNING}${NC} ${GRAY}Tip: Snapshots may cause Disk Utility to show different 'Available' values${NC}"
return 0
fi
echo -e " ${YELLOW}!${NC} Time Machine local snapshots found"
echo -e " ${GRAY}macOS can recreate them if needed.${NC}"
echo -e " ${GRAY}The most recent snapshot will be kept.${NC}"
echo -ne " ${PURPLE}${ICON_ARROW}${NC} Remove all local snapshots except the most recent one? ${GREEN}Enter${NC} continue, ${GRAY}Space${NC} skip: "
local choice
if type read_key > /dev/null 2>&1; then
choice=$(read_key)
else
IFS= read -r -s -n 1 choice || choice=""
if [[ -z "$choice" || "$choice" == $'\n' || "$choice" == $'\r' ]]; then
choice="ENTER"
fi
fi
if [[ "$choice" == "ENTER" ]]; then
printf "\r\033[K" # Clear the prompt line
else
echo -e " ${GRAY}Skipped${NC}"
return 0
fi
fi
local snap_name
for snap_name in "${snapshots[@]}"; do
if [[ "$snap_name" =~ com\.apple\.TimeMachine\.([0-9]{4})-([0-9]{2})-([0-9]{2})-([0-9]{6}) ]]; then
if [[ "${BASH_REMATCH[0]}" != "$newest_name" ]]; then
if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Local snapshot: $snap_name ${YELLOW}dry-run${NC}"
((cleaned_count++))
note_activity
else
if sudo tmutil deletelocalsnapshots "${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]}-${BASH_REMATCH[4]}" > /dev/null 2>&1; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed snapshot: $snap_name"
((cleaned_count++))
note_activity
else
echo -e " ${YELLOW}!${NC} Failed to remove: $snap_name"
fi
fi
fi
fi
done
if [[ $cleaned_count -gt 0 && "$DRY_RUN" != "true" ]]; then
log_success "Cleaned $cleaned_count local snapshots, kept latest"
fi
}

352
lib/clean/user.ps1 Normal file
View File

@@ -0,0 +1,352 @@
# Mole - User Cleanup Module
# Cleans user-level temporary files, caches, and downloads
#Requires -Version 5.1
Set-StrictMode -Version Latest
# Prevent multiple sourcing
if ((Get-Variable -Name 'MOLE_CLEAN_USER_LOADED' -Scope Script -ErrorAction SilentlyContinue) -and $script:MOLE_CLEAN_USER_LOADED) { return }
$script:MOLE_CLEAN_USER_LOADED = $true
# Import dependencies
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. "$scriptDir\..\core\base.ps1"
. "$scriptDir\..\core\log.ps1"
. "$scriptDir\..\core\file_ops.ps1"
# ============================================================================
# Windows Temp Files Cleanup
# ============================================================================
function Clear-UserTempFiles {
<#
.SYNOPSIS
Clean user temporary files
#>
param([int]$DaysOld = 7)
Start-Section "User temp files"
# User temp directory
$userTemp = $env:TEMP
if (Test-Path $userTemp) {
Remove-OldFiles -Path $userTemp -DaysOld $DaysOld -Description "User temp files"
}
# Windows Temp (if accessible)
$winTemp = "$env:WINDIR\Temp"
if ((Test-Path $winTemp) -and (Test-IsAdmin)) {
Remove-OldFiles -Path $winTemp -DaysOld $DaysOld -Description "Windows temp files"
}
Stop-Section
}
# ============================================================================
# Downloads Folder Cleanup
# ============================================================================
function Clear-OldDownloads {
<#
.SYNOPSIS
Clean old files from Downloads folder (with user confirmation pattern)
#>
param([int]$DaysOld = 30)
$downloadsPath = [Environment]::GetFolderPath('UserProfile') + '\Downloads'
if (-not (Test-Path $downloadsPath)) {
return
}
# Find old installers and archives
$patterns = @('*.exe', '*.msi', '*.zip', '*.7z', '*.rar', '*.tar.gz', '*.iso')
$cutoffDate = (Get-Date).AddDays(-$DaysOld)
$oldFiles = @()
foreach ($pattern in $patterns) {
$files = Get-ChildItem -Path $downloadsPath -Filter $pattern -File -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -lt $cutoffDate }
if ($files) {
$oldFiles += $files
}
}
if ($oldFiles.Count -gt 0) {
$paths = $oldFiles | ForEach-Object { $_.FullName }
Remove-SafeItems -Paths $paths -Description "Old downloads (>${DaysOld}d)"
}
}
# ============================================================================
# Recycle Bin Cleanup
# ============================================================================
function Clear-RecycleBin {
<#
.SYNOPSIS
Empty the Recycle Bin
#>
if (Test-DryRunMode) {
Write-DryRun "Recycle Bin (would empty)"
Set-SectionActivity
return
}
try {
# Use Shell.Application COM object
$shell = New-Object -ComObject Shell.Application
$recycleBin = $shell.Namespace(0xA) # Recycle Bin
$items = $recycleBin.Items()
if ($items.Count -gt 0) {
# Calculate size
$totalSize = 0
foreach ($item in $items) {
$totalSize += $item.Size
}
# Clear using Clear-RecycleBin cmdlet (Windows 10+)
Clear-RecycleBin -Force -ErrorAction SilentlyContinue
$sizeHuman = Format-ByteSize -Bytes $totalSize
Write-Success "Recycle Bin $($script:Colors.Green)($sizeHuman)$($script:Colors.NC)"
Set-SectionActivity
}
}
catch {
Write-Debug "Could not clear Recycle Bin: $_"
}
}
# ============================================================================
# Recent Files Cleanup
# ============================================================================
function Clear-RecentFiles {
<#
.SYNOPSIS
Clean old recent file shortcuts
#>
param([int]$DaysOld = 30)
$recentPath = "$env:APPDATA\Microsoft\Windows\Recent"
if (Test-Path $recentPath) {
Remove-OldFiles -Path $recentPath -DaysOld $DaysOld -Filter "*.lnk" -Description "Old recent shortcuts"
}
# AutomaticDestinations (jump lists)
$autoDestPath = "$recentPath\AutomaticDestinations"
if (Test-Path $autoDestPath) {
Remove-OldFiles -Path $autoDestPath -DaysOld $DaysOld -Description "Old jump list entries"
}
}
# ============================================================================
# Thumbnail Cache Cleanup
# ============================================================================
function Clear-ThumbnailCache {
<#
.SYNOPSIS
Clean Windows thumbnail cache
#>
$thumbCachePath = "$env:LOCALAPPDATA\Microsoft\Windows\Explorer"
if (-not (Test-Path $thumbCachePath)) {
return
}
# Thumbnail cache files (thumbcache_*.db)
$thumbFiles = Get-ChildItem -Path $thumbCachePath -Filter "thumbcache_*.db" -File -ErrorAction SilentlyContinue
if ($thumbFiles) {
$paths = $thumbFiles | ForEach-Object { $_.FullName }
Remove-SafeItems -Paths $paths -Description "Thumbnail cache"
}
# Icon cache
$iconCache = "$env:LOCALAPPDATA\IconCache.db"
if (Test-Path $iconCache) {
Remove-SafeItem -Path $iconCache -Description "Icon cache"
}
}
# ============================================================================
# Windows Error Reports Cleanup
# ============================================================================
function Clear-ErrorReports {
<#
.SYNOPSIS
Clean Windows Error Reporting files
#>
param([int]$DaysOld = 7)
$werPaths = @(
"$env:LOCALAPPDATA\Microsoft\Windows\WER"
"$env:LOCALAPPDATA\CrashDumps"
"$env:USERPROFILE\AppData\Local\Microsoft\Windows\WER"
)
foreach ($path in $werPaths) {
if (Test-Path $path) {
$items = Get-ChildItem -Path $path -Recurse -Force -ErrorAction SilentlyContinue
if ($items) {
$paths = $items | ForEach-Object { $_.FullName }
Remove-SafeItems -Paths $paths -Description "Error reports"
}
}
}
# Memory dumps
$dumpPaths = @(
"$env:LOCALAPPDATA\CrashDumps"
"$env:USERPROFILE\*.dmp"
)
foreach ($path in $dumpPaths) {
$dumps = Get-ChildItem -Path $path -Filter "*.dmp" -ErrorAction SilentlyContinue
if ($dumps) {
$paths = $dumps | ForEach-Object { $_.FullName }
Remove-SafeItems -Paths $paths -Description "Memory dumps"
}
}
}
# ============================================================================
# Windows Prefetch Cleanup (requires admin)
# ============================================================================
function Clear-Prefetch {
<#
.SYNOPSIS
Clean Windows Prefetch files (requires admin)
#>
param([int]$DaysOld = 14)
if (-not (Test-IsAdmin)) {
Write-Debug "Skipping Prefetch cleanup - requires admin"
return
}
$prefetchPath = "$env:WINDIR\Prefetch"
if (Test-Path $prefetchPath) {
Remove-OldFiles -Path $prefetchPath -DaysOld $DaysOld -Description "Prefetch files"
}
}
# ============================================================================
# Log Files Cleanup
# ============================================================================
function Clear-UserLogs {
<#
.SYNOPSIS
Clean old log files from common locations
#>
param([int]$DaysOld = 7)
$logLocations = @(
"$env:LOCALAPPDATA\Temp\*.log"
"$env:APPDATA\*.log"
"$env:USERPROFILE\*.log"
)
foreach ($location in $logLocations) {
$parent = Split-Path -Parent $location
$filter = Split-Path -Leaf $location
if (Test-Path $parent) {
$logs = Get-ChildItem -Path $parent -Filter $filter -File -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$DaysOld) }
if ($logs) {
$paths = $logs | ForEach-Object { $_.FullName }
Remove-SafeItems -Paths $paths -Description "Old log files"
}
}
}
}
# ============================================================================
# Clipboard History Cleanup
# ============================================================================
function Clear-ClipboardHistory {
<#
.SYNOPSIS
Clear Windows clipboard history
#>
if (Test-DryRunMode) {
Write-DryRun "Clipboard history (would clear)"
return
}
try {
# Load Windows Forms assembly for clipboard access
Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue
# Clear current clipboard
[System.Windows.Forms.Clipboard]::Clear()
# Clear clipboard history (Windows 10 1809+)
$clipboardPath = "$env:LOCALAPPDATA\Microsoft\Windows\Clipboard"
if (Test-Path $clipboardPath) {
Clear-DirectoryContents -Path $clipboardPath -Description "Clipboard history"
}
}
catch {
Write-Debug "Could not clear clipboard: $_"
}
}
# ============================================================================
# Main User Cleanup Function
# ============================================================================
function Invoke-UserCleanup {
<#
.SYNOPSIS
Run all user-level cleanup tasks
#>
param(
[int]$TempDaysOld = 7,
[int]$DownloadsDaysOld = 30,
[int]$LogDaysOld = 7,
[switch]$IncludeDownloads,
[switch]$IncludeRecycleBin
)
Start-Section "User essentials"
# Always clean these
Clear-UserTempFiles -DaysOld $TempDaysOld
Clear-RecentFiles -DaysOld 30
Clear-ThumbnailCache
Clear-ErrorReports -DaysOld 7
Clear-UserLogs -DaysOld $LogDaysOld
Clear-Prefetch -DaysOld 14
# Optional: Downloads cleanup
if ($IncludeDownloads) {
Clear-OldDownloads -DaysOld $DownloadsDaysOld
}
# Optional: Recycle Bin
if ($IncludeRecycleBin) {
Clear-RecycleBin
}
Stop-Section
}
# ============================================================================
# Exports
# ============================================================================
# Functions: Clear-UserTempFiles, Clear-OldDownloads, Clear-RecycleBin, etc.

View File

@@ -1,695 +0,0 @@
#!/bin/bash
# User Data Cleanup Module
set -euo pipefail
clean_user_essentials() {
start_section_spinner "Scanning caches..."
safe_clean ~/Library/Caches/* "User app cache"
stop_section_spinner
start_section_spinner "Scanning empty items..."
clean_empty_library_items
stop_section_spinner
safe_clean ~/Library/Logs/* "User app logs"
if is_path_whitelisted "$HOME/.Trash"; then
note_activity
echo -e " ${GREEN}${ICON_EMPTY}${NC} Trash · whitelist protected"
else
safe_clean ~/.Trash/* "Trash"
fi
}
clean_empty_library_items() {
if [[ ! -d "$HOME/Library" ]]; then
return 0
fi
# 1. Clean top-level empty directories in Library
local -a empty_dirs=()
while IFS= read -r -d '' dir; do
[[ -d "$dir" ]] && empty_dirs+=("$dir")
done < <(find "$HOME/Library" -mindepth 1 -maxdepth 1 -type d -empty -print0 2> /dev/null)
if [[ ${#empty_dirs[@]} -gt 0 ]]; then
safe_clean "${empty_dirs[@]}" "Empty Library folders"
fi
# 2. Clean empty subdirectories in Application Support and other key locations
# Iteratively remove empty directories until no more are found
local -a key_locations=(
"$HOME/Library/Application Support"
"$HOME/Library/Caches"
)
for location in "${key_locations[@]}"; do
[[ -d "$location" ]] || continue
# Limit passes to keep cleanup fast; 3 iterations handle most nested scenarios.
local max_iterations=3
local iteration=0
while [[ $iteration -lt $max_iterations ]]; do
local -a nested_empty_dirs=()
# Find empty directories
while IFS= read -r -d '' dir; do
# Skip if whitelisted
if is_path_whitelisted "$dir"; then
continue
fi
# Skip protected system components
local dir_name=$(basename "$dir")
if is_critical_system_component "$dir_name"; then
continue
fi
[[ -d "$dir" ]] && nested_empty_dirs+=("$dir")
done < <(find "$location" -mindepth 1 -type d -empty -print0 2> /dev/null)
# If no empty dirs found, we're done with this location
if [[ ${#nested_empty_dirs[@]} -eq 0 ]]; then
break
fi
local location_name=$(basename "$location")
safe_clean "${nested_empty_dirs[@]}" "Empty $location_name subdirs"
((iteration++))
done
done
# Empty file cleanup is skipped to avoid removing app sentinel files.
}
# Remove old Google Chrome versions while keeping Current.
clean_chrome_old_versions() {
local -a app_paths=(
"/Applications/Google Chrome.app"
"$HOME/Applications/Google Chrome.app"
)
# Use -f to match Chrome Helper processes as well
if pgrep -f "Google Chrome" > /dev/null 2>&1; then
echo -e " ${YELLOW}${ICON_WARNING}${NC} Google Chrome running · old versions cleanup skipped"
return 0
fi
local cleaned_count=0
local total_size=0
local cleaned_any=false
for app_path in "${app_paths[@]}"; do
[[ -d "$app_path" ]] || continue
local versions_dir="$app_path/Contents/Frameworks/Google Chrome Framework.framework/Versions"
[[ -d "$versions_dir" ]] || continue
local current_link="$versions_dir/Current"
[[ -L "$current_link" ]] || continue
local current_version
current_version=$(readlink "$current_link" 2> /dev/null || true)
current_version="${current_version##*/}"
[[ -n "$current_version" ]] || continue
local -a old_versions=()
local dir name
for dir in "$versions_dir"/*; do
[[ -d "$dir" ]] || continue
name=$(basename "$dir")
[[ "$name" == "Current" ]] && continue
[[ "$name" == "$current_version" ]] && continue
if is_path_whitelisted "$dir"; then
continue
fi
old_versions+=("$dir")
done
if [[ ${#old_versions[@]} -eq 0 ]]; then
continue
fi
for dir in "${old_versions[@]}"; do
local size_kb
size_kb=$(get_path_size_kb "$dir" || echo 0)
size_kb="${size_kb:-0}"
total_size=$((total_size + size_kb))
((cleaned_count++))
cleaned_any=true
if [[ "$DRY_RUN" != "true" ]]; then
if has_sudo_session; then
safe_sudo_remove "$dir" > /dev/null 2>&1 || true
else
safe_remove "$dir" true > /dev/null 2>&1 || true
fi
fi
done
done
if [[ "$cleaned_any" == "true" ]]; then
local size_human
size_human=$(bytes_to_human "$((total_size * 1024))")
if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Chrome old versions ${YELLOW}(${cleaned_count} dirs, $size_human dry)${NC}"
else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Chrome old versions ${GREEN}(${cleaned_count} dirs, $size_human)${NC}"
fi
((files_cleaned += cleaned_count))
((total_size_cleaned += total_size))
((total_items++))
note_activity
fi
}
# Remove old Microsoft Edge versions while keeping Current.
clean_edge_old_versions() {
local -a app_paths=(
"/Applications/Microsoft Edge.app"
"$HOME/Applications/Microsoft Edge.app"
)
# Use -f to match Edge Helper processes as well
if pgrep -f "Microsoft Edge" > /dev/null 2>&1; then
echo -e " ${YELLOW}${ICON_WARNING}${NC} Microsoft Edge running · old versions cleanup skipped"
return 0
fi
local cleaned_count=0
local total_size=0
local cleaned_any=false
for app_path in "${app_paths[@]}"; do
[[ -d "$app_path" ]] || continue
local versions_dir="$app_path/Contents/Frameworks/Microsoft Edge Framework.framework/Versions"
[[ -d "$versions_dir" ]] || continue
local current_link="$versions_dir/Current"
[[ -L "$current_link" ]] || continue
local current_version
current_version=$(readlink "$current_link" 2> /dev/null || true)
current_version="${current_version##*/}"
[[ -n "$current_version" ]] || continue
local -a old_versions=()
local dir name
for dir in "$versions_dir"/*; do
[[ -d "$dir" ]] || continue
name=$(basename "$dir")
[[ "$name" == "Current" ]] && continue
[[ "$name" == "$current_version" ]] && continue
if is_path_whitelisted "$dir"; then
continue
fi
old_versions+=("$dir")
done
if [[ ${#old_versions[@]} -eq 0 ]]; then
continue
fi
for dir in "${old_versions[@]}"; do
local size_kb
size_kb=$(get_path_size_kb "$dir" || echo 0)
size_kb="${size_kb:-0}"
total_size=$((total_size + size_kb))
((cleaned_count++))
cleaned_any=true
if [[ "$DRY_RUN" != "true" ]]; then
if has_sudo_session; then
safe_sudo_remove "$dir" > /dev/null 2>&1 || true
else
safe_remove "$dir" true > /dev/null 2>&1 || true
fi
fi
done
done
if [[ "$cleaned_any" == "true" ]]; then
local size_human
size_human=$(bytes_to_human "$((total_size * 1024))")
if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Edge old versions ${YELLOW}(${cleaned_count} dirs, $size_human dry)${NC}"
else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge old versions ${GREEN}(${cleaned_count} dirs, $size_human)${NC}"
fi
((files_cleaned += cleaned_count))
((total_size_cleaned += total_size))
((total_items++))
note_activity
fi
}
# Remove old Microsoft EdgeUpdater versions while keeping latest.
clean_edge_updater_old_versions() {
local updater_dir="$HOME/Library/Application Support/Microsoft/EdgeUpdater/apps/msedge-stable"
[[ -d "$updater_dir" ]] || return 0
if pgrep -f "Microsoft Edge" > /dev/null 2>&1; then
echo -e " ${YELLOW}${ICON_WARNING}${NC} Microsoft Edge running · updater cleanup skipped"
return 0
fi
local -a version_dirs=()
local dir
for dir in "$updater_dir"/*; do
[[ -d "$dir" ]] || continue
version_dirs+=("$dir")
done
if [[ ${#version_dirs[@]} -lt 2 ]]; then
return 0
fi
local latest_version
latest_version=$(printf '%s\n' "${version_dirs[@]##*/}" | sort -V | tail -n 1)
[[ -n "$latest_version" ]] || return 0
local cleaned_count=0
local total_size=0
local cleaned_any=false
for dir in "${version_dirs[@]}"; do
local name
name=$(basename "$dir")
[[ "$name" == "$latest_version" ]] && continue
if is_path_whitelisted "$dir"; then
continue
fi
local size_kb
size_kb=$(get_path_size_kb "$dir" || echo 0)
size_kb="${size_kb:-0}"
total_size=$((total_size + size_kb))
((cleaned_count++))
cleaned_any=true
if [[ "$DRY_RUN" != "true" ]]; then
safe_remove "$dir" true > /dev/null 2>&1 || true
fi
done
if [[ "$cleaned_any" == "true" ]]; then
local size_human
size_human=$(bytes_to_human "$((total_size * 1024))")
if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Edge updater old versions ${YELLOW}(${cleaned_count} dirs, $size_human dry)${NC}"
else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge updater old versions ${GREEN}(${cleaned_count} dirs, $size_human)${NC}"
fi
((files_cleaned += cleaned_count))
((total_size_cleaned += total_size))
((total_items++))
note_activity
fi
}
scan_external_volumes() {
[[ -d "/Volumes" ]] || return 0
local -a candidate_volumes=()
local -a network_volumes=()
for volume in /Volumes/*; do
[[ -d "$volume" && -w "$volume" && ! -L "$volume" ]] || continue
[[ "$volume" == "/" || "$volume" == "/Volumes/Macintosh HD" ]] && continue
local protocol=""
protocol=$(run_with_timeout 1 command diskutil info "$volume" 2> /dev/null | grep -i "Protocol:" | awk '{print $2}' || echo "")
case "$protocol" in
SMB | NFS | AFP | CIFS | WebDAV)
network_volumes+=("$volume")
continue
;;
esac
local fs_type=""
fs_type=$(run_with_timeout 1 command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}' || echo "")
case "$fs_type" in
nfs | smbfs | afpfs | cifs | webdav)
network_volumes+=("$volume")
continue
;;
esac
candidate_volumes+=("$volume")
done
local volume_count=${#candidate_volumes[@]}
local network_count=${#network_volumes[@]}
if [[ $volume_count -eq 0 ]]; then
if [[ $network_count -gt 0 ]]; then
echo -e " ${GRAY}${ICON_LIST}${NC} External volumes (${network_count} network volume(s) skipped)"
note_activity
fi
return 0
fi
start_section_spinner "Scanning $volume_count external volume(s)..."
for volume in "${candidate_volumes[@]}"; do
[[ -d "$volume" && -r "$volume" ]] || continue
local volume_trash="$volume/.Trashes"
if [[ -d "$volume_trash" && "$DRY_RUN" != "true" ]] && ! is_path_whitelisted "$volume_trash"; then
while IFS= read -r -d '' item; do
safe_remove "$item" true || true
done < <(command find "$volume_trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true)
fi
if [[ "$PROTECT_FINDER_METADATA" != "true" ]]; then
clean_ds_store_tree "$volume" "$(basename "$volume") volume (.DS_Store)"
fi
done
stop_section_spinner
}
# Finder metadata (.DS_Store).
clean_finder_metadata() {
stop_section_spinner
if [[ "$PROTECT_FINDER_METADATA" == "true" ]]; then
note_activity
echo -e " ${GREEN}${ICON_EMPTY}${NC} Finder metadata · whitelist protected"
return
fi
clean_ds_store_tree "$HOME" "Home directory (.DS_Store)"
}
# macOS system caches and user-level leftovers.
clean_macos_system_caches() {
stop_section_spinner
# safe_clean already checks protected paths.
safe_clean ~/Library/Saved\ Application\ State/* "Saved application states" || true
safe_clean ~/Library/Caches/com.apple.photoanalysisd "Photo analysis cache" || true
safe_clean ~/Library/Caches/com.apple.akd "Apple ID cache" || true
safe_clean ~/Library/Caches/com.apple.WebKit.Networking/* "WebKit network cache" || true
safe_clean ~/Library/DiagnosticReports/* "Diagnostic reports" || true
safe_clean ~/Library/Caches/com.apple.QuickLook.thumbnailcache "QuickLook thumbnails" || true
safe_clean ~/Library/Caches/Quick\ Look/* "QuickLook cache" || true
safe_clean ~/Library/Caches/com.apple.iconservices* "Icon services cache" || true
safe_clean ~/Downloads/*.download "Safari incomplete downloads" || true
safe_clean ~/Downloads/*.crdownload "Chrome incomplete downloads" || true
safe_clean ~/Downloads/*.part "Partial incomplete downloads" || true
safe_clean ~/Library/Autosave\ Information/* "Autosave information" || true
safe_clean ~/Library/IdentityCaches/* "Identity caches" || true
safe_clean ~/Library/Suggestions/* "Siri suggestions cache" || true
safe_clean ~/Library/Calendars/Calendar\ Cache "Calendar cache" || true
safe_clean ~/Library/Application\ Support/AddressBook/Sources/*/Photos.cache "Address Book photo cache" || true
}
clean_recent_items() {
stop_section_spinner
local shared_dir="$HOME/Library/Application Support/com.apple.sharedfilelist"
local -a recent_lists=(
"$shared_dir/com.apple.LSSharedFileList.RecentApplications.sfl2"
"$shared_dir/com.apple.LSSharedFileList.RecentDocuments.sfl2"
"$shared_dir/com.apple.LSSharedFileList.RecentServers.sfl2"
"$shared_dir/com.apple.LSSharedFileList.RecentHosts.sfl2"
"$shared_dir/com.apple.LSSharedFileList.RecentApplications.sfl"
"$shared_dir/com.apple.LSSharedFileList.RecentDocuments.sfl"
"$shared_dir/com.apple.LSSharedFileList.RecentServers.sfl"
"$shared_dir/com.apple.LSSharedFileList.RecentHosts.sfl"
)
if [[ -d "$shared_dir" ]]; then
for sfl_file in "${recent_lists[@]}"; do
[[ -e "$sfl_file" ]] && safe_clean "$sfl_file" "Recent items list" || true
done
fi
safe_clean ~/Library/Preferences/com.apple.recentitems.plist "Recent items preferences" || true
}
clean_mail_downloads() {
stop_section_spinner
local mail_age_days=${MOLE_MAIL_AGE_DAYS:-}
if ! [[ "$mail_age_days" =~ ^[0-9]+$ ]]; then
mail_age_days=30
fi
local -a mail_dirs=(
"$HOME/Library/Mail Downloads"
"$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads"
)
local count=0
local cleaned_kb=0
for target_path in "${mail_dirs[@]}"; do
if [[ -d "$target_path" ]]; then
local dir_size_kb=0
dir_size_kb=$(get_path_size_kb "$target_path")
if ! [[ "$dir_size_kb" =~ ^[0-9]+$ ]]; then
dir_size_kb=0
fi
local min_kb="${MOLE_MAIL_DOWNLOADS_MIN_KB:-}"
if ! [[ "$min_kb" =~ ^[0-9]+$ ]]; then
min_kb=5120
fi
if [[ "$dir_size_kb" -lt "$min_kb" ]]; then
continue
fi
while IFS= read -r -d '' file_path; do
if [[ -f "$file_path" ]]; then
local file_size_kb=$(get_path_size_kb "$file_path")
if safe_remove "$file_path" true; then
((count++))
((cleaned_kb += file_size_kb))
fi
fi
done < <(command find "$target_path" -type f -mtime +"$mail_age_days" -print0 2> /dev/null || true)
fi
done
if [[ $count -gt 0 ]]; then
local cleaned_mb=$(echo "$cleaned_kb" | awk '{printf "%.1f", $1/1024}' || echo "0.0")
echo " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $count mail attachments (~${cleaned_mb}MB)"
note_activity
fi
}
# Sandboxed app caches.
clean_sandboxed_app_caches() {
stop_section_spinner
safe_clean ~/Library/Containers/com.apple.wallpaper.agent/Data/Library/Caches/* "Wallpaper agent cache"
safe_clean ~/Library/Containers/com.apple.mediaanalysisd/Data/Library/Caches/* "Media analysis cache"
safe_clean ~/Library/Containers/com.apple.AppStore/Data/Library/Caches/* "App Store cache"
safe_clean ~/Library/Containers/com.apple.configurator.xpc.InternetService/Data/tmp/* "Apple Configurator temp files"
local containers_dir="$HOME/Library/Containers"
[[ ! -d "$containers_dir" ]] && return 0
start_section_spinner "Scanning sandboxed apps..."
local total_size=0
local cleaned_count=0
local found_any=false
# Use nullglob to avoid literal globs.
local _ng_state
_ng_state=$(shopt -p nullglob || true)
shopt -s nullglob
for container_dir in "$containers_dir"/*; do
process_container_cache "$container_dir"
done
eval "$_ng_state"
stop_section_spinner
if [[ "$found_any" == "true" ]]; then
local size_human=$(bytes_to_human "$((total_size * 1024))")
if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Sandboxed app caches ${YELLOW}($size_human dry)${NC}"
else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Sandboxed app caches ${GREEN}($size_human)${NC}"
fi
((files_cleaned += cleaned_count))
((total_size_cleaned += total_size))
((total_items++))
note_activity
fi
}
# Process a single container cache directory.
process_container_cache() {
local container_dir="$1"
[[ -d "$container_dir" ]] || return 0
local bundle_id=$(basename "$container_dir")
if is_critical_system_component "$bundle_id"; then
return 0
fi
if should_protect_data "$bundle_id" || should_protect_data "$(echo "$bundle_id" | LC_ALL=C tr '[:upper:]' '[:lower:]')"; then
return 0
fi
local cache_dir="$container_dir/Data/Library/Caches"
[[ -d "$cache_dir" ]] || return 0
# Fast non-empty check.
if find "$cache_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then
local size=$(get_path_size_kb "$cache_dir")
((total_size += size))
found_any=true
((cleaned_count++))
if [[ "$DRY_RUN" != "true" ]]; then
# Clean contents safely with local nullglob.
local _ng_state
_ng_state=$(shopt -p nullglob || true)
shopt -s nullglob
for item in "$cache_dir"/*; do
[[ -e "$item" ]] || continue
safe_remove "$item" true || true
done
eval "$_ng_state"
fi
fi
}
# Browser caches (Safari/Chrome/Edge/Firefox).
clean_browsers() {
stop_section_spinner
safe_clean ~/Library/Caches/com.apple.Safari/* "Safari cache"
# Chrome/Chromium.
safe_clean ~/Library/Caches/Google/Chrome/* "Chrome cache"
safe_clean ~/Library/Application\ Support/Google/Chrome/*/Application\ Cache/* "Chrome app cache"
safe_clean ~/Library/Application\ Support/Google/Chrome/*/GPUCache/* "Chrome GPU cache"
safe_clean ~/Library/Caches/Chromium/* "Chromium cache"
safe_clean ~/Library/Caches/com.microsoft.edgemac/* "Edge cache"
safe_clean ~/Library/Caches/company.thebrowser.Browser/* "Arc cache"
safe_clean ~/Library/Caches/company.thebrowser.dia/* "Dia cache"
safe_clean ~/Library/Caches/BraveSoftware/Brave-Browser/* "Brave cache"
local firefox_running=false
if pgrep -x "Firefox" > /dev/null 2>&1; then
firefox_running=true
fi
if [[ "$firefox_running" == "true" ]]; then
echo -e " ${YELLOW}${ICON_WARNING}${NC} Firefox is running · cache cleanup skipped"
else
safe_clean ~/Library/Caches/Firefox/* "Firefox cache"
fi
safe_clean ~/Library/Caches/com.operasoftware.Opera/* "Opera cache"
safe_clean ~/Library/Caches/com.vivaldi.Vivaldi/* "Vivaldi cache"
safe_clean ~/Library/Caches/Comet/* "Comet cache"
safe_clean ~/Library/Caches/com.kagi.kagimacOS/* "Orion cache"
safe_clean ~/Library/Caches/zen/* "Zen cache"
if [[ "$firefox_running" == "true" ]]; then
echo -e " ${YELLOW}${ICON_WARNING}${NC} Firefox is running · profile cache cleanup skipped"
else
safe_clean ~/Library/Application\ Support/Firefox/Profiles/*/cache2/* "Firefox profile cache"
fi
clean_chrome_old_versions
clean_edge_old_versions
clean_edge_updater_old_versions
}
# Cloud storage caches.
clean_cloud_storage() {
stop_section_spinner
safe_clean ~/Library/Caches/com.dropbox.* "Dropbox cache"
safe_clean ~/Library/Caches/com.getdropbox.dropbox "Dropbox cache"
safe_clean ~/Library/Caches/com.google.GoogleDrive "Google Drive cache"
safe_clean ~/Library/Caches/com.baidu.netdisk "Baidu Netdisk cache"
safe_clean ~/Library/Caches/com.alibaba.teambitiondisk "Alibaba Cloud cache"
safe_clean ~/Library/Caches/com.box.desktop "Box cache"
safe_clean ~/Library/Caches/com.microsoft.OneDrive "OneDrive cache"
}
# Office app caches.
clean_office_applications() {
stop_section_spinner
safe_clean ~/Library/Caches/com.microsoft.Word "Microsoft Word cache"
safe_clean ~/Library/Caches/com.microsoft.Excel "Microsoft Excel cache"
safe_clean ~/Library/Caches/com.microsoft.Powerpoint "Microsoft PowerPoint cache"
safe_clean ~/Library/Caches/com.microsoft.Outlook/* "Microsoft Outlook cache"
safe_clean ~/Library/Caches/com.apple.iWork.* "Apple iWork cache"
safe_clean ~/Library/Caches/com.kingsoft.wpsoffice.mac "WPS Office cache"
safe_clean ~/Library/Caches/org.mozilla.thunderbird/* "Thunderbird cache"
safe_clean ~/Library/Caches/com.apple.mail/* "Apple Mail cache"
}
# Virtualization caches.
clean_virtualization_tools() {
stop_section_spinner
safe_clean ~/Library/Caches/com.vmware.fusion "VMware Fusion cache"
safe_clean ~/Library/Caches/com.parallels.* "Parallels cache"
safe_clean ~/VirtualBox\ VMs/.cache "VirtualBox cache"
safe_clean ~/.vagrant.d/tmp/* "Vagrant temporary files"
}
# Application Support logs/caches.
clean_application_support_logs() {
stop_section_spinner
if [[ ! -d "$HOME/Library/Application Support" ]] || ! ls "$HOME/Library/Application Support" > /dev/null 2>&1; then
note_activity
echo -e " ${YELLOW}${ICON_WARNING}${NC} Skipped: No permission to access Application Support"
return 0
fi
start_section_spinner "Scanning Application Support..."
local total_size=0
local cleaned_count=0
local found_any=false
# Enable nullglob for safe globbing.
local _ng_state
_ng_state=$(shopt -p nullglob || true)
shopt -s nullglob
for app_dir in ~/Library/Application\ Support/*; do
[[ -d "$app_dir" ]] || continue
local app_name=$(basename "$app_dir")
local app_name_lower=$(echo "$app_name" | LC_ALL=C tr '[:upper:]' '[:lower:]')
local is_protected=false
if should_protect_data "$app_name"; then
is_protected=true
elif should_protect_data "$app_name_lower"; then
is_protected=true
fi
if [[ "$is_protected" == "true" ]]; then
continue
fi
if is_critical_system_component "$app_name"; then
continue
fi
local -a start_candidates=("$app_dir/log" "$app_dir/logs" "$app_dir/activitylog" "$app_dir/Cache/Cache_Data" "$app_dir/Crashpad/completed")
for candidate in "${start_candidates[@]}"; do
if [[ -d "$candidate" ]]; then
if find "$candidate" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then
local size=$(get_path_size_kb "$candidate")
((total_size += size))
((cleaned_count++))
found_any=true
if [[ "$DRY_RUN" != "true" ]]; then
for item in "$candidate"/*; do
[[ -e "$item" ]] || continue
safe_remove "$item" true > /dev/null 2>&1 || true
done
fi
fi
fi
done
done
# Group Containers logs (explicit allowlist).
local known_group_containers=(
"group.com.apple.contentdelivery"
)
for container in "${known_group_containers[@]}"; do
local container_path="$HOME/Library/Group Containers/$container"
local -a gc_candidates=("$container_path/Logs" "$container_path/Library/Logs")
for candidate in "${gc_candidates[@]}"; do
if [[ -d "$candidate" ]]; then
if find "$candidate" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then
local size=$(get_path_size_kb "$candidate")
((total_size += size))
((cleaned_count++))
found_any=true
if [[ "$DRY_RUN" != "true" ]]; then
for item in "$candidate"/*; do
[[ -e "$item" ]] || continue
safe_remove "$item" true > /dev/null 2>&1 || true
done
fi
fi
fi
done
done
eval "$_ng_state"
stop_section_spinner
if [[ "$found_any" == "true" ]]; then
local size_human=$(bytes_to_human "$((total_size * 1024))")
if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Application Support logs/caches ${YELLOW}($size_human dry)${NC}"
else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Application Support logs/caches ${GREEN}($size_human)${NC}"
fi
((files_cleaned += cleaned_count))
((total_size_cleaned += total_size))
((total_items++))
note_activity
fi
}
# iOS device backup info.
check_ios_device_backups() {
local backup_dir="$HOME/Library/Application Support/MobileSync/Backup"
# Simplified check without find to avoid hanging.
if [[ -d "$backup_dir" ]]; then
local backup_kb=$(get_path_size_kb "$backup_dir")
if [[ -n "${backup_kb:-}" && "$backup_kb" -gt 102400 ]]; then
local backup_human=$(command du -sh "$backup_dir" 2> /dev/null | awk '{print $1}')
if [[ -n "$backup_human" ]]; then
note_activity
echo -e " Found ${GREEN}${backup_human}${NC} iOS backups"
echo -e " You can delete them manually: ${backup_dir}"
fi
fi
fi
return 0
}
# Apple Silicon specific caches (IS_M_SERIES).
clean_apple_silicon_caches() {
if [[ "${IS_M_SERIES:-false}" != "true" ]]; then
return 0
fi
start_section "Apple Silicon updates"
safe_clean /Library/Apple/usr/share/rosetta/rosetta_update_bundle "Rosetta 2 cache"
safe_clean ~/Library/Caches/com.apple.rosetta.update "Rosetta 2 user cache"
safe_clean ~/Library/Caches/com.apple.amp.mediasevicesd "Apple Silicon media service cache"
end_section
}