mirror of
https://github.com/tw93/Mole.git
synced 2026-02-08 06:14:20 +00:00
chore: restructure windows branch (move windows/ content to root, remove macos files)
This commit is contained in:
79
bin/analyze.ps1
Normal file
79
bin/analyze.ps1
Normal file
@@ -0,0 +1,79 @@
|
||||
# Mole - Analyze Command
|
||||
# Disk space analyzer wrapper
|
||||
|
||||
#Requires -Version 5.1
|
||||
param(
|
||||
[Parameter(Position = 0)]
|
||||
[string]$Path,
|
||||
[switch]$ShowHelp
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Script location
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$windowsDir = Split-Path -Parent $scriptDir
|
||||
$binPath = Join-Path $windowsDir "bin\analyze.exe"
|
||||
|
||||
# Help
|
||||
function Show-AnalyzeHelp {
|
||||
$esc = [char]27
|
||||
Write-Host ""
|
||||
Write-Host "$esc[1;35mMole Analyze$esc[0m - Interactive disk space analyzer"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mUsage:$esc[0m mole analyze [path]"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mOptions:$esc[0m"
|
||||
Write-Host " [path] Path to analyze (default: user profile)"
|
||||
Write-Host " -ShowHelp Show this help message"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mKeybindings:$esc[0m"
|
||||
Write-Host " Up/Down Navigate entries"
|
||||
Write-Host " Enter Enter directory"
|
||||
Write-Host " Backspace Go back"
|
||||
Write-Host " Space Multi-select"
|
||||
Write-Host " d Delete selected"
|
||||
Write-Host " f Toggle large files view"
|
||||
Write-Host " o Open in Explorer"
|
||||
Write-Host " r Refresh"
|
||||
Write-Host " q Quit"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
if ($ShowHelp) {
|
||||
Show-AnalyzeHelp
|
||||
return
|
||||
}
|
||||
|
||||
# Check if binary exists
|
||||
if (-not (Test-Path $binPath)) {
|
||||
Write-Host "Building analyze tool..." -ForegroundColor Cyan
|
||||
|
||||
$cmdDir = Join-Path $windowsDir "cmd\analyze"
|
||||
$binDir = Join-Path $windowsDir "bin"
|
||||
|
||||
if (-not (Test-Path $binDir)) {
|
||||
New-Item -ItemType Directory -Path $binDir -Force | Out-Null
|
||||
}
|
||||
|
||||
Push-Location $windowsDir
|
||||
try {
|
||||
$result = & go build -o "$binPath" "./cmd/analyze/" 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Failed to build analyze tool: $result" -ForegroundColor Red
|
||||
Pop-Location
|
||||
return
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
# Set path environment variable if provided
|
||||
if ($Path) {
|
||||
$env:MO_ANALYZE_PATH = $Path
|
||||
}
|
||||
|
||||
# Run the binary
|
||||
& $binPath
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Mole - Analyze command.
|
||||
# Runs the Go disk analyzer UI.
|
||||
# Uses bundled analyze-go binary.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
GO_BIN="$SCRIPT_DIR/analyze-go"
|
||||
if [[ -x "$GO_BIN" ]]; then
|
||||
exec "$GO_BIN" "$@"
|
||||
fi
|
||||
|
||||
echo "Bundled analyzer binary not found. Please reinstall Mole or run mo update to restore it." >&2
|
||||
exit 1
|
||||
101
bin/check.sh
101
bin/check.sh
@@ -1,101 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Fix locale issues (similar to Issue #83)
|
||||
export LC_ALL=C
|
||||
export LANG=C
|
||||
|
||||
# Load common functions
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "$SCRIPT_DIR/lib/core/common.sh"
|
||||
source "$SCRIPT_DIR/lib/core/sudo.sh"
|
||||
source "$SCRIPT_DIR/lib/manage/update.sh"
|
||||
source "$SCRIPT_DIR/lib/manage/autofix.sh"
|
||||
|
||||
source "$SCRIPT_DIR/lib/check/all.sh"
|
||||
|
||||
cleanup_all() {
|
||||
stop_inline_spinner 2> /dev/null || true
|
||||
stop_sudo_session
|
||||
cleanup_temp_files
|
||||
}
|
||||
|
||||
handle_interrupt() {
|
||||
cleanup_all
|
||||
exit 130
|
||||
}
|
||||
|
||||
main() {
|
||||
# Register unified cleanup handler
|
||||
trap cleanup_all EXIT
|
||||
trap handle_interrupt INT TERM
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
clear
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
|
||||
# Create temp files for parallel execution
|
||||
local updates_file=$(mktemp_file)
|
||||
local health_file=$(mktemp_file)
|
||||
local security_file=$(mktemp_file)
|
||||
local config_file=$(mktemp_file)
|
||||
|
||||
# Run all checks in parallel with spinner
|
||||
if [[ -t 1 ]]; then
|
||||
echo -ne "${PURPLE_BOLD}System Check${NC} "
|
||||
start_inline_spinner "Running checks..."
|
||||
else
|
||||
echo -e "${PURPLE_BOLD}System Check${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Parallel execution
|
||||
{
|
||||
check_all_updates > "$updates_file" 2>&1 &
|
||||
check_system_health > "$health_file" 2>&1 &
|
||||
check_all_security > "$security_file" 2>&1 &
|
||||
check_all_config > "$config_file" 2>&1 &
|
||||
wait
|
||||
}
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
stop_inline_spinner
|
||||
printf '\n'
|
||||
fi
|
||||
|
||||
# Display results
|
||||
echo -e "${BLUE}${ICON_ARROW}${NC} System updates"
|
||||
cat "$updates_file"
|
||||
|
||||
printf '\n'
|
||||
echo -e "${BLUE}${ICON_ARROW}${NC} System health"
|
||||
cat "$health_file"
|
||||
|
||||
printf '\n'
|
||||
echo -e "${BLUE}${ICON_ARROW}${NC} Security posture"
|
||||
cat "$security_file"
|
||||
|
||||
printf '\n'
|
||||
echo -e "${BLUE}${ICON_ARROW}${NC} Configuration"
|
||||
cat "$config_file"
|
||||
|
||||
# Show suggestions
|
||||
show_suggestions
|
||||
|
||||
# Ask about auto-fix
|
||||
if ask_for_auto_fix; then
|
||||
perform_auto_fix
|
||||
fi
|
||||
|
||||
# Ask about updates
|
||||
if ask_for_updates; then
|
||||
perform_updates
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
main "$@"
|
||||
300
bin/clean.ps1
Normal file
300
bin/clean.ps1
Normal file
@@ -0,0 +1,300 @@
|
||||
# Mole - Clean Command
|
||||
# Deep cleanup for Windows with dry-run support and whitelist
|
||||
|
||||
#Requires -Version 5.1
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$DryRun,
|
||||
[switch]$System,
|
||||
[switch]$DebugMode,
|
||||
[switch]$Whitelist,
|
||||
[switch]$ShowHelp
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Script location
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$libDir = Join-Path (Split-Path -Parent $scriptDir) "lib"
|
||||
|
||||
# Import core modules
|
||||
. "$libDir\core\base.ps1"
|
||||
. "$libDir\core\log.ps1"
|
||||
. "$libDir\core\ui.ps1"
|
||||
. "$libDir\core\file_ops.ps1"
|
||||
|
||||
# Import cleanup modules
|
||||
. "$libDir\clean\user.ps1"
|
||||
. "$libDir\clean\caches.ps1"
|
||||
. "$libDir\clean\dev.ps1"
|
||||
. "$libDir\clean\apps.ps1"
|
||||
. "$libDir\clean\system.ps1"
|
||||
|
||||
# ============================================================================
|
||||
# Configuration
|
||||
# ============================================================================
|
||||
|
||||
$script:ExportListFile = "$env:USERPROFILE\.config\mole\clean-list.txt"
|
||||
|
||||
# ============================================================================
|
||||
# Help
|
||||
# ============================================================================
|
||||
|
||||
function Show-CleanHelp {
|
||||
$esc = [char]27
|
||||
Write-Host ""
|
||||
Write-Host "$esc[1;35mMole Clean$esc[0m - Deep cleanup for Windows"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mUsage:$esc[0m mole clean [options]"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mOptions:$esc[0m"
|
||||
Write-Host " -DryRun Preview changes without deleting (recommended first run)"
|
||||
Write-Host " -System Include system-level cleanup (requires admin)"
|
||||
Write-Host " -Whitelist Manage protected paths"
|
||||
Write-Host " -DebugMode Enable debug logging"
|
||||
Write-Host " -ShowHelp Show this help message"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mExamples:$esc[0m"
|
||||
Write-Host " mole clean -DryRun # Preview what would be cleaned"
|
||||
Write-Host " mole clean # Run standard cleanup"
|
||||
Write-Host " mole clean -System # Include system cleanup (as admin)"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Whitelist Management
|
||||
# ============================================================================
|
||||
|
||||
function Edit-Whitelist {
|
||||
$whitelistPath = $script:Config.WhitelistFile
|
||||
$whitelistDir = Split-Path -Parent $whitelistPath
|
||||
|
||||
# Ensure directory exists
|
||||
if (-not (Test-Path $whitelistDir)) {
|
||||
New-Item -ItemType Directory -Path $whitelistDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# Create default whitelist if doesn't exist
|
||||
if (-not (Test-Path $whitelistPath)) {
|
||||
$defaultContent = @"
|
||||
# Mole Whitelist - Paths listed here will never be cleaned
|
||||
# Use full paths or patterns with wildcards (*)
|
||||
#
|
||||
# Examples:
|
||||
# C:\Users\YourName\Documents\ImportantProject
|
||||
# C:\Users\*\AppData\Local\MyApp
|
||||
# $env:LOCALAPPDATA\CriticalApp
|
||||
#
|
||||
# Add your protected paths below:
|
||||
|
||||
"@
|
||||
Set-Content -Path $whitelistPath -Value $defaultContent
|
||||
}
|
||||
|
||||
# Open in default editor
|
||||
Write-Info "Opening whitelist file: $whitelistPath"
|
||||
Start-Process notepad.exe -ArgumentList $whitelistPath -Wait
|
||||
|
||||
Write-Success "Whitelist saved"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Cleanup Summary
|
||||
# ============================================================================
|
||||
|
||||
function Show-CleanupSummary {
|
||||
param(
|
||||
[hashtable]$Stats,
|
||||
[bool]$IsDryRun
|
||||
)
|
||||
|
||||
$esc = [char]27
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "$esc[1;35m" -NoNewline
|
||||
if ($IsDryRun) {
|
||||
Write-Host "Dry run complete - no changes made" -NoNewline
|
||||
}
|
||||
else {
|
||||
Write-Host "Cleanup complete" -NoNewline
|
||||
}
|
||||
Write-Host "$esc[0m"
|
||||
Write-Host ""
|
||||
|
||||
if ($Stats.TotalSizeKB -gt 0) {
|
||||
$sizeGB = [Math]::Round($Stats.TotalSizeKB / 1024 / 1024, 2)
|
||||
|
||||
if ($IsDryRun) {
|
||||
Write-Host " Potential space: $esc[32m${sizeGB}GB$esc[0m"
|
||||
Write-Host " Items found: $($Stats.FilesCleaned)"
|
||||
Write-Host " Categories: $($Stats.TotalItems)"
|
||||
Write-Host ""
|
||||
Write-Host " Detailed list: $esc[90m$($script:ExportListFile)$esc[0m"
|
||||
Write-Host " Run without -DryRun to apply cleanup"
|
||||
}
|
||||
else {
|
||||
Write-Host " Space freed: $esc[32m${sizeGB}GB$esc[0m"
|
||||
Write-Host " Items cleaned: $($Stats.FilesCleaned)"
|
||||
Write-Host " Categories: $($Stats.TotalItems)"
|
||||
Write-Host ""
|
||||
Write-Host " Free space now: $(Get-FreeSpace)"
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ($IsDryRun) {
|
||||
Write-Host " No significant reclaimable space detected."
|
||||
}
|
||||
else {
|
||||
Write-Host " System was already clean; no additional space freed."
|
||||
}
|
||||
Write-Host " Free space now: $(Get-FreeSpace)"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main Cleanup Flow
|
||||
# ============================================================================
|
||||
|
||||
function Start-Cleanup {
|
||||
param(
|
||||
[bool]$IsDryRun,
|
||||
[bool]$IncludeSystem
|
||||
)
|
||||
|
||||
$esc = [char]27
|
||||
|
||||
# Clear screen
|
||||
Clear-Host
|
||||
Write-Host ""
|
||||
Write-Host "$esc[1;35mClean Your Windows$esc[0m"
|
||||
Write-Host ""
|
||||
|
||||
# Show mode
|
||||
if ($IsDryRun) {
|
||||
Write-Host "$esc[33mDry Run Mode$esc[0m - Preview only, no deletions"
|
||||
Write-Host ""
|
||||
|
||||
# Prepare export file
|
||||
$exportDir = Split-Path -Parent $script:ExportListFile
|
||||
if (-not (Test-Path $exportDir)) {
|
||||
New-Item -ItemType Directory -Path $exportDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$header = @"
|
||||
# Mole Cleanup Preview - $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
|
||||
#
|
||||
# How to protect files:
|
||||
# 1. Copy any path below to $($script:Config.WhitelistFile)
|
||||
# 2. Run: mole clean -Whitelist
|
||||
#
|
||||
|
||||
"@
|
||||
Set-Content -Path $script:ExportListFile -Value $header
|
||||
}
|
||||
else {
|
||||
Write-Host "$esc[90m$($script:Icons.Solid) Use -DryRun to preview, -Whitelist to manage protected paths$esc[0m"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# System cleanup confirmation
|
||||
if ($IncludeSystem -and -not $IsDryRun) {
|
||||
if (-not (Test-IsAdmin)) {
|
||||
Write-MoleWarning "System cleanup requires administrator privileges"
|
||||
Write-Host " Run PowerShell as Administrator for full cleanup"
|
||||
Write-Host ""
|
||||
$IncludeSystem = $false
|
||||
}
|
||||
else {
|
||||
Write-Host "$esc[32m$($script:Icons.Success)$esc[0m Running with Administrator privileges"
|
||||
Write-Host ""
|
||||
}
|
||||
}
|
||||
|
||||
# Show system info
|
||||
$winVer = Get-WindowsVersion
|
||||
Write-Host "$esc[34m$($script:Icons.Admin)$esc[0m $($winVer.Name) | Free space: $(Get-FreeSpace)"
|
||||
Write-Host ""
|
||||
|
||||
# Reset stats
|
||||
Reset-CleanupStats
|
||||
Set-DryRunMode -Enabled $IsDryRun
|
||||
|
||||
# Run cleanup modules
|
||||
try {
|
||||
# User essentials (temp, logs, etc.)
|
||||
Invoke-UserCleanup -TempDaysOld 7 -LogDaysOld 7
|
||||
|
||||
# Browser caches
|
||||
Clear-BrowserCaches
|
||||
|
||||
# Application caches
|
||||
Clear-AppCaches
|
||||
|
||||
# Developer tools
|
||||
Invoke-DevToolsCleanup
|
||||
|
||||
# Applications cleanup
|
||||
Invoke-AppCleanup
|
||||
|
||||
# System cleanup (if requested and admin)
|
||||
if ($IncludeSystem -and (Test-IsAdmin)) {
|
||||
Invoke-SystemCleanup
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-MoleError "Cleanup error: $_"
|
||||
}
|
||||
|
||||
# Get final stats
|
||||
$stats = Get-CleanupStats
|
||||
|
||||
# Show summary
|
||||
Show-CleanupSummary -Stats $stats -IsDryRun $IsDryRun
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main Entry Point
|
||||
# ============================================================================
|
||||
|
||||
function Main {
|
||||
# Enable debug if requested
|
||||
if ($DebugMode) {
|
||||
$env:MOLE_DEBUG = "1"
|
||||
$DebugPreference = "Continue"
|
||||
}
|
||||
|
||||
# Show help
|
||||
if ($ShowHelp) {
|
||||
Show-CleanHelp
|
||||
return
|
||||
}
|
||||
|
||||
# Manage whitelist
|
||||
if ($Whitelist) {
|
||||
Edit-Whitelist
|
||||
return
|
||||
}
|
||||
|
||||
# Set dry-run mode
|
||||
if ($DryRun) {
|
||||
$env:MOLE_DRY_RUN = "1"
|
||||
}
|
||||
else {
|
||||
$env:MOLE_DRY_RUN = "0"
|
||||
}
|
||||
|
||||
# Run cleanup
|
||||
try {
|
||||
Start-Cleanup -IsDryRun $DryRun -IncludeSystem $System
|
||||
}
|
||||
finally {
|
||||
# Cleanup temp files
|
||||
Clear-TempFiles
|
||||
}
|
||||
}
|
||||
|
||||
# Run main
|
||||
Main
|
||||
1045
bin/clean.sh
1045
bin/clean.sh
File diff suppressed because it is too large
Load Diff
@@ -1,251 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
source "$ROOT_DIR/lib/core/common.sh"
|
||||
source "$ROOT_DIR/lib/core/commands.sh"
|
||||
|
||||
command_names=()
|
||||
for entry in "${MOLE_COMMANDS[@]}"; do
|
||||
command_names+=("${entry%%:*}")
|
||||
done
|
||||
command_words="${command_names[*]}"
|
||||
|
||||
emit_zsh_subcommands() {
|
||||
for entry in "${MOLE_COMMANDS[@]}"; do
|
||||
printf " '%s:%s'\n" "${entry%%:*}" "${entry#*:}"
|
||||
done
|
||||
}
|
||||
|
||||
emit_fish_completions() {
|
||||
local cmd="$1"
|
||||
for entry in "${MOLE_COMMANDS[@]}"; do
|
||||
local name="${entry%%:*}"
|
||||
local desc="${entry#*:}"
|
||||
printf 'complete -c %s -n "__fish_mole_no_subcommand" -a %s -d "%s"\n' "$cmd" "$name" "$desc"
|
||||
done
|
||||
|
||||
printf '\n'
|
||||
printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a bash -d "generate bash completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
|
||||
printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a zsh -d "generate zsh completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
|
||||
printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a fish -d "generate fish completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
|
||||
}
|
||||
|
||||
# Auto-install mode when run without arguments
|
||||
if [[ $# -eq 0 ]]; then
|
||||
# Detect current shell
|
||||
current_shell="${SHELL##*/}"
|
||||
if [[ -z "$current_shell" ]]; then
|
||||
current_shell="$(ps -p "$PPID" -o comm= 2> /dev/null | awk '{print $1}')"
|
||||
fi
|
||||
|
||||
completion_name=""
|
||||
if command -v mole > /dev/null 2>&1; then
|
||||
completion_name="mole"
|
||||
elif command -v mo > /dev/null 2>&1; then
|
||||
completion_name="mo"
|
||||
fi
|
||||
|
||||
case "$current_shell" in
|
||||
bash)
|
||||
config_file="${HOME}/.bashrc"
|
||||
[[ -f "${HOME}/.bash_profile" ]] && config_file="${HOME}/.bash_profile"
|
||||
# shellcheck disable=SC2016
|
||||
completion_line='if output="$('"$completion_name"' completion bash 2>/dev/null)"; then eval "$output"; fi'
|
||||
;;
|
||||
zsh)
|
||||
config_file="${HOME}/.zshrc"
|
||||
# shellcheck disable=SC2016
|
||||
completion_line='if output="$('"$completion_name"' completion zsh 2>/dev/null)"; then eval "$output"; fi'
|
||||
;;
|
||||
fish)
|
||||
config_file="${HOME}/.config/fish/config.fish"
|
||||
# shellcheck disable=SC2016
|
||||
completion_line='set -l output ('"$completion_name"' completion fish 2>/dev/null); and echo "$output" | source'
|
||||
;;
|
||||
*)
|
||||
log_error "Unsupported shell: $current_shell"
|
||||
echo " mole completion <bash|zsh|fish>"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -z "$completion_name" ]]; then
|
||||
if [[ -f "$config_file" ]] && grep -Eq "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" 2> /dev/null; then
|
||||
original_mode=""
|
||||
original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)"
|
||||
temp_file="$(mktemp)"
|
||||
grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true
|
||||
mv "$temp_file" "$config_file"
|
||||
if [[ -n "$original_mode" ]]; then
|
||||
chmod "$original_mode" "$config_file" 2> /dev/null || true
|
||||
fi
|
||||
echo -e "${GREEN}${ICON_SUCCESS}${NC} Removed stale completion entries from $config_file"
|
||||
echo ""
|
||||
fi
|
||||
log_error "mole not found in PATH - install Mole before enabling completion"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if already installed and normalize to latest line
|
||||
if [[ -f "$config_file" ]] && grep -Eq "(mole|mo)[[:space:]]+completion" "$config_file" 2> /dev/null; then
|
||||
original_mode=""
|
||||
original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)"
|
||||
temp_file="$(mktemp)"
|
||||
grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true
|
||||
mv "$temp_file" "$config_file"
|
||||
if [[ -n "$original_mode" ]]; then
|
||||
chmod "$original_mode" "$config_file" 2> /dev/null || true
|
||||
fi
|
||||
{
|
||||
echo ""
|
||||
echo "# Mole shell completion"
|
||||
echo "$completion_line"
|
||||
} >> "$config_file"
|
||||
echo ""
|
||||
echo -e "${GREEN}${ICON_SUCCESS}${NC} Shell completion updated in $config_file"
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Prompt user for installation
|
||||
echo ""
|
||||
echo -e "${GRAY}Will add to ${config_file}:${NC}"
|
||||
echo " $completion_line"
|
||||
echo ""
|
||||
echo -ne "${PURPLE}${ICON_ARROW}${NC} Enable completion for ${GREEN}${current_shell}${NC}? ${GRAY}Enter confirm / Q cancel${NC}: "
|
||||
IFS= read -r -s -n1 key || key=""
|
||||
drain_pending_input
|
||||
echo ""
|
||||
|
||||
case "$key" in
|
||||
$'\e' | [Qq] | [Nn])
|
||||
echo -e "${YELLOW}Cancelled${NC}"
|
||||
exit 0
|
||||
;;
|
||||
"" | $'\n' | $'\r' | [Yy]) ;;
|
||||
*)
|
||||
log_error "Invalid key"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Create config file if it doesn't exist
|
||||
if [[ ! -f "$config_file" ]]; then
|
||||
mkdir -p "$(dirname "$config_file")"
|
||||
touch "$config_file"
|
||||
fi
|
||||
|
||||
# Remove previous Mole completion lines to avoid duplicates
|
||||
if [[ -f "$config_file" ]]; then
|
||||
original_mode=""
|
||||
original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)"
|
||||
temp_file="$(mktemp)"
|
||||
grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true
|
||||
mv "$temp_file" "$config_file"
|
||||
if [[ -n "$original_mode" ]]; then
|
||||
chmod "$original_mode" "$config_file" 2> /dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Add completion line
|
||||
{
|
||||
echo ""
|
||||
echo "# Mole shell completion"
|
||||
echo "$completion_line"
|
||||
} >> "$config_file"
|
||||
|
||||
echo -e "${GREEN}${ICON_SUCCESS}${NC} Completion added to $config_file"
|
||||
echo ""
|
||||
echo ""
|
||||
echo -e "${GRAY}To activate now:${NC}"
|
||||
echo -e " ${GREEN}source $config_file${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$1" in
|
||||
bash)
|
||||
cat << EOF
|
||||
_mole_completions()
|
||||
{
|
||||
local cur_word prev_word
|
||||
cur_word="\${COMP_WORDS[\$COMP_CWORD]}"
|
||||
prev_word="\${COMP_WORDS[\$COMP_CWORD-1]}"
|
||||
|
||||
if [ "\$COMP_CWORD" -eq 1 ]; then
|
||||
COMPREPLY=( \$(compgen -W "$command_words" -- "\$cur_word") )
|
||||
else
|
||||
case "\$prev_word" in
|
||||
completion)
|
||||
COMPREPLY=( \$(compgen -W "bash zsh fish" -- "\$cur_word") )
|
||||
;;
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
||||
complete -F _mole_completions mole mo
|
||||
EOF
|
||||
;;
|
||||
zsh)
|
||||
printf '#compdef mole mo\n\n'
|
||||
printf '_mole() {\n'
|
||||
printf ' local -a subcommands\n'
|
||||
printf ' subcommands=(\n'
|
||||
emit_zsh_subcommands
|
||||
printf ' )\n'
|
||||
printf " _describe 'subcommand' subcommands\n"
|
||||
printf '}\n\n'
|
||||
printf 'compdef _mole mole mo\n'
|
||||
;;
|
||||
fish)
|
||||
printf '# Completions for mole\n'
|
||||
emit_fish_completions mole
|
||||
printf '\n# Completions for mo (alias)\n'
|
||||
emit_fish_completions mo
|
||||
printf '\nfunction __fish_mole_no_subcommand\n'
|
||||
printf ' for i in (commandline -opc)\n'
|
||||
# shellcheck disable=SC2016
|
||||
printf ' if contains -- $i %s\n' "$command_words"
|
||||
printf ' return 1\n'
|
||||
printf ' end\n'
|
||||
printf ' end\n'
|
||||
printf ' return 0\n'
|
||||
printf 'end\n\n'
|
||||
printf 'function __fish_see_subcommand_path\n'
|
||||
printf ' string match -q -- "completion" (commandline -opc)[1]\n'
|
||||
printf 'end\n'
|
||||
;;
|
||||
*)
|
||||
cat << 'EOF'
|
||||
Usage: mole completion [bash|zsh|fish]
|
||||
|
||||
Setup shell tab completion for mole and mo commands.
|
||||
|
||||
Auto-install:
|
||||
mole completion # Auto-detect shell and install
|
||||
|
||||
Manual install:
|
||||
mole completion bash # Generate bash completion script
|
||||
mole completion zsh # Generate zsh completion script
|
||||
mole completion fish # Generate fish completion script
|
||||
|
||||
Examples:
|
||||
# Auto-install (recommended)
|
||||
mole completion
|
||||
|
||||
# Manual install - Bash
|
||||
eval "$(mole completion bash)"
|
||||
|
||||
# Manual install - Zsh
|
||||
eval "$(mole completion zsh)"
|
||||
|
||||
# Manual install - Fish
|
||||
mole completion fish | source
|
||||
EOF
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
704
bin/installer.sh
704
bin/installer.sh
@@ -1,704 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Mole - Installer command
|
||||
# Find and remove installer files - .dmg, .pkg, .mpkg, .iso, .xip, .zip
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# shellcheck disable=SC2154
|
||||
# External variables set by menu_paginated.sh and environment
|
||||
declare MOLE_SELECTION_RESULT
|
||||
declare MOLE_INSTALLER_SCAN_MAX_DEPTH
|
||||
|
||||
export LC_ALL=C
|
||||
export LANG=C
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../lib/core/common.sh"
|
||||
source "$SCRIPT_DIR/../lib/ui/menu_paginated.sh"
|
||||
|
||||
cleanup() {
|
||||
if [[ "${IN_ALT_SCREEN:-0}" == "1" ]]; then
|
||||
leave_alt_screen
|
||||
IN_ALT_SCREEN=0
|
||||
fi
|
||||
show_cursor
|
||||
cleanup_temp_files
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'trap - EXIT; cleanup; exit 130' INT TERM
|
||||
|
||||
# Scan configuration
|
||||
readonly INSTALLER_SCAN_MAX_DEPTH_DEFAULT=2
|
||||
readonly INSTALLER_SCAN_PATHS=(
|
||||
"$HOME/Downloads"
|
||||
"$HOME/Desktop"
|
||||
"$HOME/Documents"
|
||||
"$HOME/Public"
|
||||
"$HOME/Library/Downloads"
|
||||
"/Users/Shared"
|
||||
"/Users/Shared/Downloads"
|
||||
"$HOME/Library/Caches/Homebrew"
|
||||
"$HOME/Library/Mobile Documents/com~apple~CloudDocs/Downloads"
|
||||
"$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads"
|
||||
"$HOME/Library/Application Support/Telegram Desktop"
|
||||
"$HOME/Downloads/Telegram Desktop"
|
||||
)
|
||||
readonly MAX_ZIP_ENTRIES=50
|
||||
ZIP_LIST_CMD=()
|
||||
IN_ALT_SCREEN=0
|
||||
|
||||
if command -v zipinfo > /dev/null 2>&1; then
|
||||
ZIP_LIST_CMD=(zipinfo -1)
|
||||
elif command -v unzip > /dev/null 2>&1; then
|
||||
ZIP_LIST_CMD=(unzip -Z -1)
|
||||
fi
|
||||
|
||||
TERMINAL_WIDTH=0
|
||||
|
||||
# Check for installer payloads inside ZIP - check first N entries for installer patterns
|
||||
is_installer_zip() {
|
||||
local zip="$1"
|
||||
local cap="$MAX_ZIP_ENTRIES"
|
||||
|
||||
[[ ${#ZIP_LIST_CMD[@]} -gt 0 ]] || return 1
|
||||
|
||||
if ! "${ZIP_LIST_CMD[@]}" "$zip" 2> /dev/null |
|
||||
head -n "$cap" |
|
||||
awk '
|
||||
/\.(app|pkg|dmg|xip)(\/|$)/ { found=1; exit 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
'; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
handle_candidate_file() {
|
||||
local file="$1"
|
||||
|
||||
[[ -L "$file" ]] && return 0 # Skip symlinks explicitly
|
||||
case "$file" in
|
||||
*.dmg | *.pkg | *.mpkg | *.iso | *.xip)
|
||||
echo "$file"
|
||||
;;
|
||||
*.zip)
|
||||
[[ -r "$file" ]] || return 0
|
||||
if is_installer_zip "$file" 2> /dev/null; then
|
||||
echo "$file"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
scan_installers_in_path() {
|
||||
local path="$1"
|
||||
local max_depth="${MOLE_INSTALLER_SCAN_MAX_DEPTH:-$INSTALLER_SCAN_MAX_DEPTH_DEFAULT}"
|
||||
|
||||
[[ -d "$path" ]] || return 0
|
||||
|
||||
local file
|
||||
|
||||
if command -v fd > /dev/null 2>&1; then
|
||||
while IFS= read -r file; do
|
||||
handle_candidate_file "$file"
|
||||
done < <(
|
||||
fd --no-ignore --hidden --type f --max-depth "$max_depth" \
|
||||
-e dmg -e pkg -e mpkg -e iso -e xip -e zip \
|
||||
. "$path" 2> /dev/null || true
|
||||
)
|
||||
else
|
||||
while IFS= read -r file; do
|
||||
handle_candidate_file "$file"
|
||||
done < <(
|
||||
find "$path" -maxdepth "$max_depth" -type f \
|
||||
\( -name '*.dmg' -o -name '*.pkg' -o -name '*.mpkg' \
|
||||
-o -name '*.iso' -o -name '*.xip' -o -name '*.zip' \) \
|
||||
2> /dev/null || true
|
||||
)
|
||||
fi
|
||||
}
|
||||
|
||||
scan_all_installers() {
|
||||
for path in "${INSTALLER_SCAN_PATHS[@]}"; do
|
||||
scan_installers_in_path "$path"
|
||||
done
|
||||
}
|
||||
|
||||
# Initialize stats
|
||||
declare -i total_deleted=0
|
||||
declare -i total_size_freed_kb=0
|
||||
|
||||
# Global arrays for installer data
|
||||
declare -a INSTALLER_PATHS=()
|
||||
declare -a INSTALLER_SIZES=()
|
||||
declare -a INSTALLER_SOURCES=()
|
||||
declare -a DISPLAY_NAMES=()
|
||||
|
||||
# Get source directory display name - for example "Downloads" or "Desktop"
|
||||
get_source_display() {
|
||||
local file_path="$1"
|
||||
local dir_path="${file_path%/*}"
|
||||
|
||||
# Match against known paths and return friendly names
|
||||
case "$dir_path" in
|
||||
"$HOME/Downloads"*) echo "Downloads" ;;
|
||||
"$HOME/Desktop"*) echo "Desktop" ;;
|
||||
"$HOME/Documents"*) echo "Documents" ;;
|
||||
"$HOME/Public"*) echo "Public" ;;
|
||||
"$HOME/Library/Downloads"*) echo "Library" ;;
|
||||
"/Users/Shared"*) echo "Shared" ;;
|
||||
"$HOME/Library/Caches/Homebrew"*) echo "Homebrew" ;;
|
||||
"$HOME/Library/Mobile Documents/com~apple~CloudDocs/Downloads"*) echo "iCloud" ;;
|
||||
"$HOME/Library/Containers/com.apple.mail"*) echo "Mail" ;;
|
||||
*"Telegram Desktop"*) echo "Telegram" ;;
|
||||
*) echo "${dir_path##*/}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
get_terminal_width() {
|
||||
if [[ $TERMINAL_WIDTH -le 0 ]]; then
|
||||
TERMINAL_WIDTH=$(tput cols 2> /dev/null || echo 80)
|
||||
fi
|
||||
echo "$TERMINAL_WIDTH"
|
||||
}
|
||||
|
||||
# Format installer display with alignment - similar to purge command
|
||||
format_installer_display() {
|
||||
local filename="$1"
|
||||
local size_str="$2"
|
||||
local source="$3"
|
||||
|
||||
# Terminal width for alignment
|
||||
local terminal_width
|
||||
terminal_width=$(get_terminal_width)
|
||||
local fixed_width=24 # Reserve for size and source
|
||||
local available_width=$((terminal_width - fixed_width))
|
||||
|
||||
# Bounds check: 20-40 chars for filename
|
||||
[[ $available_width -lt 20 ]] && available_width=20
|
||||
[[ $available_width -gt 40 ]] && available_width=40
|
||||
|
||||
# Truncate filename if needed
|
||||
local truncated_name
|
||||
truncated_name=$(truncate_by_display_width "$filename" "$available_width")
|
||||
local current_width
|
||||
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: "filename size | source"
|
||||
printf "%-*s %8s | %-10s" "$printf_width" "$truncated_name" "$size_str" "$source"
|
||||
}
|
||||
|
||||
# Collect all installers with their metadata
|
||||
collect_installers() {
|
||||
# Clear previous results
|
||||
INSTALLER_PATHS=()
|
||||
INSTALLER_SIZES=()
|
||||
INSTALLER_SOURCES=()
|
||||
DISPLAY_NAMES=()
|
||||
|
||||
# Start scanning with spinner
|
||||
if [[ -t 1 ]]; then
|
||||
start_inline_spinner "Scanning for installers..."
|
||||
fi
|
||||
|
||||
# Start debug session
|
||||
debug_operation_start "Collect Installers" "Scanning for redundant installer files"
|
||||
|
||||
# Scan all paths, deduplicate, and sort results
|
||||
local -a all_files=()
|
||||
|
||||
while IFS= read -r file; do
|
||||
[[ -z "$file" ]] && continue
|
||||
all_files+=("$file")
|
||||
debug_file_action "Found installer" "$file"
|
||||
done < <(scan_all_installers | sort -u)
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
stop_inline_spinner
|
||||
fi
|
||||
|
||||
if [[ ${#all_files[@]} -eq 0 ]]; then
|
||||
if [[ "${IN_ALT_SCREEN:-0}" != "1" ]]; then
|
||||
echo -e "${GREEN}${ICON_SUCCESS}${NC} Great! No installer files to clean"
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Calculate sizes with spinner
|
||||
if [[ -t 1 ]]; then
|
||||
start_inline_spinner "Calculating sizes..."
|
||||
fi
|
||||
|
||||
# Process each installer
|
||||
for file in "${all_files[@]}"; do
|
||||
# Calculate file size
|
||||
local file_size=0
|
||||
if [[ -f "$file" ]]; then
|
||||
file_size=$(get_file_size "$file")
|
||||
fi
|
||||
|
||||
# Get source directory
|
||||
local source
|
||||
source=$(get_source_display "$file")
|
||||
|
||||
# Format human readable size
|
||||
local size_human
|
||||
size_human=$(bytes_to_human "$file_size")
|
||||
|
||||
# Get display filename - strip Homebrew hash prefix if present
|
||||
local display_name
|
||||
display_name=$(basename "$file")
|
||||
if [[ "$source" == "Homebrew" ]]; then
|
||||
# Homebrew names often look like: sha256--name--version
|
||||
# Strip the leading hash if it matches [0-9a-f]{64}--
|
||||
if [[ "$display_name" =~ ^[0-9a-f]{64}--(.*) ]]; then
|
||||
display_name="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Format display with alignment
|
||||
local display
|
||||
display=$(format_installer_display "$display_name" "$size_human" "$source")
|
||||
|
||||
# Store installer data in parallel arrays
|
||||
INSTALLER_PATHS+=("$file")
|
||||
INSTALLER_SIZES+=("$file_size")
|
||||
INSTALLER_SOURCES+=("$source")
|
||||
DISPLAY_NAMES+=("$display")
|
||||
done
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
stop_inline_spinner
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Installer selector with Select All / Invert support
|
||||
select_installers() {
|
||||
local -a items=("$@")
|
||||
local total_items=${#items[@]}
|
||||
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 unselected by default)
|
||||
local -a selected=()
|
||||
for ((i = 0; i < total_items; i++)); do
|
||||
selected[i]=false
|
||||
done
|
||||
|
||||
local original_stty=""
|
||||
if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then
|
||||
original_stty=$(stty -g 2> /dev/null || echo "")
|
||||
fi
|
||||
|
||||
restore_terminal() {
|
||||
trap - EXIT INT TERM
|
||||
if [[ "${IN_ALT_SCREEN:-0}" == "1" ]]; then
|
||||
leave_alt_screen
|
||||
IN_ALT_SCREEN=0
|
||||
fi
|
||||
show_cursor
|
||||
if [[ -n "${original_stty:-}" ]]; then
|
||||
stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
handle_interrupt() {
|
||||
restore_terminal
|
||||
exit 130
|
||||
}
|
||||
|
||||
draw_menu() {
|
||||
items_per_page=$(_get_items_per_page)
|
||||
|
||||
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 selected size and count
|
||||
local selected_size=0
|
||||
local selected_count=0
|
||||
for ((i = 0; i < total_items; i++)); do
|
||||
if [[ ${selected[i]} == true ]]; then
|
||||
selected_size=$((selected_size + ${INSTALLER_SIZES[i]:-0}))
|
||||
((selected_count++))
|
||||
fi
|
||||
done
|
||||
local selected_human
|
||||
selected_human=$(bytes_to_human "$selected_size")
|
||||
|
||||
# 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 "${PURPLE_BOLD}Select Installers to Remove${NC}%s ${GRAY}- ${selected_human} ($selected_count selected)${NC}\n" "$scroll_indicator"
|
||||
printf "%s\n" "$clear_line"
|
||||
|
||||
# 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 rel_pos=$((i - top_index))
|
||||
if [[ $rel_pos -eq $cursor_pos ]]; then
|
||||
printf "%s${CYAN}${ICON_ARROW} %s %s${NC}\n" "$clear_line" "$checkbox" "${items[i]}"
|
||||
else
|
||||
printf "%s %s %s\n" "$clear_line" "$checkbox" "${items[i]}"
|
||||
fi
|
||||
done
|
||||
|
||||
# Fill empty slots
|
||||
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
|
||||
stty -echo -icanon intr ^C 2> /dev/null || true
|
||||
hide_cursor
|
||||
if [[ -t 1 ]]; then
|
||||
printf "\033[2J\033[H" >&2
|
||||
fi
|
||||
|
||||
# Main loop
|
||||
while true; do
|
||||
draw_menu
|
||||
|
||||
IFS= read -r -s -n1 key || key=""
|
||||
case "$key" in
|
||||
$'\x1b')
|
||||
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
|
||||
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
|
||||
MOLE_SELECTION_RESULT=""
|
||||
for ((i = 0; i < total_items; i++)); do
|
||||
if [[ ${selected[i]} == true ]]; then
|
||||
[[ -n "$MOLE_SELECTION_RESULT" ]] && MOLE_SELECTION_RESULT+=","
|
||||
MOLE_SELECTION_RESULT+="$i"
|
||||
fi
|
||||
done
|
||||
restore_terminal
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Show menu for user selection
|
||||
show_installer_menu() {
|
||||
if [[ ${#DISPLAY_NAMES[@]} -eq 0 ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
MOLE_SELECTION_RESULT=""
|
||||
if ! select_installers "${DISPLAY_NAMES[@]}"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Delete selected installers
|
||||
delete_selected_installers() {
|
||||
# Parse selection indices
|
||||
local -a selected_indices=()
|
||||
[[ -n "$MOLE_SELECTION_RESULT" ]] && IFS=',' read -ra selected_indices <<< "$MOLE_SELECTION_RESULT"
|
||||
|
||||
if [[ ${#selected_indices[@]} -eq 0 ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Calculate total size for confirmation
|
||||
local confirm_size=0
|
||||
for idx in "${selected_indices[@]}"; do
|
||||
if [[ "$idx" =~ ^[0-9]+$ ]] && [[ $idx -lt ${#INSTALLER_SIZES[@]} ]]; then
|
||||
confirm_size=$((confirm_size + ${INSTALLER_SIZES[$idx]:-0}))
|
||||
fi
|
||||
done
|
||||
local confirm_human
|
||||
confirm_human=$(bytes_to_human "$confirm_size")
|
||||
|
||||
# Show files to be deleted
|
||||
echo -e "${PURPLE_BOLD}Files to be removed:${NC}"
|
||||
for idx in "${selected_indices[@]}"; do
|
||||
if [[ "$idx" =~ ^[0-9]+$ ]] && [[ $idx -lt ${#INSTALLER_PATHS[@]} ]]; then
|
||||
local file_path="${INSTALLER_PATHS[$idx]}"
|
||||
local file_size="${INSTALLER_SIZES[$idx]}"
|
||||
local size_human
|
||||
size_human=$(bytes_to_human "$file_size")
|
||||
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $(basename "$file_path") ${GRAY}(${size_human})${NC}"
|
||||
fi
|
||||
done
|
||||
|
||||
# Confirm deletion
|
||||
echo ""
|
||||
echo -ne "${PURPLE}${ICON_ARROW}${NC} Delete ${#selected_indices[@]} installer(s) (${confirm_human}) ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: "
|
||||
|
||||
IFS= read -r -s -n1 confirm || confirm=""
|
||||
case "$confirm" in
|
||||
$'\e' | q | Q)
|
||||
return 1
|
||||
;;
|
||||
"" | $'\n' | $'\r')
|
||||
printf "\r\033[K" # Clear prompt line
|
||||
echo "" # Single line break
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Delete each selected installer with spinner
|
||||
total_deleted=0
|
||||
total_size_freed_kb=0
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
start_inline_spinner "Removing installers..."
|
||||
fi
|
||||
|
||||
for idx in "${selected_indices[@]}"; do
|
||||
if [[ ! "$idx" =~ ^[0-9]+$ ]] || [[ $idx -ge ${#INSTALLER_PATHS[@]} ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
local file_path="${INSTALLER_PATHS[$idx]}"
|
||||
local file_size="${INSTALLER_SIZES[$idx]}"
|
||||
|
||||
# Validate path before deletion
|
||||
if ! validate_path_for_deletion "$file_path"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Delete the file
|
||||
if safe_remove "$file_path" true; then
|
||||
total_size_freed_kb=$((total_size_freed_kb + ((file_size + 1023) / 1024)))
|
||||
total_deleted=$((total_deleted + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
stop_inline_spinner
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Perform the installers cleanup
|
||||
perform_installers() {
|
||||
# Enter alt screen for scanning and selection
|
||||
if [[ -t 1 ]]; then
|
||||
enter_alt_screen
|
||||
IN_ALT_SCREEN=1
|
||||
printf "\033[2J\033[H" >&2
|
||||
fi
|
||||
|
||||
# Collect installers
|
||||
if ! collect_installers; then
|
||||
if [[ -t 1 ]]; then
|
||||
leave_alt_screen
|
||||
IN_ALT_SCREEN=0
|
||||
fi
|
||||
printf '\n'
|
||||
echo -e "${GREEN}${ICON_SUCCESS}${NC} Great! No installer files to clean"
|
||||
printf '\n'
|
||||
return 2 # Nothing to clean
|
||||
fi
|
||||
|
||||
# Show menu
|
||||
if ! show_installer_menu; then
|
||||
if [[ -t 1 ]]; then
|
||||
leave_alt_screen
|
||||
IN_ALT_SCREEN=0
|
||||
fi
|
||||
return 1 # User cancelled
|
||||
fi
|
||||
|
||||
# Leave alt screen before deletion (so confirmation and results are on main screen)
|
||||
if [[ -t 1 ]]; then
|
||||
leave_alt_screen
|
||||
IN_ALT_SCREEN=0
|
||||
fi
|
||||
|
||||
# Delete selected
|
||||
if ! delete_selected_installers; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
show_summary() {
|
||||
local summary_heading="Installers cleaned"
|
||||
local -a summary_details=()
|
||||
|
||||
if [[ $total_deleted -gt 0 ]]; then
|
||||
local freed_mb
|
||||
freed_mb=$(echo "$total_size_freed_kb" | awk '{printf "%.2f", $1/1024}')
|
||||
|
||||
summary_details+=("Removed ${GREEN}$total_deleted${NC} installer(s), freed ${GREEN}${freed_mb}MB${NC}")
|
||||
summary_details+=("Your Mac is cleaner now!")
|
||||
else
|
||||
summary_details+=("No installers were removed")
|
||||
fi
|
||||
|
||||
print_summary_block "$summary_heading" "${summary_details[@]}"
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
main() {
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
"--debug")
|
||||
export MO_DEBUG=1
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $arg"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
hide_cursor
|
||||
perform_installers
|
||||
local exit_code=$?
|
||||
show_cursor
|
||||
|
||||
case $exit_code in
|
||||
0)
|
||||
show_summary
|
||||
;;
|
||||
1)
|
||||
printf '\n'
|
||||
;;
|
||||
2)
|
||||
# Already handled by collect_installers
|
||||
;;
|
||||
esac
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Only run main if not in test mode
|
||||
if [[ "${MOLE_TEST_MODE:-0}" != "1" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
545
bin/optimize.ps1
Normal file
545
bin/optimize.ps1
Normal file
@@ -0,0 +1,545 @@
|
||||
# Mole - Optimize Command
|
||||
# System optimization and health checks for Windows
|
||||
|
||||
#Requires -Version 5.1
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$DryRun,
|
||||
[switch]$DebugMode,
|
||||
[switch]$ShowHelp
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Script location
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$libDir = Join-Path (Split-Path -Parent $scriptDir) "lib"
|
||||
|
||||
# Import core modules
|
||||
. "$libDir\core\base.ps1"
|
||||
. "$libDir\core\log.ps1"
|
||||
. "$libDir\core\ui.ps1"
|
||||
. "$libDir\core\file_ops.ps1"
|
||||
|
||||
# ============================================================================
|
||||
# Configuration
|
||||
# ============================================================================
|
||||
|
||||
$script:OptimizationsApplied = 0
|
||||
$script:IssuesFound = 0
|
||||
$script:IssuesFixed = 0
|
||||
|
||||
# ============================================================================
|
||||
# Help
|
||||
# ============================================================================
|
||||
|
||||
function Show-OptimizeHelp {
|
||||
$esc = [char]27
|
||||
Write-Host ""
|
||||
Write-Host "$esc[1;35mMole Optimize$esc[0m - System optimization and health checks"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mUsage:$esc[0m mole optimize [options]"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mOptions:$esc[0m"
|
||||
Write-Host " -DryRun Preview optimizations without applying"
|
||||
Write-Host " -DebugMode Enable debug logging"
|
||||
Write-Host " -ShowHelp Show this help message"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mOptimizations:$esc[0m"
|
||||
Write-Host " - Disk defragmentation/TRIM (SSD optimization)"
|
||||
Write-Host " - Windows Search index optimization"
|
||||
Write-Host " - DNS cache flush"
|
||||
Write-Host " - Network optimization"
|
||||
Write-Host " - Startup program analysis"
|
||||
Write-Host " - System file verification"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# System Health Information
|
||||
# ============================================================================
|
||||
|
||||
function Get-SystemHealth {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Collect system health metrics
|
||||
#>
|
||||
|
||||
$health = @{}
|
||||
|
||||
# Memory info
|
||||
$os = Get-WmiObject Win32_OperatingSystem
|
||||
$health.MemoryTotalGB = [Math]::Round($os.TotalVisibleMemorySize / 1MB, 1)
|
||||
$health.MemoryUsedGB = [Math]::Round(($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / 1MB, 1)
|
||||
$health.MemoryUsedPercent = [Math]::Round((($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / $os.TotalVisibleMemorySize) * 100, 0)
|
||||
|
||||
# Disk info
|
||||
$disk = Get-WmiObject Win32_LogicalDisk -Filter "DeviceID='$env:SystemDrive'"
|
||||
$health.DiskTotalGB = [Math]::Round($disk.Size / 1GB, 0)
|
||||
$health.DiskUsedGB = [Math]::Round(($disk.Size - $disk.FreeSpace) / 1GB, 0)
|
||||
$health.DiskUsedPercent = [Math]::Round((($disk.Size - $disk.FreeSpace) / $disk.Size) * 100, 0)
|
||||
|
||||
# Uptime
|
||||
$uptime = (Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
|
||||
$health.UptimeDays = [Math]::Round($uptime.TotalDays, 1)
|
||||
|
||||
# CPU info
|
||||
$cpu = Get-WmiObject Win32_Processor
|
||||
$health.CPUName = $cpu.Name
|
||||
$health.CPUCores = $cpu.NumberOfLogicalProcessors
|
||||
|
||||
return $health
|
||||
}
|
||||
|
||||
function Show-SystemHealth {
|
||||
param([hashtable]$Health)
|
||||
|
||||
$esc = [char]27
|
||||
|
||||
Write-Host "$esc[34m$($script:Icons.Admin)$esc[0m System " -NoNewline
|
||||
Write-Host "$($Health.MemoryUsedGB)/$($Health.MemoryTotalGB)GB RAM | " -NoNewline
|
||||
Write-Host "$($Health.DiskUsedGB)/$($Health.DiskTotalGB)GB Disk | " -NoNewline
|
||||
Write-Host "Uptime $($Health.UptimeDays)d"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Optimization Tasks
|
||||
# ============================================================================
|
||||
|
||||
function Optimize-DiskDrive {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Optimize disk (defrag for HDD, TRIM for SSD)
|
||||
#>
|
||||
|
||||
$esc = [char]27
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "$esc[34m$($script:Icons.Arrow) Disk Optimization$esc[0m"
|
||||
|
||||
if (-not (Test-IsAdmin)) {
|
||||
Write-Host " $esc[33m$($script:Icons.Warning)$esc[0m Requires administrator privileges"
|
||||
return
|
||||
}
|
||||
|
||||
if ($script:DryRun) {
|
||||
Write-Host " $esc[33m$($script:Icons.DryRun)$esc[0m Would optimize $env:SystemDrive"
|
||||
$script:OptimizationsApplied++
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
# Check if SSD or HDD
|
||||
$diskNumber = (Get-Partition -DriveLetter $env:SystemDrive[0]).DiskNumber
|
||||
$mediaType = (Get-PhysicalDisk | Where-Object { $_.DeviceId -eq $diskNumber }).MediaType
|
||||
|
||||
if ($mediaType -eq "SSD") {
|
||||
Write-Host " Running TRIM on SSD..."
|
||||
$null = Optimize-Volume -DriveLetter $env:SystemDrive[0] -ReTrim -ErrorAction Stop
|
||||
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m SSD TRIM completed"
|
||||
}
|
||||
else {
|
||||
Write-Host " Running defragmentation on HDD..."
|
||||
$null = Optimize-Volume -DriveLetter $env:SystemDrive[0] -Defrag -ErrorAction Stop
|
||||
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m Defragmentation completed"
|
||||
}
|
||||
$script:OptimizationsApplied++
|
||||
}
|
||||
catch {
|
||||
Write-Host " $esc[31m$($script:Icons.Error)$esc[0m Disk optimization failed: $_"
|
||||
}
|
||||
}
|
||||
|
||||
function Optimize-SearchIndex {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Rebuild Windows Search index if needed
|
||||
#>
|
||||
|
||||
$esc = [char]27
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "$esc[34m$($script:Icons.Arrow) Windows Search$esc[0m"
|
||||
|
||||
$searchService = Get-Service -Name WSearch -ErrorAction SilentlyContinue
|
||||
|
||||
if (-not $searchService) {
|
||||
Write-Host " $esc[90mWindows Search service not found$esc[0m"
|
||||
return
|
||||
}
|
||||
|
||||
if ($searchService.Status -ne 'Running') {
|
||||
Write-Host " $esc[33m$($script:Icons.Warning)$esc[0m Windows Search service is not running"
|
||||
|
||||
if ($script:DryRun) {
|
||||
Write-Host " $esc[33m$($script:Icons.DryRun)$esc[0m Would start search service"
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Start-Service -Name WSearch -ErrorAction Stop
|
||||
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m Started Windows Search service"
|
||||
$script:OptimizationsApplied++
|
||||
}
|
||||
catch {
|
||||
Write-Host " $esc[31m$($script:Icons.Error)$esc[0m Could not start search service"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m Search service running"
|
||||
}
|
||||
}
|
||||
|
||||
function Clear-DnsCache {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clear DNS resolver cache
|
||||
#>
|
||||
|
||||
$esc = [char]27
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "$esc[34m$($script:Icons.Arrow) DNS Cache$esc[0m"
|
||||
|
||||
if ($script:DryRun) {
|
||||
Write-Host " $esc[33m$($script:Icons.DryRun)$esc[0m Would flush DNS cache"
|
||||
$script:OptimizationsApplied++
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Clear-DnsClientCache -ErrorAction Stop
|
||||
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m DNS cache flushed"
|
||||
$script:OptimizationsApplied++
|
||||
}
|
||||
catch {
|
||||
Write-Host " $esc[31m$($script:Icons.Error)$esc[0m Could not flush DNS cache: $_"
|
||||
}
|
||||
}
|
||||
|
||||
function Optimize-Network {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Network stack optimization
|
||||
#>
|
||||
|
||||
$esc = [char]27
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "$esc[34m$($script:Icons.Arrow) Network Optimization$esc[0m"
|
||||
|
||||
if (-not (Test-IsAdmin)) {
|
||||
Write-Host " $esc[33m$($script:Icons.Warning)$esc[0m Requires administrator privileges"
|
||||
return
|
||||
}
|
||||
|
||||
if ($script:DryRun) {
|
||||
Write-Host " $esc[33m$($script:Icons.DryRun)$esc[0m Would reset Winsock catalog"
|
||||
Write-Host " $esc[33m$($script:Icons.DryRun)$esc[0m Would reset TCP/IP stack"
|
||||
$script:OptimizationsApplied += 2
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
# Reset Winsock
|
||||
$null = netsh winsock reset 2>&1
|
||||
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m Winsock catalog reset"
|
||||
$script:OptimizationsApplied++
|
||||
}
|
||||
catch {
|
||||
Write-Host " $esc[31m$($script:Icons.Error)$esc[0m Winsock reset failed"
|
||||
}
|
||||
|
||||
try {
|
||||
# Flush ARP cache
|
||||
$null = netsh interface ip delete arpcache 2>&1
|
||||
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m ARP cache cleared"
|
||||
$script:OptimizationsApplied++
|
||||
}
|
||||
catch {
|
||||
Write-Host " $esc[31m$($script:Icons.Error)$esc[0m ARP cache clear failed"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-StartupPrograms {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Analyze startup programs
|
||||
#>
|
||||
|
||||
$esc = [char]27
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "$esc[34m$($script:Icons.Arrow) Startup Programs$esc[0m"
|
||||
|
||||
$startupPaths = @(
|
||||
"HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run"
|
||||
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run"
|
||||
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run"
|
||||
)
|
||||
|
||||
$startupCount = 0
|
||||
|
||||
foreach ($path in $startupPaths) {
|
||||
if (Test-Path $path) {
|
||||
$items = Get-ItemProperty -Path $path -ErrorAction SilentlyContinue
|
||||
$props = @($items.PSObject.Properties | Where-Object {
|
||||
$_.Name -notin @('PSPath', 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider')
|
||||
})
|
||||
$startupCount += $props.Count
|
||||
}
|
||||
}
|
||||
|
||||
# Also check startup folder
|
||||
$startupFolder = "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup"
|
||||
if (Test-Path $startupFolder) {
|
||||
$startupFiles = @(Get-ChildItem -Path $startupFolder -File -ErrorAction SilentlyContinue)
|
||||
$startupCount += $startupFiles.Count
|
||||
}
|
||||
|
||||
if ($startupCount -gt 10) {
|
||||
Write-Host " $esc[33m$($script:Icons.Warning)$esc[0m $startupCount startup programs (high)"
|
||||
Write-Host " $esc[90mConsider disabling unnecessary startup items in Task Manager$esc[0m"
|
||||
$script:IssuesFound++
|
||||
}
|
||||
elseif ($startupCount -gt 5) {
|
||||
Write-Host " $esc[33m$($script:Icons.Warning)$esc[0m $startupCount startup programs (moderate)"
|
||||
$script:IssuesFound++
|
||||
}
|
||||
else {
|
||||
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m $startupCount startup programs"
|
||||
}
|
||||
}
|
||||
|
||||
function Test-SystemFiles {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Run System File Checker (SFC)
|
||||
#>
|
||||
|
||||
$esc = [char]27
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "$esc[34m$($script:Icons.Arrow) System File Verification$esc[0m"
|
||||
|
||||
if (-not (Test-IsAdmin)) {
|
||||
Write-Host " $esc[33m$($script:Icons.Warning)$esc[0m Requires administrator privileges"
|
||||
return
|
||||
}
|
||||
|
||||
if ($script:DryRun) {
|
||||
Write-Host " $esc[33m$($script:Icons.DryRun)$esc[0m Would run System File Checker"
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host " Running System File Checker (this may take several minutes)..."
|
||||
|
||||
try {
|
||||
$sfcResult = Start-Process -FilePath "sfc.exe" -ArgumentList "/scannow" `
|
||||
-Wait -PassThru -NoNewWindow -RedirectStandardOutput "$env:TEMP\sfc_output.txt" -ErrorAction Stop
|
||||
|
||||
$output = Get-Content "$env:TEMP\sfc_output.txt" -ErrorAction SilentlyContinue
|
||||
Remove-Item "$env:TEMP\sfc_output.txt" -Force -ErrorAction SilentlyContinue
|
||||
|
||||
if ($output -match "did not find any integrity violations") {
|
||||
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m No integrity violations found"
|
||||
}
|
||||
elseif ($output -match "found corrupt files and successfully repaired") {
|
||||
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m Corrupt files were repaired"
|
||||
$script:IssuesFixed++
|
||||
}
|
||||
elseif ($output -match "found corrupt files but was unable to fix") {
|
||||
Write-Host " $esc[31m$($script:Icons.Error)$esc[0m Found corrupt files that could not be repaired"
|
||||
Write-Host " $esc[90mRun 'DISM /Online /Cleanup-Image /RestoreHealth' then retry SFC$esc[0m"
|
||||
$script:IssuesFound++
|
||||
}
|
||||
else {
|
||||
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m Scan completed"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host " $esc[31m$($script:Icons.Error)$esc[0m System File Checker failed: $_"
|
||||
}
|
||||
}
|
||||
|
||||
function Test-DiskHealth {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Check disk health status
|
||||
#>
|
||||
|
||||
$esc = [char]27
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "$esc[34m$($script:Icons.Arrow) Disk Health$esc[0m"
|
||||
|
||||
try {
|
||||
$disks = Get-PhysicalDisk -ErrorAction Stop
|
||||
|
||||
foreach ($disk in $disks) {
|
||||
$status = $disk.HealthStatus
|
||||
$name = $disk.FriendlyName
|
||||
|
||||
if ($status -eq "Healthy") {
|
||||
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m $name - Healthy"
|
||||
}
|
||||
elseif ($status -eq "Warning") {
|
||||
Write-Host " $esc[33m$($script:Icons.Warning)$esc[0m $name - Warning"
|
||||
Write-Host " $esc[90mDisk may have issues, consider backing up data$esc[0m"
|
||||
$script:IssuesFound++
|
||||
}
|
||||
else {
|
||||
Write-Host " $esc[31m$($script:Icons.Error)$esc[0m $name - $status"
|
||||
Write-Host " $esc[31mDisk has critical issues, back up data immediately!$esc[0m"
|
||||
$script:IssuesFound++
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host " $esc[90mCould not check disk health$esc[0m"
|
||||
}
|
||||
}
|
||||
|
||||
function Test-WindowsUpdate {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Check Windows Update status
|
||||
#>
|
||||
|
||||
$esc = [char]27
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "$esc[34m$($script:Icons.Arrow) Windows Update$esc[0m"
|
||||
|
||||
try {
|
||||
$updateSession = New-Object -ComObject Microsoft.Update.Session
|
||||
$updateSearcher = $updateSession.CreateUpdateSearcher()
|
||||
|
||||
Write-Host " Checking for updates..."
|
||||
$searchResult = $updateSearcher.Search("IsInstalled=0")
|
||||
|
||||
$importantUpdates = $searchResult.Updates | Where-Object {
|
||||
$_.MsrcSeverity -in @('Critical', 'Important')
|
||||
}
|
||||
|
||||
if ($importantUpdates.Count -gt 0) {
|
||||
Write-Host " $esc[33m$($script:Icons.Warning)$esc[0m $($importantUpdates.Count) important updates available"
|
||||
Write-Host " $esc[90mRun Windows Update to install$esc[0m"
|
||||
$script:IssuesFound++
|
||||
}
|
||||
elseif ($searchResult.Updates.Count -gt 0) {
|
||||
Write-Host " $esc[90m$($script:Icons.List)$esc[0m $($searchResult.Updates.Count) optional updates available"
|
||||
}
|
||||
else {
|
||||
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m System is up to date"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host " $esc[90mCould not check Windows Update status$esc[0m"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Summary
|
||||
# ============================================================================
|
||||
|
||||
function Show-OptimizeSummary {
|
||||
$esc = [char]27
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "$esc[1;35m" -NoNewline
|
||||
if ($script:DryRun) {
|
||||
Write-Host "Dry Run Complete - No Changes Made" -NoNewline
|
||||
}
|
||||
else {
|
||||
Write-Host "Optimization Complete" -NoNewline
|
||||
}
|
||||
Write-Host "$esc[0m"
|
||||
Write-Host ""
|
||||
|
||||
if ($script:DryRun) {
|
||||
Write-Host " Would apply $esc[33m$($script:OptimizationsApplied)$esc[0m optimizations"
|
||||
Write-Host " Run without -DryRun to apply changes"
|
||||
}
|
||||
else {
|
||||
Write-Host " Optimizations applied: $esc[32m$($script:OptimizationsApplied)$esc[0m"
|
||||
|
||||
if ($script:IssuesFixed -gt 0) {
|
||||
Write-Host " Issues fixed: $esc[32m$($script:IssuesFixed)$esc[0m"
|
||||
}
|
||||
|
||||
if ($script:IssuesFound -gt 0) {
|
||||
Write-Host " Issues found: $esc[33m$($script:IssuesFound)$esc[0m"
|
||||
}
|
||||
else {
|
||||
Write-Host " System health: $esc[32mGood$esc[0m"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main Entry Point
|
||||
# ============================================================================
|
||||
|
||||
function Main {
|
||||
# Enable debug if requested
|
||||
if ($DebugMode) {
|
||||
$env:MOLE_DEBUG = "1"
|
||||
$DebugPreference = "Continue"
|
||||
}
|
||||
|
||||
# Show help
|
||||
if ($ShowHelp) {
|
||||
Show-OptimizeHelp
|
||||
return
|
||||
}
|
||||
|
||||
# Set dry-run mode
|
||||
$script:DryRun = $DryRun
|
||||
|
||||
# Clear screen
|
||||
Clear-Host
|
||||
|
||||
$esc = [char]27
|
||||
Write-Host ""
|
||||
Write-Host "$esc[1;35mOptimize and Check$esc[0m"
|
||||
Write-Host ""
|
||||
|
||||
if ($script:DryRun) {
|
||||
Write-Host "$esc[33m$($script:Icons.DryRun) DRY RUN MODE$esc[0m - No changes will be made"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# Show system health
|
||||
$health = Get-SystemHealth
|
||||
Show-SystemHealth -Health $health
|
||||
|
||||
# Run optimizations
|
||||
Optimize-DiskDrive
|
||||
Optimize-SearchIndex
|
||||
Clear-DnsCache
|
||||
Optimize-Network
|
||||
|
||||
# Run health checks
|
||||
Get-StartupPrograms
|
||||
Test-DiskHealth
|
||||
Test-WindowsUpdate
|
||||
|
||||
# System file check is slow, ask first
|
||||
if (-not $script:DryRun -and (Test-IsAdmin)) {
|
||||
Write-Host ""
|
||||
$runSfc = Read-Host "Run System File Checker? This may take several minutes (y/N)"
|
||||
if ($runSfc -eq 'y' -or $runSfc -eq 'Y') {
|
||||
Test-SystemFiles
|
||||
}
|
||||
}
|
||||
|
||||
# Summary
|
||||
Show-OptimizeSummary
|
||||
}
|
||||
|
||||
# Run main
|
||||
Main
|
||||
509
bin/optimize.sh
509
bin/optimize.sh
@@ -1,509 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Mole - Optimize command.
|
||||
# Runs system maintenance checks and fixes.
|
||||
# Supports dry-run where applicable.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Fix locale issues.
|
||||
export LC_ALL=C
|
||||
export LANG=C
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "$SCRIPT_DIR/lib/core/common.sh"
|
||||
|
||||
# Clean temp files on exit.
|
||||
trap cleanup_temp_files EXIT INT TERM
|
||||
source "$SCRIPT_DIR/lib/core/sudo.sh"
|
||||
source "$SCRIPT_DIR/lib/manage/update.sh"
|
||||
source "$SCRIPT_DIR/lib/manage/autofix.sh"
|
||||
source "$SCRIPT_DIR/lib/optimize/maintenance.sh"
|
||||
source "$SCRIPT_DIR/lib/optimize/tasks.sh"
|
||||
source "$SCRIPT_DIR/lib/check/health_json.sh"
|
||||
source "$SCRIPT_DIR/lib/check/all.sh"
|
||||
source "$SCRIPT_DIR/lib/manage/whitelist.sh"
|
||||
|
||||
print_header() {
|
||||
printf '\n'
|
||||
echo -e "${PURPLE_BOLD}Optimize and Check${NC}"
|
||||
}
|
||||
|
||||
run_system_checks() {
|
||||
# Skip checks in dry-run mode.
|
||||
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
unset AUTO_FIX_SUMMARY AUTO_FIX_DETAILS
|
||||
unset MOLE_SECURITY_FIXES_SHOWN
|
||||
unset MOLE_SECURITY_FIXES_SKIPPED
|
||||
echo ""
|
||||
|
||||
check_all_updates
|
||||
echo ""
|
||||
|
||||
check_system_health
|
||||
echo ""
|
||||
|
||||
check_all_security
|
||||
if ask_for_security_fixes; then
|
||||
perform_security_fixes
|
||||
fi
|
||||
if [[ "${MOLE_SECURITY_FIXES_SKIPPED:-}" != "true" ]]; then
|
||||
echo ""
|
||||
fi
|
||||
|
||||
check_all_config
|
||||
echo ""
|
||||
|
||||
show_suggestions
|
||||
|
||||
if ask_for_updates; then
|
||||
perform_updates
|
||||
fi
|
||||
if ask_for_auto_fix; then
|
||||
perform_auto_fix
|
||||
fi
|
||||
}
|
||||
|
||||
show_optimization_summary() {
|
||||
local safe_count="${OPTIMIZE_SAFE_COUNT:-0}"
|
||||
local confirm_count="${OPTIMIZE_CONFIRM_COUNT:-0}"
|
||||
if ((safe_count == 0 && confirm_count == 0)) && [[ -z "${AUTO_FIX_SUMMARY:-}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local summary_title
|
||||
local -a summary_details=()
|
||||
local total_applied=$((safe_count + confirm_count))
|
||||
|
||||
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
|
||||
summary_title="Dry Run Complete - No Changes Made"
|
||||
summary_details+=("Would apply ${YELLOW}${total_applied:-0}${NC} optimizations")
|
||||
summary_details+=("Run without ${YELLOW}--dry-run${NC} to apply these changes")
|
||||
else
|
||||
summary_title="Optimization and Check Complete"
|
||||
|
||||
# Build statistics summary
|
||||
local -a stats=()
|
||||
local cache_kb="${OPTIMIZE_CACHE_CLEANED_KB:-0}"
|
||||
local db_count="${OPTIMIZE_DATABASES_COUNT:-0}"
|
||||
local config_count="${OPTIMIZE_CONFIGS_REPAIRED:-0}"
|
||||
|
||||
if [[ "$cache_kb" =~ ^[0-9]+$ ]] && [[ "$cache_kb" -gt 0 ]]; then
|
||||
local cache_human=$(bytes_to_human "$((cache_kb * 1024))")
|
||||
stats+=("${cache_human} cache cleaned")
|
||||
fi
|
||||
|
||||
if [[ "$db_count" =~ ^[0-9]+$ ]] && [[ "$db_count" -gt 0 ]]; then
|
||||
stats+=("${db_count} databases optimized")
|
||||
fi
|
||||
|
||||
if [[ "$config_count" =~ ^[0-9]+$ ]] && [[ "$config_count" -gt 0 ]]; then
|
||||
stats+=("${config_count} configs repaired")
|
||||
fi
|
||||
|
||||
# Build first summary line with most important stat only
|
||||
local key_stat=""
|
||||
if [[ "$cache_kb" =~ ^[0-9]+$ ]] && [[ "$cache_kb" -gt 0 ]]; then
|
||||
local cache_human=$(bytes_to_human "$((cache_kb * 1024))")
|
||||
key_stat="${cache_human} cache cleaned"
|
||||
elif [[ "$db_count" =~ ^[0-9]+$ ]] && [[ "$db_count" -gt 0 ]]; then
|
||||
key_stat="${db_count} databases optimized"
|
||||
elif [[ "$config_count" =~ ^[0-9]+$ ]] && [[ "$config_count" -gt 0 ]]; then
|
||||
key_stat="${config_count} configs repaired"
|
||||
fi
|
||||
|
||||
if [[ -n "$key_stat" ]]; then
|
||||
summary_details+=("Applied ${GREEN}${total_applied:-0}${NC} optimizations — ${key_stat}")
|
||||
else
|
||||
summary_details+=("Applied ${GREEN}${total_applied:-0}${NC} optimizations — all services tuned")
|
||||
fi
|
||||
|
||||
local summary_line3=""
|
||||
if [[ -n "${AUTO_FIX_SUMMARY:-}" ]]; then
|
||||
summary_line3="${AUTO_FIX_SUMMARY}"
|
||||
if [[ -n "${AUTO_FIX_DETAILS:-}" ]]; then
|
||||
local detail_join
|
||||
detail_join=$(echo "${AUTO_FIX_DETAILS}" | paste -sd ", " -)
|
||||
[[ -n "$detail_join" ]] && summary_line3+=" — ${detail_join}"
|
||||
fi
|
||||
summary_details+=("$summary_line3")
|
||||
fi
|
||||
summary_details+=("System fully optimized — faster, more secure and responsive")
|
||||
fi
|
||||
|
||||
print_summary_block "$summary_title" "${summary_details[@]}"
|
||||
}
|
||||
|
||||
show_system_health() {
|
||||
local health_json="$1"
|
||||
|
||||
local mem_used=$(echo "$health_json" | jq -r '.memory_used_gb // 0' 2> /dev/null || echo "0")
|
||||
local mem_total=$(echo "$health_json" | jq -r '.memory_total_gb // 0' 2> /dev/null || echo "0")
|
||||
local disk_used=$(echo "$health_json" | jq -r '.disk_used_gb // 0' 2> /dev/null || echo "0")
|
||||
local disk_total=$(echo "$health_json" | jq -r '.disk_total_gb // 0' 2> /dev/null || echo "0")
|
||||
local disk_percent=$(echo "$health_json" | jq -r '.disk_used_percent // 0' 2> /dev/null || echo "0")
|
||||
local uptime=$(echo "$health_json" | jq -r '.uptime_days // 0' 2> /dev/null || echo "0")
|
||||
|
||||
mem_used=${mem_used:-0}
|
||||
mem_total=${mem_total:-0}
|
||||
disk_used=${disk_used:-0}
|
||||
disk_total=${disk_total:-0}
|
||||
disk_percent=${disk_percent:-0}
|
||||
uptime=${uptime:-0}
|
||||
|
||||
printf "${ICON_ADMIN} System %.0f/%.0f GB RAM | %.0f/%.0f GB Disk | Uptime %.0fd\n" \
|
||||
"$mem_used" "$mem_total" "$disk_used" "$disk_total" "$uptime"
|
||||
}
|
||||
|
||||
parse_optimizations() {
|
||||
local health_json="$1"
|
||||
echo "$health_json" | jq -c '.optimizations[]' 2> /dev/null
|
||||
}
|
||||
|
||||
announce_action() {
|
||||
local name="$1"
|
||||
local desc="$2"
|
||||
local kind="$3"
|
||||
|
||||
if [[ "${FIRST_ACTION:-true}" == "true" ]]; then
|
||||
export FIRST_ACTION=false
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
echo -e "${BLUE}${ICON_ARROW} ${name}${NC}"
|
||||
}
|
||||
|
||||
touchid_configured() {
|
||||
local pam_file="/etc/pam.d/sudo"
|
||||
[[ -f "$pam_file" ]] && grep -q "pam_tid.so" "$pam_file" 2> /dev/null
|
||||
}
|
||||
|
||||
touchid_supported() {
|
||||
if command -v bioutil > /dev/null 2>&1; then
|
||||
if bioutil -r 2> /dev/null | grep -qi "Touch ID"; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback: Apple Silicon Macs usually have Touch ID.
|
||||
if [[ "$(uname -m)" == "arm64" ]]; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
cleanup_path() {
|
||||
local raw_path="$1"
|
||||
local label="$2"
|
||||
|
||||
local expanded_path="${raw_path/#\~/$HOME}"
|
||||
if [[ ! -e "$expanded_path" ]]; then
|
||||
echo -e "${GREEN}${ICON_SUCCESS}${NC} $label"
|
||||
return
|
||||
fi
|
||||
if should_protect_path "$expanded_path"; then
|
||||
echo -e "${YELLOW}${ICON_WARNING}${NC} Protected $label"
|
||||
return
|
||||
fi
|
||||
|
||||
local size_kb
|
||||
size_kb=$(get_path_size_kb "$expanded_path")
|
||||
local size_display=""
|
||||
if [[ "$size_kb" =~ ^[0-9]+$ && "$size_kb" -gt 0 ]]; then
|
||||
size_display=$(bytes_to_human "$((size_kb * 1024))")
|
||||
fi
|
||||
|
||||
local removed=false
|
||||
if safe_remove "$expanded_path" true; then
|
||||
removed=true
|
||||
elif request_sudo_access "Removing $label requires admin access"; then
|
||||
if safe_sudo_remove "$expanded_path"; then
|
||||
removed=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$removed" == "true" ]]; then
|
||||
if [[ -n "$size_display" ]]; then
|
||||
echo -e "${GREEN}${ICON_SUCCESS}${NC} $label ${GREEN}(${size_display})${NC}"
|
||||
else
|
||||
echo -e "${GREEN}${ICON_SUCCESS}${NC} $label"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}${ICON_WARNING}${NC} Skipped $label ${GRAY}(grant Full Disk Access to your terminal and retry)${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_directory() {
|
||||
local raw_path="$1"
|
||||
local expanded_path="${raw_path/#\~/$HOME}"
|
||||
ensure_user_dir "$expanded_path"
|
||||
}
|
||||
|
||||
declare -a SECURITY_FIXES=()
|
||||
|
||||
collect_security_fix_actions() {
|
||||
SECURITY_FIXES=()
|
||||
if [[ "${FIREWALL_DISABLED:-}" == "true" ]]; then
|
||||
if ! is_whitelisted "firewall"; then
|
||||
SECURITY_FIXES+=("firewall|Enable macOS firewall")
|
||||
fi
|
||||
fi
|
||||
if [[ "${GATEKEEPER_DISABLED:-}" == "true" ]]; then
|
||||
if ! is_whitelisted "gatekeeper"; then
|
||||
SECURITY_FIXES+=("gatekeeper|Enable Gatekeeper (App download protection)")
|
||||
fi
|
||||
fi
|
||||
if touchid_supported && ! touchid_configured; then
|
||||
if ! is_whitelisted "check_touchid"; then
|
||||
SECURITY_FIXES+=("touchid|Enable Touch ID for sudo")
|
||||
fi
|
||||
fi
|
||||
|
||||
((${#SECURITY_FIXES[@]} > 0))
|
||||
}
|
||||
|
||||
ask_for_security_fixes() {
|
||||
if ! collect_security_fix_actions; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}SECURITY FIXES${NC}"
|
||||
for entry in "${SECURITY_FIXES[@]}"; do
|
||||
IFS='|' read -r _ label <<< "$entry"
|
||||
echo -e " ${ICON_LIST} $label"
|
||||
done
|
||||
echo ""
|
||||
export MOLE_SECURITY_FIXES_SHOWN=true
|
||||
echo -ne "${YELLOW}Apply now?${NC} ${GRAY}Enter confirm / Space cancel${NC}: "
|
||||
|
||||
local key
|
||||
if ! key=$(read_key); then
|
||||
export MOLE_SECURITY_FIXES_SKIPPED=true
|
||||
echo -e "\n ${GRAY}${ICON_WARNING}${NC} Security fixes skipped"
|
||||
echo ""
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$key" == "ENTER" ]]; then
|
||||
echo ""
|
||||
return 0
|
||||
else
|
||||
export MOLE_SECURITY_FIXES_SKIPPED=true
|
||||
echo -e "\n ${GRAY}${ICON_WARNING}${NC} Security fixes skipped"
|
||||
echo ""
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
apply_firewall_fix() {
|
||||
if sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on > /dev/null 2>&1; then
|
||||
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Firewall enabled"
|
||||
FIREWALL_DISABLED=false
|
||||
return 0
|
||||
fi
|
||||
echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to enable firewall (check permissions)"
|
||||
return 1
|
||||
}
|
||||
|
||||
apply_gatekeeper_fix() {
|
||||
if sudo spctl --master-enable 2> /dev/null; then
|
||||
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Gatekeeper enabled"
|
||||
GATEKEEPER_DISABLED=false
|
||||
return 0
|
||||
fi
|
||||
echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to enable Gatekeeper"
|
||||
return 1
|
||||
}
|
||||
|
||||
apply_touchid_fix() {
|
||||
if "$SCRIPT_DIR/bin/touchid.sh" enable; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
perform_security_fixes() {
|
||||
if ! ensure_sudo_session "Security changes require admin access"; then
|
||||
echo -e "${YELLOW}${ICON_WARNING}${NC} Skipped security fixes (sudo denied)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local applied=0
|
||||
for entry in "${SECURITY_FIXES[@]}"; do
|
||||
IFS='|' read -r action _ <<< "$entry"
|
||||
case "$action" in
|
||||
firewall)
|
||||
apply_firewall_fix && ((applied++))
|
||||
;;
|
||||
gatekeeper)
|
||||
apply_gatekeeper_fix && ((applied++))
|
||||
;;
|
||||
touchid)
|
||||
apply_touchid_fix && ((applied++))
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ((applied > 0)); then
|
||||
log_success "Security settings updated"
|
||||
fi
|
||||
SECURITY_FIXES=()
|
||||
}
|
||||
|
||||
cleanup_all() {
|
||||
stop_inline_spinner 2> /dev/null || true
|
||||
stop_sudo_session
|
||||
cleanup_temp_files
|
||||
}
|
||||
|
||||
handle_interrupt() {
|
||||
cleanup_all
|
||||
exit 130
|
||||
}
|
||||
|
||||
main() {
|
||||
local health_json
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
"--debug")
|
||||
export MO_DEBUG=1
|
||||
;;
|
||||
"--dry-run")
|
||||
export MOLE_DRY_RUN=1
|
||||
;;
|
||||
"--whitelist")
|
||||
manage_whitelist "optimize"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
trap cleanup_all EXIT
|
||||
trap handle_interrupt INT TERM
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
clear
|
||||
fi
|
||||
print_header
|
||||
|
||||
# Dry-run indicator.
|
||||
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
|
||||
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC} - No files will be modified\n"
|
||||
fi
|
||||
|
||||
if ! command -v jq > /dev/null 2>&1; then
|
||||
echo -e "${YELLOW}${ICON_ERROR}${NC} Missing dependency: jq"
|
||||
echo -e "${GRAY}Install with: ${GREEN}brew install jq${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v bc > /dev/null 2>&1; then
|
||||
echo -e "${YELLOW}${ICON_ERROR}${NC} Missing dependency: bc"
|
||||
echo -e "${GRAY}Install with: ${GREEN}brew install bc${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
start_inline_spinner "Collecting system info..."
|
||||
fi
|
||||
|
||||
if ! health_json=$(generate_health_json 2> /dev/null); then
|
||||
if [[ -t 1 ]]; then
|
||||
stop_inline_spinner
|
||||
fi
|
||||
echo ""
|
||||
log_error "Failed to collect system health data"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! echo "$health_json" | jq empty 2> /dev/null; then
|
||||
if [[ -t 1 ]]; then
|
||||
stop_inline_spinner
|
||||
fi
|
||||
echo ""
|
||||
log_error "Invalid system health data format"
|
||||
echo -e "${YELLOW}Tip:${NC} Check if jq, awk, sysctl, and df commands are available"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
stop_inline_spinner
|
||||
fi
|
||||
|
||||
show_system_health "$health_json"
|
||||
|
||||
load_whitelist "optimize"
|
||||
if [[ ${#CURRENT_WHITELIST_PATTERNS[@]} -gt 0 ]]; then
|
||||
local count=${#CURRENT_WHITELIST_PATTERNS[@]}
|
||||
if [[ $count -le 3 ]]; then
|
||||
local patterns_list=$(
|
||||
IFS=', '
|
||||
echo "${CURRENT_WHITELIST_PATTERNS[*]}"
|
||||
)
|
||||
echo -e "${ICON_ADMIN} Active Whitelist: ${patterns_list}"
|
||||
fi
|
||||
fi
|
||||
|
||||
local -a safe_items=()
|
||||
local -a confirm_items=()
|
||||
local opts_file
|
||||
opts_file=$(mktemp_file)
|
||||
parse_optimizations "$health_json" > "$opts_file"
|
||||
|
||||
while IFS= read -r opt_json; do
|
||||
[[ -z "$opt_json" ]] && continue
|
||||
|
||||
local name=$(echo "$opt_json" | jq -r '.name')
|
||||
local desc=$(echo "$opt_json" | jq -r '.description')
|
||||
local action=$(echo "$opt_json" | jq -r '.action')
|
||||
local path=$(echo "$opt_json" | jq -r '.path // ""')
|
||||
local safe=$(echo "$opt_json" | jq -r '.safe')
|
||||
|
||||
local item="${name}|${desc}|${action}|${path}"
|
||||
|
||||
if [[ "$safe" == "true" ]]; then
|
||||
safe_items+=("$item")
|
||||
else
|
||||
confirm_items+=("$item")
|
||||
fi
|
||||
done < "$opts_file"
|
||||
|
||||
echo ""
|
||||
if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
|
||||
ensure_sudo_session "System optimization requires admin access" || true
|
||||
fi
|
||||
|
||||
export FIRST_ACTION=true
|
||||
if [[ ${#safe_items[@]} -gt 0 ]]; then
|
||||
for item in "${safe_items[@]}"; do
|
||||
IFS='|' read -r name desc action path <<< "$item"
|
||||
announce_action "$name" "$desc" "safe"
|
||||
execute_optimization "$action" "$path"
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ ${#confirm_items[@]} -gt 0 ]]; then
|
||||
for item in "${confirm_items[@]}"; do
|
||||
IFS='|' read -r name desc action path <<< "$item"
|
||||
announce_action "$name" "$desc" "confirm"
|
||||
execute_optimization "$action" "$path"
|
||||
done
|
||||
fi
|
||||
|
||||
local safe_count=${#safe_items[@]}
|
||||
local confirm_count=${#confirm_items[@]}
|
||||
|
||||
run_system_checks
|
||||
|
||||
export OPTIMIZE_SAFE_COUNT=$safe_count
|
||||
export OPTIMIZE_CONFIRM_COUNT=$confirm_count
|
||||
|
||||
show_optimization_summary
|
||||
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
main "$@"
|
||||
615
bin/purge.ps1
Normal file
615
bin/purge.ps1
Normal file
@@ -0,0 +1,615 @@
|
||||
# Mole - Purge Command
|
||||
# Aggressive cleanup of project build artifacts
|
||||
|
||||
#Requires -Version 5.1
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$DebugMode,
|
||||
[switch]$Paths,
|
||||
[switch]$ShowHelp
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Script location
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$libDir = Join-Path (Split-Path -Parent $scriptDir) "lib"
|
||||
|
||||
# Import core modules
|
||||
. "$libDir\core\base.ps1"
|
||||
. "$libDir\core\log.ps1"
|
||||
. "$libDir\core\ui.ps1"
|
||||
. "$libDir\core\file_ops.ps1"
|
||||
|
||||
# ============================================================================
|
||||
# Configuration
|
||||
# ============================================================================
|
||||
|
||||
$script:DefaultSearchPaths = @(
|
||||
"$env:USERPROFILE\Documents"
|
||||
"$env:USERPROFILE\Projects"
|
||||
"$env:USERPROFILE\Code"
|
||||
"$env:USERPROFILE\Development"
|
||||
"$env:USERPROFILE\workspace"
|
||||
"$env:USERPROFILE\github"
|
||||
"$env:USERPROFILE\repos"
|
||||
"$env:USERPROFILE\src"
|
||||
"D:\Projects"
|
||||
"D:\Code"
|
||||
"D:\Development"
|
||||
)
|
||||
|
||||
$script:ConfigFile = "$env:USERPROFILE\.config\mole\purge_paths.txt"
|
||||
|
||||
# Artifact patterns to clean
|
||||
$script:ArtifactPatterns = @(
|
||||
@{ Name = "node_modules"; Type = "Directory"; Language = "JavaScript/Node.js" }
|
||||
@{ Name = "vendor"; Type = "Directory"; Language = "PHP/Go" }
|
||||
@{ Name = ".venv"; Type = "Directory"; Language = "Python" }
|
||||
@{ Name = "venv"; Type = "Directory"; Language = "Python" }
|
||||
@{ Name = "__pycache__"; Type = "Directory"; Language = "Python" }
|
||||
@{ Name = ".pytest_cache"; Type = "Directory"; Language = "Python" }
|
||||
@{ Name = "target"; Type = "Directory"; Language = "Rust/Java" }
|
||||
@{ Name = "build"; Type = "Directory"; Language = "General" }
|
||||
@{ Name = "dist"; Type = "Directory"; Language = "General" }
|
||||
@{ Name = ".next"; Type = "Directory"; Language = "Next.js" }
|
||||
@{ Name = ".nuxt"; Type = "Directory"; Language = "Nuxt.js" }
|
||||
@{ Name = ".turbo"; Type = "Directory"; Language = "Turborepo" }
|
||||
@{ Name = ".parcel-cache"; Type = "Directory"; Language = "Parcel" }
|
||||
@{ Name = "bin"; Type = "Directory"; Language = ".NET" }
|
||||
@{ Name = "obj"; Type = "Directory"; Language = ".NET" }
|
||||
@{ Name = ".gradle"; Type = "Directory"; Language = "Java/Gradle" }
|
||||
@{ Name = ".idea"; Type = "Directory"; Language = "JetBrains IDE" }
|
||||
@{ Name = "*.log"; Type = "File"; Language = "Logs" }
|
||||
)
|
||||
|
||||
$script:TotalSizeCleaned = 0
|
||||
$script:ItemsCleaned = 0
|
||||
|
||||
# ============================================================================
|
||||
# Help
|
||||
# ============================================================================
|
||||
|
||||
function Show-PurgeHelp {
|
||||
$esc = [char]27
|
||||
Write-Host ""
|
||||
Write-Host "$esc[1;35mMole Purge$esc[0m - Clean project build artifacts"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mUsage:$esc[0m mole purge [options]"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mOptions:$esc[0m"
|
||||
Write-Host " -Paths Edit custom scan directories"
|
||||
Write-Host " -DebugMode Enable debug logging"
|
||||
Write-Host " -ShowHelp Show this help message"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mDefault Search Paths:$esc[0m"
|
||||
foreach ($path in $script:DefaultSearchPaths) {
|
||||
if (Test-Path $path) {
|
||||
Write-Host " $esc[32m+$esc[0m $path"
|
||||
}
|
||||
else {
|
||||
Write-Host " $esc[90m-$esc[0m $path (not found)"
|
||||
}
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mArtifacts Cleaned:$esc[0m"
|
||||
Write-Host " node_modules, vendor, venv, target, build, dist, __pycache__, etc."
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Path Management
|
||||
# ============================================================================
|
||||
|
||||
function Get-SearchPaths {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get list of paths to scan for projects
|
||||
#>
|
||||
|
||||
$paths = @()
|
||||
|
||||
# Load custom paths if available
|
||||
if (Test-Path $script:ConfigFile) {
|
||||
$customPaths = Get-Content $script:ConfigFile -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_ -and -not $_.StartsWith('#') } |
|
||||
ForEach-Object { $_.Trim() }
|
||||
|
||||
foreach ($path in $customPaths) {
|
||||
if (Test-Path $path) {
|
||||
$paths += $path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Add default paths if no custom paths or custom paths don't exist
|
||||
if ($null -eq $paths -or @($paths).Count -eq 0) {
|
||||
foreach ($path in $script:DefaultSearchPaths) {
|
||||
if (Test-Path $path) {
|
||||
$paths += $path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $paths
|
||||
}
|
||||
|
||||
function Edit-SearchPaths {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Open search paths configuration for editing
|
||||
#>
|
||||
|
||||
$configDir = Split-Path -Parent $script:ConfigFile
|
||||
|
||||
if (-not (Test-Path $configDir)) {
|
||||
New-Item -ItemType Directory -Path $configDir -Force | Out-Null
|
||||
}
|
||||
|
||||
if (-not (Test-Path $script:ConfigFile)) {
|
||||
$defaultContent = @"
|
||||
# Mole Purge - Custom Search Paths
|
||||
# Add directories to scan for project artifacts (one per line)
|
||||
# Lines starting with # are ignored
|
||||
#
|
||||
# Examples:
|
||||
# D:\MyProjects
|
||||
# E:\Work\Code
|
||||
#
|
||||
# Default paths (used if this file is empty):
|
||||
# $env:USERPROFILE\Documents
|
||||
# $env:USERPROFILE\Projects
|
||||
# $env:USERPROFILE\Code
|
||||
|
||||
"@
|
||||
Set-Content -Path $script:ConfigFile -Value $defaultContent
|
||||
}
|
||||
|
||||
Write-Info "Opening paths configuration: $($script:ConfigFile)"
|
||||
Start-Process notepad.exe -ArgumentList $script:ConfigFile -Wait
|
||||
|
||||
Write-Success "Configuration saved"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Project Discovery
|
||||
# ============================================================================
|
||||
|
||||
function Find-Projects {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Find all development projects in search paths
|
||||
#>
|
||||
param([string[]]$SearchPaths)
|
||||
|
||||
$projects = @()
|
||||
|
||||
# Project markers
|
||||
$projectMarkers = @(
|
||||
"package.json" # Node.js
|
||||
"composer.json" # PHP
|
||||
"Cargo.toml" # Rust
|
||||
"go.mod" # Go
|
||||
"pom.xml" # Java/Maven
|
||||
"build.gradle" # Java/Gradle
|
||||
"requirements.txt" # Python
|
||||
"pyproject.toml" # Python
|
||||
"*.csproj" # .NET
|
||||
"*.sln" # .NET Solution
|
||||
)
|
||||
|
||||
$esc = [char]27
|
||||
$pathCount = 0
|
||||
$totalPaths = if ($null -eq $SearchPaths) { 0 } else { @($SearchPaths).Count }
|
||||
if ($totalPaths -eq 0) {
|
||||
return $projects
|
||||
}
|
||||
|
||||
foreach ($searchPath in $SearchPaths) {
|
||||
$pathCount++
|
||||
Write-Progress -Activity "Scanning for projects" `
|
||||
-Status "Searching: $searchPath" `
|
||||
-PercentComplete (($pathCount / $totalPaths) * 100)
|
||||
|
||||
foreach ($marker in $projectMarkers) {
|
||||
try {
|
||||
$found = Get-ChildItem -Path $searchPath -Filter $marker -Recurse -Depth 4 -ErrorAction SilentlyContinue
|
||||
|
||||
foreach ($item in $found) {
|
||||
$projectPath = Split-Path -Parent $item.FullName
|
||||
|
||||
# Skip if already found or if it's inside node_modules, etc.
|
||||
$existingPaths = @($projects | ForEach-Object { $_.Path })
|
||||
if ($existingPaths -contains $projectPath) { continue }
|
||||
if ($projectPath -like "*\node_modules\*") { continue }
|
||||
if ($projectPath -like "*\vendor\*") { continue }
|
||||
if ($projectPath -like "*\.git\*") { continue }
|
||||
|
||||
# Find artifacts in this project
|
||||
$artifacts = @(Find-ProjectArtifacts -ProjectPath $projectPath)
|
||||
$artifactCount = if ($null -eq $artifacts) { 0 } else { $artifacts.Count }
|
||||
|
||||
if ($artifactCount -gt 0) {
|
||||
$totalSize = ($artifacts | Measure-Object -Property SizeKB -Sum).Sum
|
||||
if ($null -eq $totalSize) { $totalSize = 0 }
|
||||
|
||||
$projects += [PSCustomObject]@{
|
||||
Path = $projectPath
|
||||
Name = Split-Path -Leaf $projectPath
|
||||
Marker = $marker
|
||||
Artifacts = $artifacts
|
||||
TotalSizeKB = $totalSize
|
||||
TotalSizeHuman = Format-ByteSize -Bytes ($totalSize * 1024)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Debug "Error scanning $searchPath for $marker : $_"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Progress -Activity "Scanning for projects" -Completed
|
||||
|
||||
# Sort by size (largest first)
|
||||
return $projects | Sort-Object -Property TotalSizeKB -Descending
|
||||
}
|
||||
|
||||
function Find-ProjectArtifacts {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Find cleanable artifacts in a project directory
|
||||
#>
|
||||
param([string]$ProjectPath)
|
||||
|
||||
$artifacts = @()
|
||||
|
||||
foreach ($pattern in $script:ArtifactPatterns) {
|
||||
$items = Get-ChildItem -Path $ProjectPath -Filter $pattern.Name -Force -ErrorAction SilentlyContinue
|
||||
|
||||
foreach ($item in $items) {
|
||||
if ($pattern.Type -eq "Directory" -and $item.PSIsContainer) {
|
||||
$sizeKB = Get-PathSizeKB -Path $item.FullName
|
||||
|
||||
$artifacts += [PSCustomObject]@{
|
||||
Path = $item.FullName
|
||||
Name = $item.Name
|
||||
Type = "Directory"
|
||||
Language = $pattern.Language
|
||||
SizeKB = $sizeKB
|
||||
SizeHuman = Format-ByteSize -Bytes ($sizeKB * 1024)
|
||||
}
|
||||
}
|
||||
elseif ($pattern.Type -eq "File" -and -not $item.PSIsContainer) {
|
||||
$sizeKB = [Math]::Ceiling($item.Length / 1024)
|
||||
|
||||
$artifacts += [PSCustomObject]@{
|
||||
Path = $item.FullName
|
||||
Name = $item.Name
|
||||
Type = "File"
|
||||
Language = $pattern.Language
|
||||
SizeKB = $sizeKB
|
||||
SizeHuman = Format-ByteSize -Bytes ($sizeKB * 1024)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $artifacts
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Project Selection UI
|
||||
# ============================================================================
|
||||
|
||||
function Show-ProjectSelectionMenu {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Interactive menu for selecting projects to clean
|
||||
#>
|
||||
param([array]$Projects)
|
||||
|
||||
$projectCount = if ($null -eq $Projects) { 0 } else { @($Projects).Count }
|
||||
if ($projectCount -eq 0) {
|
||||
Write-MoleWarning "No projects with cleanable artifacts found"
|
||||
return @()
|
||||
}
|
||||
|
||||
$esc = [char]27
|
||||
$selectedIndices = @{}
|
||||
$currentIndex = 0
|
||||
$pageSize = 12
|
||||
$pageStart = 0
|
||||
|
||||
try { [Console]::CursorVisible = $false } catch { }
|
||||
|
||||
try {
|
||||
while ($true) {
|
||||
Clear-Host
|
||||
|
||||
# Header
|
||||
Write-Host ""
|
||||
Write-Host "$esc[1;35mSelect Projects to Clean$esc[0m"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[90mUse: $($script:Icons.NavUp)$($script:Icons.NavDown) navigate | Space select | A select all | Enter confirm | Q quit$esc[0m"
|
||||
Write-Host ""
|
||||
|
||||
# Display projects
|
||||
$pageEnd = [Math]::Min($pageStart + $pageSize, $projectCount)
|
||||
|
||||
for ($i = $pageStart; $i -lt $pageEnd; $i++) {
|
||||
$project = $Projects[$i]
|
||||
$isSelected = $selectedIndices.ContainsKey($i)
|
||||
$isCurrent = ($i -eq $currentIndex)
|
||||
|
||||
$checkbox = if ($isSelected) { "$esc[32m[$($script:Icons.Success)]$esc[0m" } else { "[ ]" }
|
||||
|
||||
if ($isCurrent) {
|
||||
Write-Host "$esc[7m" -NoNewline
|
||||
}
|
||||
|
||||
$name = $project.Name
|
||||
if ($name.Length -gt 30) {
|
||||
$name = $name.Substring(0, 27) + "..."
|
||||
}
|
||||
|
||||
$artifactCount = if ($null -eq $project.Artifacts) { 0 } else { @($project.Artifacts).Count }
|
||||
|
||||
Write-Host (" {0} {1,-32} {2,10} ({3} items)" -f $checkbox, $name, $project.TotalSizeHuman, $artifactCount) -NoNewline
|
||||
|
||||
if ($isCurrent) {
|
||||
Write-Host "$esc[0m"
|
||||
}
|
||||
else {
|
||||
Write-Host ""
|
||||
}
|
||||
}
|
||||
|
||||
# Footer
|
||||
Write-Host ""
|
||||
$selectedCount = $selectedIndices.Count
|
||||
if ($selectedCount -gt 0) {
|
||||
$totalSize = 0
|
||||
foreach ($idx in $selectedIndices.Keys) {
|
||||
$totalSize += $Projects[$idx].TotalSizeKB
|
||||
}
|
||||
$totalSizeHuman = Format-ByteSize -Bytes ($totalSize * 1024)
|
||||
Write-Host "$esc[33mSelected:$esc[0m $selectedCount projects ($totalSizeHuman)"
|
||||
}
|
||||
|
||||
# Page indicator
|
||||
$totalPages = [Math]::Ceiling($projectCount / $pageSize)
|
||||
$currentPage = [Math]::Floor($pageStart / $pageSize) + 1
|
||||
Write-Host "$esc[90mPage $currentPage of $totalPages | Total: $projectCount projects$esc[0m"
|
||||
|
||||
# Handle input
|
||||
$key = [Console]::ReadKey($true)
|
||||
|
||||
switch ($key.Key) {
|
||||
'UpArrow' {
|
||||
if ($currentIndex -gt 0) {
|
||||
$currentIndex--
|
||||
if ($currentIndex -lt $pageStart) {
|
||||
$pageStart = [Math]::Max(0, $pageStart - $pageSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
'DownArrow' {
|
||||
if ($currentIndex -lt $projectCount - 1) {
|
||||
$currentIndex++
|
||||
if ($currentIndex -ge $pageStart + $pageSize) {
|
||||
$pageStart += $pageSize
|
||||
}
|
||||
}
|
||||
}
|
||||
'PageUp' {
|
||||
$pageStart = [Math]::Max(0, $pageStart - $pageSize)
|
||||
$currentIndex = $pageStart
|
||||
}
|
||||
'PageDown' {
|
||||
$pageStart = [Math]::Min($projectCount - $pageSize, $pageStart + $pageSize)
|
||||
if ($pageStart -lt 0) { $pageStart = 0 }
|
||||
$currentIndex = $pageStart
|
||||
}
|
||||
'Spacebar' {
|
||||
if ($selectedIndices.ContainsKey($currentIndex)) {
|
||||
$selectedIndices.Remove($currentIndex)
|
||||
}
|
||||
else {
|
||||
$selectedIndices[$currentIndex] = $true
|
||||
}
|
||||
}
|
||||
'A' {
|
||||
# Select/deselect all
|
||||
if ($selectedIndices.Count -eq $projectCount) {
|
||||
$selectedIndices.Clear()
|
||||
}
|
||||
else {
|
||||
for ($i = 0; $i -lt $projectCount; $i++) {
|
||||
$selectedIndices[$i] = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
'Enter' {
|
||||
if ($selectedIndices.Count -gt 0) {
|
||||
$selected = @()
|
||||
foreach ($idx in $selectedIndices.Keys) {
|
||||
$selected += $Projects[$idx]
|
||||
}
|
||||
return $selected
|
||||
}
|
||||
}
|
||||
'Escape' { return @() }
|
||||
'Q' { return @() }
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
try { [Console]::CursorVisible = $true } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Cleanup
|
||||
# ============================================================================
|
||||
|
||||
function Remove-ProjectArtifacts {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Remove artifacts from selected projects
|
||||
#>
|
||||
param([array]$Projects)
|
||||
|
||||
$esc = [char]27
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "$esc[1;35mCleaning Project Artifacts$esc[0m"
|
||||
Write-Host ""
|
||||
|
||||
foreach ($project in $Projects) {
|
||||
Write-Host "$esc[34m$($script:Icons.Arrow)$esc[0m $($project.Name)"
|
||||
|
||||
foreach ($artifact in $project.Artifacts) {
|
||||
if (Test-Path $artifact.Path) {
|
||||
# Use safe removal with protection checks (returns boolean)
|
||||
$success = Remove-SafeItem -Path $artifact.Path -Description $artifact.Name -Recurse
|
||||
|
||||
if ($success) {
|
||||
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m $($artifact.Name) ($($artifact.SizeHuman))"
|
||||
$script:TotalSizeCleaned += $artifact.SizeKB
|
||||
$script:ItemsCleaned++
|
||||
}
|
||||
else {
|
||||
Write-Host " $esc[31m$($script:Icons.Error)$esc[0m $($artifact.Name) - removal failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Summary
|
||||
# ============================================================================
|
||||
|
||||
function Show-PurgeSummary {
|
||||
$esc = [char]27
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "$esc[1;35mPurge Complete$esc[0m"
|
||||
Write-Host ""
|
||||
|
||||
if ($script:TotalSizeCleaned -gt 0) {
|
||||
$sizeGB = [Math]::Round($script:TotalSizeCleaned / 1024 / 1024, 2)
|
||||
Write-Host " Space freed: $esc[32m${sizeGB}GB$esc[0m"
|
||||
Write-Host " Items cleaned: $($script:ItemsCleaned)"
|
||||
Write-Host " Free space now: $(Get-FreeSpace)"
|
||||
}
|
||||
else {
|
||||
Write-Host " No artifacts to clean."
|
||||
Write-Host " Free space now: $(Get-FreeSpace)"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main Entry Point
|
||||
# ============================================================================
|
||||
|
||||
function Main {
|
||||
# Enable debug if requested
|
||||
if ($DebugMode) {
|
||||
$env:MOLE_DEBUG = "1"
|
||||
$DebugPreference = "Continue"
|
||||
}
|
||||
|
||||
# Show help
|
||||
if ($ShowHelp) {
|
||||
Show-PurgeHelp
|
||||
return
|
||||
}
|
||||
|
||||
# Edit paths
|
||||
if ($Paths) {
|
||||
Edit-SearchPaths
|
||||
return
|
||||
}
|
||||
|
||||
# Clear screen
|
||||
Clear-Host
|
||||
|
||||
$esc = [char]27
|
||||
Write-Host ""
|
||||
Write-Host "$esc[1;35mPurge Project Artifacts$esc[0m"
|
||||
Write-Host ""
|
||||
|
||||
# Get search paths
|
||||
$searchPaths = @(Get-SearchPaths)
|
||||
|
||||
if ($null -eq $searchPaths -or $searchPaths.Count -eq 0) {
|
||||
Write-MoleWarning "No valid search paths found"
|
||||
Write-Host "Run 'mole purge -Paths' to configure search directories"
|
||||
return
|
||||
}
|
||||
|
||||
Write-Info "Searching in $($searchPaths.Count) directories..."
|
||||
|
||||
# Find projects
|
||||
$projects = @(Find-Projects -SearchPaths $searchPaths)
|
||||
|
||||
if ($null -eq $projects -or $projects.Count -eq 0) {
|
||||
Write-Host ""
|
||||
Write-Host "$esc[32m$($script:Icons.Success)$esc[0m No cleanable artifacts found"
|
||||
Write-Host ""
|
||||
return
|
||||
}
|
||||
|
||||
$totalSize = ($projects | Measure-Object -Property TotalSizeKB -Sum).Sum
|
||||
if ($null -eq $totalSize) { $totalSize = 0 }
|
||||
$totalSizeHuman = Format-ByteSize -Bytes ($totalSize * 1024)
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Found $esc[33m$($projects.Count)$esc[0m projects with $esc[33m$totalSizeHuman$esc[0m of artifacts"
|
||||
Write-Host ""
|
||||
|
||||
# Project selection
|
||||
$selected = @(Show-ProjectSelectionMenu -Projects $projects)
|
||||
|
||||
if ($null -eq $selected -or $selected.Count -eq 0) {
|
||||
Write-Info "No projects selected"
|
||||
return
|
||||
}
|
||||
|
||||
# Confirm
|
||||
Clear-Host
|
||||
Write-Host ""
|
||||
$selectedSize = ($selected | Measure-Object -Property TotalSizeKB -Sum).Sum
|
||||
if ($null -eq $selectedSize) { $selectedSize = 0 }
|
||||
$selectedSizeHuman = Format-ByteSize -Bytes ($selectedSize * 1024)
|
||||
|
||||
Write-Host "$esc[33mThe following will be cleaned ($selectedSizeHuman):$esc[0m"
|
||||
Write-Host ""
|
||||
|
||||
foreach ($project in $selected) {
|
||||
Write-Host " $($script:Icons.List) $($project.Name) ($($project.TotalSizeHuman))"
|
||||
foreach ($artifact in $project.Artifacts) {
|
||||
Write-Host " $esc[90m$($artifact.Name) - $($artifact.SizeHuman)$esc[0m"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
$confirm = Read-Host "Continue? (y/N)"
|
||||
|
||||
if ($confirm -eq 'y' -or $confirm -eq 'Y') {
|
||||
Remove-ProjectArtifacts -Projects $selected
|
||||
Show-PurgeSummary
|
||||
}
|
||||
else {
|
||||
Write-Info "Cancelled"
|
||||
}
|
||||
}
|
||||
|
||||
# Run main
|
||||
Main
|
||||
166
bin/purge.sh
166
bin/purge.sh
@@ -1,166 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Mole - Purge command.
|
||||
# Cleans heavy project build artifacts.
|
||||
# Interactive selection by project.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Fix locale issues (avoid Perl warnings on non-English systems)
|
||||
export LC_ALL=C
|
||||
export LANG=C
|
||||
|
||||
# Get script directory and source common functions
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../lib/core/common.sh"
|
||||
|
||||
# Set up cleanup trap for temporary files
|
||||
trap cleanup_temp_files EXIT INT TERM
|
||||
source "$SCRIPT_DIR/../lib/core/log.sh"
|
||||
source "$SCRIPT_DIR/../lib/clean/project.sh"
|
||||
|
||||
# Configuration
|
||||
CURRENT_SECTION=""
|
||||
|
||||
# Section management
|
||||
start_section() {
|
||||
local section_name="$1"
|
||||
CURRENT_SECTION="$section_name"
|
||||
printf '\n'
|
||||
echo -e "${BLUE}━━━ ${section_name} ━━━${NC}"
|
||||
}
|
||||
|
||||
end_section() {
|
||||
CURRENT_SECTION=""
|
||||
}
|
||||
|
||||
# Note activity for export list
|
||||
note_activity() {
|
||||
if [[ -n "$CURRENT_SECTION" ]]; then
|
||||
printf '%s\n' "$CURRENT_SECTION" >> "$EXPORT_LIST_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
# Main purge function
|
||||
start_purge() {
|
||||
# Clear screen for better UX
|
||||
if [[ -t 1 ]]; then
|
||||
printf '\033[2J\033[H'
|
||||
fi
|
||||
printf '\n'
|
||||
echo -e "${PURPLE_BOLD}Purge Project Artifacts${NC}"
|
||||
|
||||
# Initialize stats file in user cache directory
|
||||
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
|
||||
ensure_user_dir "$stats_dir"
|
||||
ensure_user_file "$stats_dir/purge_stats"
|
||||
ensure_user_file "$stats_dir/purge_count"
|
||||
echo "0" > "$stats_dir/purge_stats"
|
||||
echo "0" > "$stats_dir/purge_count"
|
||||
}
|
||||
|
||||
# Perform the purge
|
||||
perform_purge() {
|
||||
clean_project_artifacts
|
||||
local exit_code=$?
|
||||
|
||||
# Exit codes:
|
||||
# 0 = success, show summary
|
||||
# 1 = user cancelled
|
||||
# 2 = nothing to clean
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Final summary (matching clean.sh format)
|
||||
echo ""
|
||||
|
||||
local summary_heading="Purge complete"
|
||||
local -a summary_details=()
|
||||
local total_size_cleaned=0
|
||||
local total_items_cleaned=0
|
||||
|
||||
# Read stats from user cache directory
|
||||
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
|
||||
|
||||
if [[ -f "$stats_dir/purge_stats" ]]; then
|
||||
total_size_cleaned=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0")
|
||||
rm -f "$stats_dir/purge_stats"
|
||||
fi
|
||||
|
||||
# Read count
|
||||
if [[ -f "$stats_dir/purge_count" ]]; then
|
||||
total_items_cleaned=$(cat "$stats_dir/purge_count" 2> /dev/null || echo "0")
|
||||
rm -f "$stats_dir/purge_count"
|
||||
fi
|
||||
|
||||
if [[ $total_size_cleaned -gt 0 ]]; then
|
||||
local freed_gb
|
||||
freed_gb=$(echo "$total_size_cleaned" | awk '{printf "%.2f", $1/1024/1024}')
|
||||
|
||||
summary_details+=("Space freed: ${GREEN}${freed_gb}GB${NC}")
|
||||
summary_details+=("Free space now: $(get_free_space)")
|
||||
|
||||
if [[ $total_items_cleaned -gt 0 ]]; then
|
||||
summary_details+=("Items cleaned: $total_items_cleaned")
|
||||
fi
|
||||
else
|
||||
summary_details+=("No old project artifacts to clean.")
|
||||
summary_details+=("Free space now: $(get_free_space)")
|
||||
fi
|
||||
|
||||
print_summary_block "$summary_heading" "${summary_details[@]}"
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
# Show help message
|
||||
show_help() {
|
||||
echo -e "${PURPLE_BOLD}Mole Purge${NC} - Clean old project build artifacts"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Usage:${NC} mo purge [options]"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Options:${NC}"
|
||||
echo " --paths Edit custom scan directories"
|
||||
echo " --debug Enable debug logging"
|
||||
echo " --help Show this help message"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Default Paths:${NC}"
|
||||
for path in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do
|
||||
echo " - $path"
|
||||
done
|
||||
}
|
||||
|
||||
# Main entry point
|
||||
main() {
|
||||
# Set up signal handling
|
||||
trap 'show_cursor; exit 130' INT TERM
|
||||
|
||||
# Parse arguments
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
"--paths")
|
||||
source "$SCRIPT_DIR/../lib/manage/purge_paths.sh"
|
||||
manage_purge_paths
|
||||
exit 0
|
||||
;;
|
||||
"--help")
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
"--debug")
|
||||
export MO_DEBUG=1
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $arg"
|
||||
echo "Use 'mo purge --help' for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
start_purge
|
||||
hide_cursor
|
||||
perform_purge
|
||||
show_cursor
|
||||
}
|
||||
|
||||
main "$@"
|
||||
73
bin/status.ps1
Normal file
73
bin/status.ps1
Normal file
@@ -0,0 +1,73 @@
|
||||
# Mole - Status Command
|
||||
# System status monitor wrapper
|
||||
|
||||
#Requires -Version 5.1
|
||||
param(
|
||||
[switch]$ShowHelp
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Script location
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$windowsDir = Split-Path -Parent $scriptDir
|
||||
$binPath = Join-Path $windowsDir "bin\status.exe"
|
||||
|
||||
# Help
|
||||
function Show-StatusHelp {
|
||||
$esc = [char]27
|
||||
Write-Host ""
|
||||
Write-Host "$esc[1;35mMole Status$esc[0m - Real-time system health monitor"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mUsage:$esc[0m mole status"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mOptions:$esc[0m"
|
||||
Write-Host " -ShowHelp Show this help message"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mDisplays:$esc[0m"
|
||||
Write-Host " - System health score (0-100)"
|
||||
Write-Host " - CPU usage and model"
|
||||
Write-Host " - Memory and swap usage"
|
||||
Write-Host " - Disk space per drive"
|
||||
Write-Host " - Top processes by CPU"
|
||||
Write-Host " - Network interfaces"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mKeybindings:$esc[0m"
|
||||
Write-Host " c Toggle mole animation"
|
||||
Write-Host " r Force refresh"
|
||||
Write-Host " q Quit"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
if ($ShowHelp) {
|
||||
Show-StatusHelp
|
||||
return
|
||||
}
|
||||
|
||||
# Check if binary exists
|
||||
if (-not (Test-Path $binPath)) {
|
||||
Write-Host "Building status tool..." -ForegroundColor Cyan
|
||||
|
||||
$cmdDir = Join-Path $windowsDir "cmd\status"
|
||||
$binDir = Join-Path $windowsDir "bin"
|
||||
|
||||
if (-not (Test-Path $binDir)) {
|
||||
New-Item -ItemType Directory -Path $binDir -Force | Out-Null
|
||||
}
|
||||
|
||||
Push-Location $windowsDir
|
||||
try {
|
||||
$result = & go build -o "$binPath" "./cmd/status/" 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Failed to build status tool: $result" -ForegroundColor Red
|
||||
Pop-Location
|
||||
return
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
# Run the binary
|
||||
& $binPath
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Mole - Status command.
|
||||
# Runs the Go system status panel.
|
||||
# Shows live system metrics.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
GO_BIN="$SCRIPT_DIR/status-go"
|
||||
if [[ -x "$GO_BIN" ]]; then
|
||||
exec "$GO_BIN" "$@"
|
||||
fi
|
||||
|
||||
echo "Bundled status binary not found. Please reinstall Mole or run mo update to restore it." >&2
|
||||
exit 1
|
||||
325
bin/touchid.sh
325
bin/touchid.sh
@@ -1,325 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Mole - Touch ID command.
|
||||
# Configures sudo with Touch ID.
|
||||
# Guided toggle with safety checks.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Determine script location and source common functions
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LIB_DIR="$(cd "$SCRIPT_DIR/../lib" && pwd)"
|
||||
|
||||
# Source common functions
|
||||
# shellcheck source=../lib/core/common.sh
|
||||
source "$LIB_DIR/core/common.sh"
|
||||
|
||||
readonly PAM_SUDO_FILE="${MOLE_PAM_SUDO_FILE:-/etc/pam.d/sudo}"
|
||||
readonly PAM_SUDO_LOCAL_FILE="${MOLE_PAM_SUDO_LOCAL_FILE:-/etc/pam.d/sudo_local}"
|
||||
readonly PAM_TID_LINE="auth sufficient pam_tid.so"
|
||||
|
||||
# Check if Touch ID is already configured
|
||||
is_touchid_configured() {
|
||||
# Check sudo_local first
|
||||
if [[ -f "$PAM_SUDO_LOCAL_FILE" ]]; then
|
||||
grep -q "pam_tid.so" "$PAM_SUDO_LOCAL_FILE" 2> /dev/null && return 0
|
||||
fi
|
||||
|
||||
# Fallback to standard sudo file
|
||||
if [[ ! -f "$PAM_SUDO_FILE" ]]; then
|
||||
return 1
|
||||
fi
|
||||
grep -q "pam_tid.so" "$PAM_SUDO_FILE" 2> /dev/null
|
||||
}
|
||||
|
||||
# Check if system supports Touch ID
|
||||
supports_touchid() {
|
||||
# Check if bioutil exists and has Touch ID capability
|
||||
if command -v bioutil &> /dev/null; then
|
||||
bioutil -r 2> /dev/null | grep -q "Touch ID" && return 0
|
||||
fi
|
||||
|
||||
# Fallback: check if running on Apple Silicon or modern Intel Mac
|
||||
local arch
|
||||
arch=$(uname -m)
|
||||
if [[ "$arch" == "arm64" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# For Intel Macs, check if it's 2018 or later (approximation)
|
||||
local model_year
|
||||
model_year=$(system_profiler SPHardwareDataType 2> /dev/null | grep "Model Identifier" | grep -o "[0-9]\{4\}" | head -1)
|
||||
if [[ -n "$model_year" ]] && [[ "$model_year" -ge 2018 ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Show current Touch ID status
|
||||
show_status() {
|
||||
if is_touchid_configured; then
|
||||
echo -e "${GREEN}${ICON_SUCCESS}${NC} Touch ID is enabled for sudo"
|
||||
else
|
||||
echo -e "${YELLOW}☻${NC} Touch ID is not configured for sudo"
|
||||
fi
|
||||
}
|
||||
|
||||
# Enable Touch ID for sudo
|
||||
enable_touchid() {
|
||||
# Cleanup trap
|
||||
local temp_file=""
|
||||
trap '[[ -n "${temp_file:-}" ]] && rm -f "${temp_file:-}"' EXIT
|
||||
|
||||
# First check if system supports Touch ID
|
||||
if ! supports_touchid; then
|
||||
log_warning "This Mac may not support Touch ID"
|
||||
read -rp "Continue anyway? [y/N] " confirm
|
||||
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
||||
echo -e "${YELLOW}Cancelled${NC}"
|
||||
return 1
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Check if we should use sudo_local (Sonoma+)
|
||||
if grep -q "sudo_local" "$PAM_SUDO_FILE"; then
|
||||
# Check if already correctly configured in sudo_local
|
||||
if [[ -f "$PAM_SUDO_LOCAL_FILE" ]] && grep -q "pam_tid.so" "$PAM_SUDO_LOCAL_FILE"; then
|
||||
# It is in sudo_local, but let's check if it's ALSO in sudo (incomplete migration)
|
||||
if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then
|
||||
# Clean up legacy config
|
||||
temp_file=$(mktemp)
|
||||
grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file"
|
||||
if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then
|
||||
echo -e "${GREEN}${ICON_SUCCESS} Cleanup legacy configuration${NC}"
|
||||
fi
|
||||
fi
|
||||
echo -e "${GREEN}${ICON_SUCCESS} Touch ID is already enabled${NC}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Not configured in sudo_local yet.
|
||||
# Check if configured in sudo (Legacy)
|
||||
local is_legacy_configured=false
|
||||
if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then
|
||||
is_legacy_configured=true
|
||||
fi
|
||||
|
||||
# Function to write to sudo_local
|
||||
local write_success=false
|
||||
if [[ ! -f "$PAM_SUDO_LOCAL_FILE" ]]; then
|
||||
# Create the file
|
||||
echo "# sudo_local: local customizations for sudo" | sudo tee "$PAM_SUDO_LOCAL_FILE" > /dev/null
|
||||
echo "$PAM_TID_LINE" | sudo tee -a "$PAM_SUDO_LOCAL_FILE" > /dev/null
|
||||
sudo chmod 444 "$PAM_SUDO_LOCAL_FILE"
|
||||
sudo chown root:wheel "$PAM_SUDO_LOCAL_FILE"
|
||||
write_success=true
|
||||
else
|
||||
# Append if not present
|
||||
if ! grep -q "pam_tid.so" "$PAM_SUDO_LOCAL_FILE"; then
|
||||
temp_file=$(mktemp)
|
||||
cp "$PAM_SUDO_LOCAL_FILE" "$temp_file"
|
||||
echo "$PAM_TID_LINE" >> "$temp_file"
|
||||
sudo mv "$temp_file" "$PAM_SUDO_LOCAL_FILE"
|
||||
sudo chmod 444 "$PAM_SUDO_LOCAL_FILE"
|
||||
sudo chown root:wheel "$PAM_SUDO_LOCAL_FILE"
|
||||
write_success=true
|
||||
else
|
||||
write_success=true # Already there (should be caught by first check, but safe fallback)
|
||||
fi
|
||||
fi
|
||||
|
||||
if $write_success; then
|
||||
# If we migrated from legacy, clean it up now
|
||||
if $is_legacy_configured; then
|
||||
temp_file=$(mktemp)
|
||||
grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file"
|
||||
sudo mv "$temp_file" "$PAM_SUDO_FILE"
|
||||
log_success "Touch ID migrated to sudo_local"
|
||||
else
|
||||
log_success "Touch ID enabled (via sudo_local) - try: sudo ls"
|
||||
fi
|
||||
return 0
|
||||
else
|
||||
log_error "Failed to write to sudo_local"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Legacy method: Modify sudo file directly
|
||||
|
||||
# Check if already configured (Legacy)
|
||||
if is_touchid_configured; then
|
||||
echo -e "${GREEN}${ICON_SUCCESS} Touch ID is already enabled${NC}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Create backup only if it doesn't exist to preserve original state
|
||||
if [[ ! -f "${PAM_SUDO_FILE}.mole-backup" ]]; then
|
||||
if ! sudo cp "$PAM_SUDO_FILE" "${PAM_SUDO_FILE}.mole-backup" 2> /dev/null; then
|
||||
log_error "Failed to create backup"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create temp file
|
||||
temp_file=$(mktemp)
|
||||
|
||||
# Insert pam_tid.so after the first comment block
|
||||
awk '
|
||||
BEGIN { inserted = 0 }
|
||||
/^#/ { print; next }
|
||||
!inserted && /^[^#]/ {
|
||||
print "'"$PAM_TID_LINE"'"
|
||||
inserted = 1
|
||||
}
|
||||
{ print }
|
||||
' "$PAM_SUDO_FILE" > "$temp_file"
|
||||
|
||||
# Verify content change
|
||||
if cmp -s "$PAM_SUDO_FILE" "$temp_file"; then
|
||||
log_error "Failed to modify configuration"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Apply the changes
|
||||
if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then
|
||||
log_success "Touch ID enabled - try: sudo ls"
|
||||
return 0
|
||||
else
|
||||
log_error "Failed to enable Touch ID"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Disable Touch ID for sudo
|
||||
disable_touchid() {
|
||||
# Cleanup trap
|
||||
local temp_file=""
|
||||
trap '[[ -n "${temp_file:-}" ]] && rm -f "${temp_file:-}"' EXIT
|
||||
|
||||
if ! is_touchid_configured; then
|
||||
echo -e "${YELLOW}Touch ID is not currently enabled${NC}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check sudo_local first
|
||||
if [[ -f "$PAM_SUDO_LOCAL_FILE" ]] && grep -q "pam_tid.so" "$PAM_SUDO_LOCAL_FILE"; then
|
||||
# Remove from sudo_local
|
||||
temp_file=$(mktemp)
|
||||
grep -v "pam_tid.so" "$PAM_SUDO_LOCAL_FILE" > "$temp_file"
|
||||
|
||||
if sudo mv "$temp_file" "$PAM_SUDO_LOCAL_FILE" 2> /dev/null; then
|
||||
# Since we modified sudo_local, we should also check if it's in sudo file (legacy cleanup)
|
||||
if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then
|
||||
temp_file=$(mktemp)
|
||||
grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file"
|
||||
sudo mv "$temp_file" "$PAM_SUDO_FILE"
|
||||
fi
|
||||
echo -e "${GREEN}${ICON_SUCCESS} Touch ID disabled (removed from sudo_local)${NC}"
|
||||
echo ""
|
||||
return 0
|
||||
else
|
||||
log_error "Failed to disable Touch ID from sudo_local"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback to sudo file (legacy)
|
||||
if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then
|
||||
# Create backup only if it doesn't exist
|
||||
if [[ ! -f "${PAM_SUDO_FILE}.mole-backup" ]]; then
|
||||
if ! sudo cp "$PAM_SUDO_FILE" "${PAM_SUDO_FILE}.mole-backup" 2> /dev/null; then
|
||||
log_error "Failed to create backup"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Remove pam_tid.so line
|
||||
temp_file=$(mktemp)
|
||||
grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file"
|
||||
|
||||
if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then
|
||||
echo -e "${GREEN}${ICON_SUCCESS} Touch ID disabled${NC}"
|
||||
echo ""
|
||||
return 0
|
||||
else
|
||||
log_error "Failed to disable Touch ID"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Should not reach here if is_touchid_configured was true
|
||||
log_error "Could not find Touch ID configuration to disable"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Interactive menu
|
||||
show_menu() {
|
||||
echo ""
|
||||
show_status
|
||||
if is_touchid_configured; then
|
||||
echo -ne "${PURPLE}☛${NC} Press ${GREEN}Enter${NC} to disable, ${GRAY}Q${NC} to quit: "
|
||||
IFS= read -r -s -n1 key || key=""
|
||||
drain_pending_input # Clean up any escape sequence remnants
|
||||
echo ""
|
||||
|
||||
case "$key" in
|
||||
$'\e') # ESC
|
||||
return 0
|
||||
;;
|
||||
"" | $'\n' | $'\r') # Enter
|
||||
printf "\r\033[K" # Clear the prompt line
|
||||
disable_touchid
|
||||
;;
|
||||
*)
|
||||
echo ""
|
||||
log_error "Invalid key"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
echo -ne "${PURPLE}☛${NC} Press ${GREEN}Enter${NC} to enable, ${GRAY}Q${NC} to quit: "
|
||||
IFS= read -r -s -n1 key || key=""
|
||||
drain_pending_input # Clean up any escape sequence remnants
|
||||
|
||||
case "$key" in
|
||||
$'\e') # ESC
|
||||
return 0
|
||||
;;
|
||||
"" | $'\n' | $'\r') # Enter
|
||||
printf "\r\033[K" # Clear the prompt line
|
||||
enable_touchid
|
||||
;;
|
||||
*)
|
||||
echo ""
|
||||
log_error "Invalid key"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
||||
# Main
|
||||
main() {
|
||||
local command="${1:-}"
|
||||
|
||||
case "$command" in
|
||||
enable)
|
||||
enable_touchid
|
||||
;;
|
||||
disable)
|
||||
disable_touchid
|
||||
;;
|
||||
status)
|
||||
show_status
|
||||
;;
|
||||
"")
|
||||
show_menu
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown command: $command"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
623
bin/uninstall.ps1
Normal file
623
bin/uninstall.ps1
Normal file
@@ -0,0 +1,623 @@
|
||||
# Mole - Uninstall Command
|
||||
# Interactive application uninstaller for Windows
|
||||
|
||||
#Requires -Version 5.1
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$DebugMode,
|
||||
[switch]$Rescan,
|
||||
[switch]$ShowHelp
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Script location
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$libDir = Join-Path (Split-Path -Parent $scriptDir) "lib"
|
||||
|
||||
# Import core modules
|
||||
. "$libDir\core\base.ps1"
|
||||
. "$libDir\core\log.ps1"
|
||||
. "$libDir\core\ui.ps1"
|
||||
. "$libDir\core\file_ops.ps1"
|
||||
|
||||
# ============================================================================
|
||||
# Configuration
|
||||
# ============================================================================
|
||||
|
||||
$script:CacheDir = "$env:USERPROFILE\.cache\mole"
|
||||
$script:AppCacheFile = "$script:CacheDir\app_scan_cache.json"
|
||||
$script:CacheTTLHours = 24
|
||||
|
||||
# ============================================================================
|
||||
# Help
|
||||
# ============================================================================
|
||||
|
||||
function Show-UninstallHelp {
|
||||
$esc = [char]27
|
||||
Write-Host ""
|
||||
Write-Host "$esc[1;35mMole Uninstall$esc[0m - Interactive application uninstaller"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mUsage:$esc[0m mole uninstall [options]"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mOptions:$esc[0m"
|
||||
Write-Host " -Rescan Force rescan of installed applications"
|
||||
Write-Host " -DebugMode Enable debug logging"
|
||||
Write-Host " -ShowHelp Show this help message"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mFeatures:$esc[0m"
|
||||
Write-Host " - Scans installed programs from registry and Windows Apps"
|
||||
Write-Host " - Shows program size and last used date"
|
||||
Write-Host " - Interactive selection with arrow keys"
|
||||
Write-Host " - Cleans leftover files after uninstall"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Protected Applications
|
||||
# ============================================================================
|
||||
|
||||
$script:ProtectedApps = @(
|
||||
"Microsoft Windows"
|
||||
"Windows Feature Experience Pack"
|
||||
"Microsoft Edge"
|
||||
"Microsoft Edge WebView2"
|
||||
"Windows Security"
|
||||
"Microsoft Visual C++ *"
|
||||
"Microsoft .NET *"
|
||||
".NET Desktop Runtime*"
|
||||
"Microsoft Update Health Tools"
|
||||
"NVIDIA Graphics Driver*"
|
||||
"AMD Software*"
|
||||
"Intel*Driver*"
|
||||
)
|
||||
|
||||
function Test-ProtectedApp {
|
||||
param([string]$AppName)
|
||||
|
||||
foreach ($pattern in $script:ProtectedApps) {
|
||||
if ($AppName -like $pattern) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Application Discovery
|
||||
# ============================================================================
|
||||
|
||||
function Get-InstalledApplications {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Scan and return all installed applications
|
||||
#>
|
||||
param([switch]$ForceRescan)
|
||||
|
||||
# Check cache
|
||||
if (-not $ForceRescan -and (Test-Path $script:AppCacheFile)) {
|
||||
$cacheInfo = Get-Item $script:AppCacheFile
|
||||
$cacheAge = (Get-Date) - $cacheInfo.LastWriteTime
|
||||
|
||||
if ($cacheAge.TotalHours -lt $script:CacheTTLHours) {
|
||||
Write-Debug "Loading from cache..."
|
||||
try {
|
||||
$cached = Get-Content $script:AppCacheFile | ConvertFrom-Json
|
||||
return $cached
|
||||
}
|
||||
catch {
|
||||
Write-Debug "Cache read failed, rescanning..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Info "Scanning installed applications..."
|
||||
|
||||
$apps = @()
|
||||
|
||||
# Registry paths for installed programs
|
||||
$registryPaths = @(
|
||||
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*"
|
||||
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"
|
||||
"HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*"
|
||||
)
|
||||
|
||||
$count = 0
|
||||
$total = $registryPaths.Count
|
||||
|
||||
foreach ($path in $registryPaths) {
|
||||
$count++
|
||||
Write-Progress -Activity "Scanning applications" -Status "Registry path $count of $total" -PercentComplete (($count / $total) * 50)
|
||||
|
||||
try {
|
||||
$regItems = Get-ItemProperty -Path $path -ErrorAction SilentlyContinue
|
||||
|
||||
foreach ($item in $regItems) {
|
||||
# Skip items without required properties
|
||||
$displayName = $null
|
||||
$uninstallString = $null
|
||||
|
||||
try { $displayName = $item.DisplayName } catch { }
|
||||
try { $uninstallString = $item.UninstallString } catch { }
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($displayName) -or [string]::IsNullOrWhiteSpace($uninstallString)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (Test-ProtectedApp $displayName) {
|
||||
continue
|
||||
}
|
||||
|
||||
# Calculate size
|
||||
$sizeKB = 0
|
||||
try {
|
||||
if ($item.EstimatedSize) {
|
||||
$sizeKB = [long]$item.EstimatedSize
|
||||
}
|
||||
elseif ($item.InstallLocation -and (Test-Path $item.InstallLocation -ErrorAction SilentlyContinue)) {
|
||||
$sizeKB = Get-PathSizeKB -Path $item.InstallLocation
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
# Get install date
|
||||
$installDate = $null
|
||||
try {
|
||||
if ($item.InstallDate) {
|
||||
$installDate = [DateTime]::ParseExact($item.InstallDate, "yyyyMMdd", $null)
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
# Get other properties safely
|
||||
$publisher = $null
|
||||
$version = $null
|
||||
$installLocation = $null
|
||||
|
||||
try { $publisher = $item.Publisher } catch { }
|
||||
try { $version = $item.DisplayVersion } catch { }
|
||||
try { $installLocation = $item.InstallLocation } catch { }
|
||||
|
||||
$apps += [PSCustomObject]@{
|
||||
Name = $displayName
|
||||
Publisher = $publisher
|
||||
Version = $version
|
||||
SizeKB = $sizeKB
|
||||
SizeHuman = Format-ByteSize -Bytes ($sizeKB * 1024)
|
||||
InstallLocation = $installLocation
|
||||
UninstallString = $uninstallString
|
||||
InstallDate = $installDate
|
||||
Source = "Registry"
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Debug "Error scanning registry path $path : $_"
|
||||
}
|
||||
}
|
||||
|
||||
# UWP / Store Apps
|
||||
Write-Progress -Activity "Scanning applications" -Status "Scanning Windows Apps" -PercentComplete 75
|
||||
|
||||
try {
|
||||
$uwpApps = Get-AppxPackage -ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
$_.IsFramework -eq $false -and
|
||||
$_.SignatureKind -ne 'System' -and
|
||||
-not (Test-ProtectedApp $_.Name)
|
||||
}
|
||||
|
||||
foreach ($uwp in $uwpApps) {
|
||||
# Get friendly name
|
||||
$name = $uwp.Name
|
||||
try {
|
||||
$manifest = Get-AppxPackageManifest -Package $uwp.PackageFullName -ErrorAction SilentlyContinue
|
||||
if ($manifest.Package.Properties.DisplayName -and
|
||||
-not $manifest.Package.Properties.DisplayName.StartsWith("ms-resource:")) {
|
||||
$name = $manifest.Package.Properties.DisplayName
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
# Calculate size
|
||||
$sizeKB = 0
|
||||
if ($uwp.InstallLocation -and (Test-Path $uwp.InstallLocation)) {
|
||||
$sizeKB = Get-PathSizeKB -Path $uwp.InstallLocation
|
||||
}
|
||||
|
||||
$apps += [PSCustomObject]@{
|
||||
Name = $name
|
||||
Publisher = $uwp.Publisher
|
||||
Version = $uwp.Version
|
||||
SizeKB = $sizeKB
|
||||
SizeHuman = Format-ByteSize -Bytes ($sizeKB * 1024)
|
||||
InstallLocation = $uwp.InstallLocation
|
||||
UninstallString = $null
|
||||
PackageFullName = $uwp.PackageFullName
|
||||
InstallDate = $null
|
||||
Source = "WindowsStore"
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Debug "Could not enumerate UWP apps: $_"
|
||||
}
|
||||
|
||||
Write-Progress -Activity "Scanning applications" -Completed
|
||||
|
||||
# Sort by size (largest first)
|
||||
$apps = $apps | Sort-Object -Property SizeKB -Descending
|
||||
|
||||
# Cache results
|
||||
if (-not (Test-Path $script:CacheDir)) {
|
||||
New-Item -ItemType Directory -Path $script:CacheDir -Force | Out-Null
|
||||
}
|
||||
$apps | ConvertTo-Json -Depth 5 | Set-Content $script:AppCacheFile
|
||||
|
||||
return $apps
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Application Selection UI
|
||||
# ============================================================================
|
||||
|
||||
function Show-AppSelectionMenu {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Interactive menu for selecting applications to uninstall
|
||||
#>
|
||||
param([array]$Apps)
|
||||
|
||||
if ($Apps.Count -eq 0) {
|
||||
Write-MoleWarning "No applications found to uninstall"
|
||||
return @()
|
||||
}
|
||||
|
||||
$esc = [char]27
|
||||
$selectedIndices = @{}
|
||||
$currentIndex = 0
|
||||
$pageSize = 15
|
||||
$pageStart = 0
|
||||
$searchTerm = ""
|
||||
$filteredApps = $Apps
|
||||
|
||||
# Hide cursor (may fail in non-interactive terminals)
|
||||
try { [Console]::CursorVisible = $false } catch { }
|
||||
|
||||
try {
|
||||
while ($true) {
|
||||
Clear-Host
|
||||
|
||||
# Header
|
||||
Write-Host ""
|
||||
Write-Host "$esc[1;35mSelect Applications to Uninstall$esc[0m"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[90mUse: $($script:Icons.NavUp)$($script:Icons.NavDown) navigate | Space select | Enter confirm | Q quit | / search$esc[0m"
|
||||
Write-Host ""
|
||||
|
||||
# Search indicator
|
||||
if ($searchTerm) {
|
||||
Write-Host "$esc[33mSearch:$esc[0m $searchTerm ($($filteredApps.Count) matches)"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# Display apps
|
||||
$pageEnd = [Math]::Min($pageStart + $pageSize, $filteredApps.Count)
|
||||
|
||||
for ($i = $pageStart; $i -lt $pageEnd; $i++) {
|
||||
$app = $filteredApps[$i]
|
||||
$isSelected = $selectedIndices.ContainsKey($app.Name)
|
||||
$isCurrent = ($i -eq $currentIndex)
|
||||
|
||||
# Selection indicator
|
||||
$checkbox = if ($isSelected) { "$esc[32m[$($script:Icons.Success)]$esc[0m" } else { "[ ]" }
|
||||
|
||||
# Highlight current
|
||||
if ($isCurrent) {
|
||||
Write-Host "$esc[7m" -NoNewline # Reverse video
|
||||
}
|
||||
|
||||
# App info
|
||||
$name = $app.Name
|
||||
if ($name.Length -gt 40) {
|
||||
$name = $name.Substring(0, 37) + "..."
|
||||
}
|
||||
|
||||
$size = $app.SizeHuman
|
||||
if (-not $size -or $size -eq "0B") {
|
||||
$size = "N/A"
|
||||
}
|
||||
|
||||
Write-Host (" {0} {1,-42} {2,10}" -f $checkbox, $name, $size) -NoNewline
|
||||
|
||||
if ($isCurrent) {
|
||||
Write-Host "$esc[0m" # Reset
|
||||
}
|
||||
else {
|
||||
Write-Host ""
|
||||
}
|
||||
}
|
||||
|
||||
# Footer
|
||||
Write-Host ""
|
||||
$selectedCount = $selectedIndices.Count
|
||||
if ($selectedCount -gt 0) {
|
||||
$totalSize = 0
|
||||
foreach ($key in $selectedIndices.Keys) {
|
||||
$app = $Apps | Where-Object { $_.Name -eq $key }
|
||||
if ($app.SizeKB) {
|
||||
$totalSize += $app.SizeKB
|
||||
}
|
||||
}
|
||||
$totalSizeHuman = Format-ByteSize -Bytes ($totalSize * 1024)
|
||||
Write-Host "$esc[33mSelected:$esc[0m $selectedCount apps ($totalSizeHuman)"
|
||||
}
|
||||
|
||||
# Page indicator
|
||||
$totalPages = [Math]::Ceiling($filteredApps.Count / $pageSize)
|
||||
$currentPage = [Math]::Floor($pageStart / $pageSize) + 1
|
||||
Write-Host "$esc[90mPage $currentPage of $totalPages$esc[0m"
|
||||
|
||||
# Handle input
|
||||
$key = [Console]::ReadKey($true)
|
||||
|
||||
switch ($key.Key) {
|
||||
'UpArrow' {
|
||||
if ($currentIndex -gt 0) {
|
||||
$currentIndex--
|
||||
if ($currentIndex -lt $pageStart) {
|
||||
$pageStart = [Math]::Max(0, $pageStart - $pageSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
'DownArrow' {
|
||||
if ($currentIndex -lt $filteredApps.Count - 1) {
|
||||
$currentIndex++
|
||||
if ($currentIndex -ge $pageStart + $pageSize) {
|
||||
$pageStart += $pageSize
|
||||
}
|
||||
}
|
||||
}
|
||||
'PageUp' {
|
||||
$pageStart = [Math]::Max(0, $pageStart - $pageSize)
|
||||
$currentIndex = $pageStart
|
||||
}
|
||||
'PageDown' {
|
||||
$pageStart = [Math]::Min($filteredApps.Count - $pageSize, $pageStart + $pageSize)
|
||||
if ($pageStart -lt 0) { $pageStart = 0 }
|
||||
$currentIndex = $pageStart
|
||||
}
|
||||
'Spacebar' {
|
||||
$app = $filteredApps[$currentIndex]
|
||||
if ($selectedIndices.ContainsKey($app.Name)) {
|
||||
$selectedIndices.Remove($app.Name)
|
||||
}
|
||||
else {
|
||||
$selectedIndices[$app.Name] = $true
|
||||
}
|
||||
}
|
||||
'Enter' {
|
||||
if ($selectedIndices.Count -gt 0) {
|
||||
# Return selected apps
|
||||
$selected = $Apps | Where-Object { $selectedIndices.ContainsKey($_.Name) }
|
||||
return $selected
|
||||
}
|
||||
}
|
||||
'Escape' {
|
||||
return @()
|
||||
}
|
||||
'Q' {
|
||||
return @()
|
||||
}
|
||||
'Oem2' { # Forward slash
|
||||
# Search mode
|
||||
Write-Host ""
|
||||
Write-Host "Search: " -NoNewline
|
||||
try { [Console]::CursorVisible = $true } catch { }
|
||||
$searchTerm = Read-Host
|
||||
try { [Console]::CursorVisible = $false } catch { }
|
||||
|
||||
if ($searchTerm) {
|
||||
$filteredApps = $Apps | Where-Object { $_.Name -like "*$searchTerm*" }
|
||||
}
|
||||
else {
|
||||
$filteredApps = $Apps
|
||||
}
|
||||
$currentIndex = 0
|
||||
$pageStart = 0
|
||||
}
|
||||
'Backspace' {
|
||||
if ($searchTerm) {
|
||||
$searchTerm = ""
|
||||
$filteredApps = $Apps
|
||||
$currentIndex = 0
|
||||
$pageStart = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
try { [Console]::CursorVisible = $true } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Uninstallation
|
||||
# ============================================================================
|
||||
|
||||
function Uninstall-SelectedApps {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Uninstall the selected applications
|
||||
#>
|
||||
param([array]$Apps)
|
||||
|
||||
$esc = [char]27
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "$esc[1;35mUninstalling Applications$esc[0m"
|
||||
Write-Host ""
|
||||
|
||||
$successCount = 0
|
||||
$failCount = 0
|
||||
|
||||
foreach ($app in $Apps) {
|
||||
Write-Host "$esc[34m$($script:Icons.Arrow)$esc[0m Uninstalling: $($app.Name)" -NoNewline
|
||||
|
||||
try {
|
||||
if ($app.Source -eq "WindowsStore") {
|
||||
# UWP app
|
||||
if ($app.PackageFullName) {
|
||||
Remove-AppxPackage -Package $app.PackageFullName -ErrorAction Stop
|
||||
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m"
|
||||
$successCount++
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Registry app with uninstall string
|
||||
$uninstallString = $app.UninstallString
|
||||
|
||||
# Handle different uninstall types
|
||||
if ($uninstallString -like "MsiExec.exe*") {
|
||||
# MSI uninstall
|
||||
$productCode = [regex]::Match($uninstallString, '\{[0-9A-F-]+\}').Value
|
||||
if ($productCode) {
|
||||
$process = Start-Process -FilePath "msiexec.exe" `
|
||||
-ArgumentList "/x", $productCode, "/qn", "/norestart" `
|
||||
-Wait -PassThru -NoNewWindow
|
||||
|
||||
if ($process.ExitCode -eq 0 -or $process.ExitCode -eq 3010) {
|
||||
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m"
|
||||
$successCount++
|
||||
}
|
||||
else {
|
||||
Write-Host " $esc[33m(requires interaction)$esc[0m"
|
||||
# Fallback to interactive uninstall
|
||||
Start-Process -FilePath "msiexec.exe" -ArgumentList "/x", $productCode -Wait
|
||||
$successCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Direct executable uninstall
|
||||
# Try silent uninstall first
|
||||
$silentArgs = @("/S", "/silent", "/quiet", "-s", "-silent", "-quiet", "/VERYSILENT")
|
||||
$uninstalled = $false
|
||||
|
||||
foreach ($arg in $silentArgs) {
|
||||
try {
|
||||
$process = Start-Process -FilePath "cmd.exe" `
|
||||
-ArgumentList "/c", "`"$uninstallString`"", $arg `
|
||||
-Wait -PassThru -NoNewWindow -ErrorAction SilentlyContinue
|
||||
|
||||
if ($process.ExitCode -eq 0) {
|
||||
Write-Host " $esc[32m$($script:Icons.Success)$esc[0m"
|
||||
$successCount++
|
||||
$uninstalled = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
if (-not $uninstalled) {
|
||||
# Fallback to interactive - don't count as automatic success
|
||||
Write-Host " $esc[33m(launching uninstaller - verify completion manually)$esc[0m"
|
||||
Start-Process -FilePath "cmd.exe" -ArgumentList "/c", "`"$uninstallString`"" -Wait
|
||||
# Note: Not incrementing $successCount since we can't verify if user completed or cancelled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Clean leftover files
|
||||
if ($app.InstallLocation -and (Test-Path $app.InstallLocation)) {
|
||||
Write-Host " $esc[90mCleaning leftover files...$esc[0m"
|
||||
Remove-SafeItem -Path $app.InstallLocation -Description "Leftover files" -Recurse
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host " $esc[31m$($script:Icons.Error)$esc[0m"
|
||||
Write-Debug "Uninstall failed: $_"
|
||||
$failCount++
|
||||
}
|
||||
}
|
||||
|
||||
# Summary
|
||||
Write-Host ""
|
||||
Write-Host "$esc[1;35mUninstall Complete$esc[0m"
|
||||
Write-Host " Successfully uninstalled: $esc[32m$successCount$esc[0m"
|
||||
if ($failCount -gt 0) {
|
||||
Write-Host " Failed: $esc[31m$failCount$esc[0m"
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# Clear cache
|
||||
if (Test-Path $script:AppCacheFile) {
|
||||
Remove-Item $script:AppCacheFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main Entry Point
|
||||
# ============================================================================
|
||||
|
||||
function Main {
|
||||
# Enable debug if requested
|
||||
if ($DebugMode) {
|
||||
$env:MOLE_DEBUG = "1"
|
||||
$DebugPreference = "Continue"
|
||||
}
|
||||
|
||||
# Show help
|
||||
if ($ShowHelp) {
|
||||
Show-UninstallHelp
|
||||
return
|
||||
}
|
||||
|
||||
# Clear screen
|
||||
Clear-Host
|
||||
|
||||
# Get installed apps
|
||||
$apps = Get-InstalledApplications -ForceRescan:$Rescan
|
||||
|
||||
if ($apps.Count -eq 0) {
|
||||
Write-MoleWarning "No applications found"
|
||||
return
|
||||
}
|
||||
|
||||
Write-Info "Found $($apps.Count) applications"
|
||||
|
||||
# Show selection menu
|
||||
$selected = Show-AppSelectionMenu -Apps $apps
|
||||
|
||||
if ($selected.Count -eq 0) {
|
||||
Write-Info "No applications selected"
|
||||
return
|
||||
}
|
||||
|
||||
# Confirm uninstall
|
||||
$esc = [char]27
|
||||
Clear-Host
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mThe following applications will be uninstalled:$esc[0m"
|
||||
Write-Host ""
|
||||
|
||||
foreach ($app in $selected) {
|
||||
Write-Host " $($script:Icons.List) $($app.Name) ($($app.SizeHuman))"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
$confirm = Read-Host "Continue? (y/N)"
|
||||
|
||||
if ($confirm -eq 'y' -or $confirm -eq 'Y') {
|
||||
Uninstall-SelectedApps -Apps $selected
|
||||
}
|
||||
else {
|
||||
Write-Info "Cancelled"
|
||||
}
|
||||
}
|
||||
|
||||
# Run main
|
||||
Main
|
||||
586
bin/uninstall.sh
586
bin/uninstall.sh
@@ -1,586 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Mole - Uninstall command.
|
||||
# Interactive app uninstaller.
|
||||
# Removes app files and leftovers.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Fix locale issues on non-English systems.
|
||||
export LC_ALL=C
|
||||
export LANG=C
|
||||
|
||||
# Load shared helpers.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../lib/core/common.sh"
|
||||
|
||||
# Clean temp files on exit.
|
||||
trap cleanup_temp_files EXIT INT TERM
|
||||
source "$SCRIPT_DIR/../lib/ui/menu_paginated.sh"
|
||||
source "$SCRIPT_DIR/../lib/ui/app_selector.sh"
|
||||
source "$SCRIPT_DIR/../lib/uninstall/batch.sh"
|
||||
|
||||
# State
|
||||
selected_apps=()
|
||||
declare -a apps_data=()
|
||||
declare -a selection_state=()
|
||||
total_items=0
|
||||
files_cleaned=0
|
||||
total_size_cleaned=0
|
||||
|
||||
# Scan applications and collect information.
|
||||
scan_applications() {
|
||||
# Cache app scan (24h TTL).
|
||||
local cache_dir="$HOME/.cache/mole"
|
||||
local cache_file="$cache_dir/app_scan_cache"
|
||||
local cache_ttl=86400 # 24 hours
|
||||
local force_rescan="${1:-false}"
|
||||
|
||||
ensure_user_dir "$cache_dir"
|
||||
|
||||
if [[ $force_rescan == false && -f "$cache_file" ]]; then
|
||||
local cache_age=$(($(get_epoch_seconds) - $(get_file_mtime "$cache_file")))
|
||||
[[ $cache_age -eq $(get_epoch_seconds) ]] && cache_age=86401 # Handle mtime read failure
|
||||
if [[ $cache_age -lt $cache_ttl ]]; then
|
||||
if [[ -t 2 ]]; then
|
||||
echo -e "${GREEN}Loading from cache...${NC}" >&2
|
||||
sleep 0.3 # Brief pause so user sees the message
|
||||
fi
|
||||
echo "$cache_file"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
local inline_loading=false
|
||||
if [[ -t 1 && -t 2 ]]; then
|
||||
inline_loading=true
|
||||
printf "\033[2J\033[H" >&2 # Clear screen for inline loading
|
||||
fi
|
||||
|
||||
local temp_file
|
||||
temp_file=$(create_temp_file)
|
||||
|
||||
# Local spinner_pid for cleanup
|
||||
local spinner_pid=""
|
||||
|
||||
# Trap to handle Ctrl+C during scan
|
||||
local scan_interrupted=false
|
||||
# shellcheck disable=SC2329 # Function invoked indirectly via trap
|
||||
trap_scan_cleanup() {
|
||||
scan_interrupted=true
|
||||
if [[ -n "$spinner_pid" ]]; then
|
||||
kill -TERM "$spinner_pid" 2> /dev/null || true
|
||||
wait "$spinner_pid" 2> /dev/null || true
|
||||
fi
|
||||
printf "\r\033[K" >&2
|
||||
rm -f "$temp_file" "${temp_file}.sorted" "${temp_file}.progress" 2> /dev/null || true
|
||||
exit 130
|
||||
}
|
||||
trap trap_scan_cleanup INT
|
||||
|
||||
local current_epoch
|
||||
current_epoch=$(get_epoch_seconds)
|
||||
|
||||
# Pass 1: collect app paths and bundle IDs (no mdls).
|
||||
local -a app_data_tuples=()
|
||||
local -a app_dirs=(
|
||||
"/Applications"
|
||||
"$HOME/Applications"
|
||||
"/Library/Input Methods"
|
||||
"$HOME/Library/Input Methods"
|
||||
)
|
||||
local vol_app_dir
|
||||
local nullglob_was_set=0
|
||||
shopt -q nullglob && nullglob_was_set=1
|
||||
shopt -s nullglob
|
||||
for vol_app_dir in /Volumes/*/Applications; do
|
||||
[[ -d "$vol_app_dir" && -r "$vol_app_dir" ]] || continue
|
||||
if [[ -d "/Applications" && "$vol_app_dir" -ef "/Applications" ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ -d "$HOME/Applications" && "$vol_app_dir" -ef "$HOME/Applications" ]]; then
|
||||
continue
|
||||
fi
|
||||
app_dirs+=("$vol_app_dir")
|
||||
done
|
||||
if [[ $nullglob_was_set -eq 0 ]]; then
|
||||
shopt -u nullglob
|
||||
fi
|
||||
|
||||
for app_dir in "${app_dirs[@]}"; do
|
||||
if [[ ! -d "$app_dir" ]]; then continue; fi
|
||||
|
||||
while IFS= read -r -d '' app_path; do
|
||||
if [[ ! -e "$app_path" ]]; then continue; fi
|
||||
|
||||
local app_name
|
||||
app_name=$(basename "$app_path" .app)
|
||||
|
||||
# Skip nested apps inside another .app bundle.
|
||||
local parent_dir
|
||||
parent_dir=$(dirname "$app_path")
|
||||
if [[ "$parent_dir" == *".app" || "$parent_dir" == *".app/"* ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Bundle ID from plist (fast path).
|
||||
local bundle_id="unknown"
|
||||
if [[ -f "$app_path/Contents/Info.plist" ]]; then
|
||||
bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown")
|
||||
fi
|
||||
|
||||
if should_protect_from_uninstall "$bundle_id"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Store tuple for pass 2 (metadata + size).
|
||||
app_data_tuples+=("${app_path}|${app_name}|${bundle_id}")
|
||||
done < <(command find "$app_dir" -name "*.app" -maxdepth 3 -print0 2> /dev/null)
|
||||
done
|
||||
|
||||
# Pass 2: metadata + size in parallel (mdls is slow).
|
||||
local app_count=0
|
||||
local total_apps=${#app_data_tuples[@]}
|
||||
local max_parallel
|
||||
max_parallel=$(get_optimal_parallel_jobs "io")
|
||||
if [[ $max_parallel -lt 8 ]]; then
|
||||
max_parallel=8 # At least 8 for good performance
|
||||
elif [[ $max_parallel -gt 32 ]]; then
|
||||
max_parallel=32 # Cap at 32 to avoid too many processes
|
||||
fi
|
||||
local pids=()
|
||||
|
||||
process_app_metadata() {
|
||||
local app_data_tuple="$1"
|
||||
local output_file="$2"
|
||||
local current_epoch="$3"
|
||||
|
||||
IFS='|' read -r app_path app_name bundle_id <<< "$app_data_tuple"
|
||||
|
||||
# Display name priority: mdls display name → bundle display → bundle name → folder.
|
||||
local display_name="$app_name"
|
||||
if [[ -f "$app_path/Contents/Info.plist" ]]; then
|
||||
local md_display_name
|
||||
md_display_name=$(run_with_timeout 0.05 mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "")
|
||||
|
||||
local bundle_display_name
|
||||
bundle_display_name=$(plutil -extract CFBundleDisplayName raw "$app_path/Contents/Info.plist" 2> /dev/null)
|
||||
local bundle_name
|
||||
bundle_name=$(plutil -extract CFBundleName raw "$app_path/Contents/Info.plist" 2> /dev/null)
|
||||
|
||||
if [[ "$md_display_name" == /* ]]; then md_display_name=""; fi
|
||||
md_display_name="${md_display_name//|/-}"
|
||||
md_display_name="${md_display_name//[$'\t\r\n']/}"
|
||||
|
||||
bundle_display_name="${bundle_display_name//|/-}"
|
||||
bundle_display_name="${bundle_display_name//[$'\t\r\n']/}"
|
||||
|
||||
bundle_name="${bundle_name//|/-}"
|
||||
bundle_name="${bundle_name//[$'\t\r\n']/}"
|
||||
|
||||
if [[ -n "$md_display_name" && "$md_display_name" != "(null)" && "$md_display_name" != "$app_name" ]]; then
|
||||
display_name="$md_display_name"
|
||||
elif [[ -n "$bundle_display_name" && "$bundle_display_name" != "(null)" ]]; then
|
||||
display_name="$bundle_display_name"
|
||||
elif [[ -n "$bundle_name" && "$bundle_name" != "(null)" ]]; then
|
||||
display_name="$bundle_name"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$display_name" == /* ]]; then
|
||||
display_name="$app_name"
|
||||
fi
|
||||
display_name="${display_name//|/-}"
|
||||
display_name="${display_name//[$'\t\r\n']/}"
|
||||
|
||||
# App size (KB → human).
|
||||
local app_size="N/A"
|
||||
local app_size_kb="0"
|
||||
if [[ -d "$app_path" ]]; then
|
||||
app_size_kb=$(get_path_size_kb "$app_path")
|
||||
app_size=$(bytes_to_human "$((app_size_kb * 1024))")
|
||||
fi
|
||||
|
||||
# Last used: mdls (fast timeout) → mtime.
|
||||
local last_used="Never"
|
||||
local last_used_epoch=0
|
||||
|
||||
if [[ -d "$app_path" ]]; then
|
||||
local metadata_date
|
||||
metadata_date=$(run_with_timeout 0.1 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "")
|
||||
|
||||
if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then
|
||||
last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0")
|
||||
fi
|
||||
|
||||
if [[ "$last_used_epoch" -eq 0 ]]; then
|
||||
last_used_epoch=$(get_file_mtime "$app_path")
|
||||
fi
|
||||
|
||||
if [[ $last_used_epoch -gt 0 ]]; then
|
||||
local days_ago=$(((current_epoch - last_used_epoch) / 86400))
|
||||
|
||||
if [[ $days_ago -eq 0 ]]; then
|
||||
last_used="Today"
|
||||
elif [[ $days_ago -eq 1 ]]; then
|
||||
last_used="Yesterday"
|
||||
elif [[ $days_ago -lt 7 ]]; then
|
||||
last_used="${days_ago} days ago"
|
||||
elif [[ $days_ago -lt 30 ]]; then
|
||||
local weeks_ago=$((days_ago / 7))
|
||||
[[ $weeks_ago -eq 1 ]] && last_used="1 week ago" || last_used="${weeks_ago} weeks ago"
|
||||
elif [[ $days_ago -lt 365 ]]; then
|
||||
local months_ago=$((days_ago / 30))
|
||||
[[ $months_ago -eq 1 ]] && last_used="1 month ago" || last_used="${months_ago} months ago"
|
||||
else
|
||||
local years_ago=$((days_ago / 365))
|
||||
[[ $years_ago -eq 1 ]] && last_used="1 year ago" || last_used="${years_ago} years ago"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "${last_used_epoch}|${app_path}|${display_name}|${bundle_id}|${app_size}|${last_used}|${app_size_kb}" >> "$output_file"
|
||||
}
|
||||
|
||||
export -f process_app_metadata
|
||||
|
||||
local progress_file="${temp_file}.progress"
|
||||
echo "0" > "$progress_file"
|
||||
|
||||
(
|
||||
# shellcheck disable=SC2329 # Function invoked indirectly via trap
|
||||
cleanup_spinner() { exit 0; }
|
||||
trap cleanup_spinner TERM INT EXIT
|
||||
local spinner_chars="|/-\\"
|
||||
local i=0
|
||||
while true; do
|
||||
local completed=$(cat "$progress_file" 2> /dev/null || echo 0)
|
||||
local c="${spinner_chars:$((i % 4)):1}"
|
||||
if [[ $inline_loading == true ]]; then
|
||||
printf "\033[H\033[2K%s Scanning applications... %d/%d\n" "$c" "$completed" "$total_apps" >&2
|
||||
else
|
||||
printf "\r\033[K%s Scanning applications... %d/%d" "$c" "$completed" "$total_apps" >&2
|
||||
fi
|
||||
((i++))
|
||||
sleep 0.1 2> /dev/null || sleep 1
|
||||
done
|
||||
) &
|
||||
spinner_pid=$!
|
||||
|
||||
for app_data_tuple in "${app_data_tuples[@]}"; do
|
||||
((app_count++))
|
||||
process_app_metadata "$app_data_tuple" "$temp_file" "$current_epoch" &
|
||||
pids+=($!)
|
||||
echo "$app_count" > "$progress_file"
|
||||
|
||||
if ((${#pids[@]} >= max_parallel)); then
|
||||
wait "${pids[0]}" 2> /dev/null
|
||||
pids=("${pids[@]:1}")
|
||||
fi
|
||||
done
|
||||
|
||||
for pid in "${pids[@]}"; do
|
||||
wait "$pid" 2> /dev/null
|
||||
done
|
||||
|
||||
if [[ -n "$spinner_pid" ]]; then
|
||||
kill -TERM "$spinner_pid" 2> /dev/null || true
|
||||
wait "$spinner_pid" 2> /dev/null || true
|
||||
fi
|
||||
if [[ $inline_loading == true ]]; then
|
||||
printf "\033[H\033[2K" >&2
|
||||
else
|
||||
echo -ne "\r\033[K" >&2
|
||||
fi
|
||||
rm -f "$progress_file"
|
||||
|
||||
if [[ ! -s "$temp_file" ]]; then
|
||||
echo "No applications found to uninstall" >&2
|
||||
rm -f "$temp_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ $total_apps -gt 50 ]]; then
|
||||
if [[ $inline_loading == true ]]; then
|
||||
printf "\033[H\033[2KProcessing %d applications...\n" "$total_apps" >&2
|
||||
else
|
||||
printf "\rProcessing %d applications... " "$total_apps" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
sort -t'|' -k1,1n "$temp_file" > "${temp_file}.sorted" || {
|
||||
rm -f "$temp_file"
|
||||
return 1
|
||||
}
|
||||
rm -f "$temp_file"
|
||||
|
||||
if [[ $total_apps -gt 50 ]]; then
|
||||
if [[ $inline_loading == true ]]; then
|
||||
printf "\033[H\033[2K" >&2
|
||||
else
|
||||
printf "\r\033[K" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
ensure_user_file "$cache_file"
|
||||
cp "${temp_file}.sorted" "$cache_file" 2> /dev/null || true
|
||||
|
||||
if [[ -f "${temp_file}.sorted" ]]; then
|
||||
echo "${temp_file}.sorted"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
load_applications() {
|
||||
local apps_file="$1"
|
||||
|
||||
if [[ ! -f "$apps_file" || ! -s "$apps_file" ]]; then
|
||||
log_warning "No applications found for uninstallation"
|
||||
return 1
|
||||
fi
|
||||
|
||||
apps_data=()
|
||||
selection_state=()
|
||||
|
||||
while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do
|
||||
[[ ! -e "$app_path" ]] && continue
|
||||
|
||||
apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}")
|
||||
selection_state+=(false)
|
||||
done < "$apps_file"
|
||||
|
||||
if [[ ${#apps_data[@]} -eq 0 ]]; then
|
||||
log_warning "No applications available for uninstallation"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Cleanup: restore cursor and kill keepalive.
|
||||
cleanup() {
|
||||
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
|
||||
leave_alt_screen
|
||||
unset MOLE_ALT_SCREEN_ACTIVE
|
||||
fi
|
||||
if [[ -n "${sudo_keepalive_pid:-}" ]]; then
|
||||
kill "$sudo_keepalive_pid" 2> /dev/null || true
|
||||
wait "$sudo_keepalive_pid" 2> /dev/null || true
|
||||
sudo_keepalive_pid=""
|
||||
fi
|
||||
show_cursor
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
main() {
|
||||
local force_rescan=false
|
||||
# Global flags
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
"--debug")
|
||||
export MO_DEBUG=1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
local use_inline_loading=false
|
||||
if [[ -t 1 && -t 2 ]]; then
|
||||
use_inline_loading=true
|
||||
fi
|
||||
|
||||
hide_cursor
|
||||
|
||||
while true; do
|
||||
local needs_scanning=true
|
||||
local cache_file="$HOME/.cache/mole/app_scan_cache"
|
||||
if [[ $force_rescan == false && -f "$cache_file" ]]; then
|
||||
local cache_age=$(($(get_epoch_seconds) - $(get_file_mtime "$cache_file")))
|
||||
[[ $cache_age -eq $(get_epoch_seconds) ]] && cache_age=86401
|
||||
[[ $cache_age -lt 86400 ]] && needs_scanning=false
|
||||
fi
|
||||
|
||||
if [[ $needs_scanning == true && $use_inline_loading == true ]]; then
|
||||
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" != "1" ]]; then
|
||||
enter_alt_screen
|
||||
export MOLE_ALT_SCREEN_ACTIVE=1
|
||||
export MOLE_INLINE_LOADING=1
|
||||
export MOLE_MANAGED_ALT_SCREEN=1
|
||||
fi
|
||||
printf "\033[2J\033[H" >&2
|
||||
else
|
||||
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN MOLE_ALT_SCREEN_ACTIVE
|
||||
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
|
||||
leave_alt_screen
|
||||
unset MOLE_ALT_SCREEN_ACTIVE
|
||||
fi
|
||||
fi
|
||||
|
||||
local apps_file=""
|
||||
if ! apps_file=$(scan_applications "$force_rescan"); then
|
||||
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
|
||||
printf "\033[2J\033[H" >&2
|
||||
leave_alt_screen
|
||||
unset MOLE_ALT_SCREEN_ACTIVE
|
||||
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
|
||||
printf "\033[2J\033[H" >&2
|
||||
fi
|
||||
|
||||
if [[ ! -f "$apps_file" ]]; then
|
||||
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
|
||||
leave_alt_screen
|
||||
unset MOLE_ALT_SCREEN_ACTIVE
|
||||
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! load_applications "$apps_file"; then
|
||||
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
|
||||
leave_alt_screen
|
||||
unset MOLE_ALT_SCREEN_ACTIVE
|
||||
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN
|
||||
fi
|
||||
rm -f "$apps_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
set +e
|
||||
select_apps_for_uninstall
|
||||
local exit_code=$?
|
||||
set -e
|
||||
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
|
||||
leave_alt_screen
|
||||
unset MOLE_ALT_SCREEN_ACTIVE
|
||||
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN
|
||||
fi
|
||||
show_cursor
|
||||
clear_screen
|
||||
printf '\033[2J\033[H' >&2
|
||||
rm -f "$apps_file"
|
||||
|
||||
if [[ $exit_code -eq 10 ]]; then
|
||||
force_rescan=true
|
||||
continue
|
||||
fi
|
||||
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
|
||||
leave_alt_screen
|
||||
unset MOLE_ALT_SCREEN_ACTIVE
|
||||
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN
|
||||
fi
|
||||
|
||||
show_cursor
|
||||
clear_screen
|
||||
printf '\033[2J\033[H' >&2
|
||||
local selection_count=${#selected_apps[@]}
|
||||
if [[ $selection_count -eq 0 ]]; then
|
||||
echo "No apps selected"
|
||||
rm -f "$apps_file"
|
||||
continue
|
||||
fi
|
||||
echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} app(s):"
|
||||
local -a summary_rows=()
|
||||
local max_name_display_width=0
|
||||
local max_size_width=0
|
||||
local max_last_width=0
|
||||
for selected_app in "${selected_apps[@]}"; do
|
||||
IFS='|' read -r _ _ app_name _ size last_used _ <<< "$selected_app"
|
||||
local name_width=$(get_display_width "$app_name")
|
||||
[[ $name_width -gt $max_name_display_width ]] && max_name_display_width=$name_width
|
||||
local size_display="$size"
|
||||
[[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]] && size_display="Unknown"
|
||||
[[ ${#size_display} -gt $max_size_width ]] && max_size_width=${#size_display}
|
||||
local last_display=$(format_last_used_summary "$last_used")
|
||||
[[ ${#last_display} -gt $max_last_width ]] && max_last_width=${#last_display}
|
||||
done
|
||||
((max_size_width < 5)) && max_size_width=5
|
||||
((max_last_width < 5)) && max_last_width=5
|
||||
|
||||
local term_width=$(tput cols 2> /dev/null || echo 100)
|
||||
local available_for_name=$((term_width - 17 - max_size_width - max_last_width))
|
||||
|
||||
local min_name_width=24
|
||||
if [[ $term_width -ge 120 ]]; then
|
||||
min_name_width=50
|
||||
elif [[ $term_width -ge 100 ]]; then
|
||||
min_name_width=42
|
||||
elif [[ $term_width -ge 80 ]]; then
|
||||
min_name_width=30
|
||||
fi
|
||||
|
||||
local name_trunc_limit=$max_name_display_width
|
||||
[[ $name_trunc_limit -lt $min_name_width ]] && name_trunc_limit=$min_name_width
|
||||
[[ $name_trunc_limit -gt $available_for_name ]] && name_trunc_limit=$available_for_name
|
||||
[[ $name_trunc_limit -gt 60 ]] && name_trunc_limit=60
|
||||
|
||||
max_name_display_width=0
|
||||
|
||||
for selected_app in "${selected_apps[@]}"; do
|
||||
IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$selected_app"
|
||||
|
||||
local display_name
|
||||
display_name=$(truncate_by_display_width "$app_name" "$name_trunc_limit")
|
||||
|
||||
local current_width
|
||||
current_width=$(get_display_width "$display_name")
|
||||
[[ $current_width -gt $max_name_display_width ]] && max_name_display_width=$current_width
|
||||
|
||||
local size_display="$size"
|
||||
if [[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]]; then
|
||||
size_display="Unknown"
|
||||
fi
|
||||
|
||||
local last_display
|
||||
last_display=$(format_last_used_summary "$last_used")
|
||||
|
||||
summary_rows+=("$display_name|$size_display|$last_display")
|
||||
done
|
||||
|
||||
((max_name_display_width < 16)) && max_name_display_width=16
|
||||
|
||||
local index=1
|
||||
for row in "${summary_rows[@]}"; do
|
||||
IFS='|' read -r name_cell size_cell last_cell <<< "$row"
|
||||
local name_display_width
|
||||
name_display_width=$(get_display_width "$name_cell")
|
||||
local name_char_count=${#name_cell}
|
||||
local padding_needed=$((max_name_display_width - name_display_width))
|
||||
local printf_name_width=$((name_char_count + padding_needed))
|
||||
|
||||
printf "%d. %-*s %*s | Last: %s\n" "$index" "$printf_name_width" "$name_cell" "$max_size_width" "$size_cell" "$last_cell"
|
||||
((index++))
|
||||
done
|
||||
|
||||
batch_uninstall_applications
|
||||
|
||||
rm -f "$apps_file"
|
||||
|
||||
echo -e "${GRAY}Press Enter to return to application list, any other key to exit...${NC}"
|
||||
local key
|
||||
IFS= read -r -s -n1 key || key=""
|
||||
drain_pending_input
|
||||
|
||||
if [[ -z "$key" ]]; then
|
||||
:
|
||||
else
|
||||
show_cursor
|
||||
return 0
|
||||
fi
|
||||
|
||||
force_rescan=false
|
||||
done
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,666 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Mole - Uninstall Module
|
||||
# Interactive application uninstaller with keyboard navigation
|
||||
#
|
||||
# Usage:
|
||||
# uninstall.sh # Launch interactive uninstaller
|
||||
# uninstall.sh --force-rescan # Rescan apps and refresh cache
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Fix locale issues (avoid Perl warnings on non-English systems)
|
||||
export LC_ALL=C
|
||||
export LANG=C
|
||||
|
||||
# Get script directory and source common functions
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../lib/core/common.sh"
|
||||
source "$SCRIPT_DIR/../lib/ui/menu_paginated.sh"
|
||||
source "$SCRIPT_DIR/../lib/ui/app_selector.sh"
|
||||
source "$SCRIPT_DIR/../lib/uninstall/batch.sh"
|
||||
|
||||
# Note: Bundle preservation logic is now in lib/core/common.sh
|
||||
|
||||
# Initialize global variables
|
||||
selected_apps=() # Global array for app selection
|
||||
declare -a apps_data=()
|
||||
declare -a selection_state=()
|
||||
total_items=0
|
||||
files_cleaned=0
|
||||
total_size_cleaned=0
|
||||
|
||||
# Compact the "last used" descriptor for aligned summaries
|
||||
format_last_used_summary() {
|
||||
local value="$1"
|
||||
|
||||
case "$value" in
|
||||
"" | "Unknown")
|
||||
echo "Unknown"
|
||||
return 0
|
||||
;;
|
||||
"Never" | "Recent" | "Today" | "Yesterday" | "This year" | "Old")
|
||||
echo "$value"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ $value =~ ^([0-9]+)[[:space:]]+days?\ ago$ ]]; then
|
||||
echo "${BASH_REMATCH[1]}d ago"
|
||||
return 0
|
||||
fi
|
||||
if [[ $value =~ ^([0-9]+)[[:space:]]+weeks?\ ago$ ]]; then
|
||||
echo "${BASH_REMATCH[1]}w ago"
|
||||
return 0
|
||||
fi
|
||||
if [[ $value =~ ^([0-9]+)[[:space:]]+months?\ ago$ ]]; then
|
||||
echo "${BASH_REMATCH[1]}m ago"
|
||||
return 0
|
||||
fi
|
||||
if [[ $value =~ ^([0-9]+)[[:space:]]+month\(s\)\ ago$ ]]; then
|
||||
echo "${BASH_REMATCH[1]}m ago"
|
||||
return 0
|
||||
fi
|
||||
if [[ $value =~ ^([0-9]+)[[:space:]]+years?\ ago$ ]]; then
|
||||
echo "${BASH_REMATCH[1]}y ago"
|
||||
return 0
|
||||
fi
|
||||
echo "$value"
|
||||
}
|
||||
|
||||
# Scan applications and collect information
|
||||
scan_applications() {
|
||||
# Simplified cache: only check timestamp (24h TTL)
|
||||
local cache_dir="$HOME/.cache/mole"
|
||||
local cache_file="$cache_dir/app_scan_cache"
|
||||
local cache_ttl=86400 # 24 hours
|
||||
local force_rescan="${1:-false}"
|
||||
|
||||
ensure_user_dir "$cache_dir"
|
||||
|
||||
# Check if cache exists and is fresh
|
||||
if [[ $force_rescan == false && -f "$cache_file" ]]; then
|
||||
local cache_age=$(($(get_epoch_seconds) - $(get_file_mtime "$cache_file")))
|
||||
[[ $cache_age -eq $(get_epoch_seconds) ]] && cache_age=86401 # Handle missing file
|
||||
if [[ $cache_age -lt $cache_ttl ]]; then
|
||||
# Cache hit - return immediately
|
||||
# Show brief flash of cache usage if in interactive mode
|
||||
if [[ -t 2 ]]; then
|
||||
echo -e "${GREEN}Loading from cache...${NC}" >&2
|
||||
# Small sleep to let user see it (optional, but good for "feeling" the speed vs glitch)
|
||||
sleep 0.3
|
||||
fi
|
||||
echo "$cache_file"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Cache miss - prepare for scanning
|
||||
local inline_loading=false
|
||||
if [[ -t 1 && -t 2 ]]; then
|
||||
inline_loading=true
|
||||
# Clear screen for inline loading
|
||||
printf "\033[2J\033[H" >&2
|
||||
fi
|
||||
|
||||
local temp_file
|
||||
temp_file=$(create_temp_file)
|
||||
|
||||
# Pre-cache current epoch to avoid repeated calls
|
||||
local current_epoch
|
||||
current_epoch=$(get_epoch_seconds)
|
||||
|
||||
# First pass: quickly collect all valid app paths and bundle IDs (NO mdls calls)
|
||||
local -a app_data_tuples=()
|
||||
local -a app_dirs=(
|
||||
"/Applications"
|
||||
"$HOME/Applications"
|
||||
)
|
||||
local vol_app_dir
|
||||
local nullglob_was_set=0
|
||||
shopt -q nullglob && nullglob_was_set=1
|
||||
shopt -s nullglob
|
||||
for vol_app_dir in /Volumes/*/Applications; do
|
||||
[[ -d "$vol_app_dir" && -r "$vol_app_dir" ]] || continue
|
||||
if [[ -d "/Applications" && "$vol_app_dir" -ef "/Applications" ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ -d "$HOME/Applications" && "$vol_app_dir" -ef "$HOME/Applications" ]]; then
|
||||
continue
|
||||
fi
|
||||
app_dirs+=("$vol_app_dir")
|
||||
done
|
||||
if [[ $nullglob_was_set -eq 0 ]]; then
|
||||
shopt -u nullglob
|
||||
fi
|
||||
|
||||
for app_dir in "${app_dirs[@]}"; do
|
||||
if [[ ! -d "$app_dir" ]]; then continue; fi
|
||||
|
||||
while IFS= read -r -d '' app_path; do
|
||||
if [[ ! -e "$app_path" ]]; then continue; fi
|
||||
|
||||
local app_name
|
||||
app_name=$(basename "$app_path" .app)
|
||||
|
||||
# Skip nested apps (e.g. inside Wrapper/ or Frameworks/ of another app)
|
||||
# Check if parent path component ends in .app (e.g. /Foo.app/Bar.app or /Foo.app/Contents/Bar.app)
|
||||
# This prevents false positives like /Old.apps/Target.app
|
||||
local parent_dir
|
||||
parent_dir=$(dirname "$app_path")
|
||||
if [[ "$parent_dir" == *".app" || "$parent_dir" == *".app/"* ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Get bundle ID only (fast, no mdls calls in first pass)
|
||||
local bundle_id="unknown"
|
||||
if [[ -f "$app_path/Contents/Info.plist" ]]; then
|
||||
bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown")
|
||||
fi
|
||||
|
||||
# Skip system critical apps (input methods, system components)
|
||||
if should_protect_from_uninstall "$bundle_id"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Store tuple: app_path|app_name|bundle_id (display_name will be resolved in parallel later)
|
||||
app_data_tuples+=("${app_path}|${app_name}|${bundle_id}")
|
||||
done < <(command find "$app_dir" -name "*.app" -maxdepth 3 -print0 2> /dev/null)
|
||||
done
|
||||
|
||||
# Second pass: process each app with parallel size calculation
|
||||
local app_count=0
|
||||
local total_apps=${#app_data_tuples[@]}
|
||||
# Bound parallelism - for metadata queries, can go higher since it's mostly waiting
|
||||
local max_parallel
|
||||
max_parallel=$(get_optimal_parallel_jobs "io")
|
||||
if [[ $max_parallel -lt 8 ]]; then
|
||||
max_parallel=8
|
||||
elif [[ $max_parallel -gt 32 ]]; then
|
||||
max_parallel=32
|
||||
fi
|
||||
local pids=()
|
||||
# inline_loading variable already set above (line ~92)
|
||||
|
||||
# Process app metadata extraction function
|
||||
process_app_metadata() {
|
||||
local app_data_tuple="$1"
|
||||
local output_file="$2"
|
||||
local current_epoch="$3"
|
||||
|
||||
IFS='|' read -r app_path app_name bundle_id <<< "$app_data_tuple"
|
||||
|
||||
# Get localized display name (moved from first pass for better performance)
|
||||
local display_name="$app_name"
|
||||
if [[ -f "$app_path/Contents/Info.plist" ]]; then
|
||||
# Try to get localized name from system metadata (best for i18n)
|
||||
local md_display_name
|
||||
md_display_name=$(run_with_timeout 0.05 mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "")
|
||||
|
||||
# Get bundle names
|
||||
local bundle_display_name
|
||||
bundle_display_name=$(plutil -extract CFBundleDisplayName raw "$app_path/Contents/Info.plist" 2> /dev/null)
|
||||
local bundle_name
|
||||
bundle_name=$(plutil -extract CFBundleName raw "$app_path/Contents/Info.plist" 2> /dev/null)
|
||||
|
||||
# Priority order for name selection (prefer localized names):
|
||||
# 1. System metadata display name (kMDItemDisplayName) - respects system language
|
||||
# 2. CFBundleDisplayName - usually localized
|
||||
# 3. CFBundleName - fallback
|
||||
# 4. App folder name - last resort
|
||||
|
||||
if [[ -n "$md_display_name" && "$md_display_name" != "(null)" && "$md_display_name" != "$app_name" ]]; then
|
||||
display_name="$md_display_name"
|
||||
elif [[ -n "$bundle_display_name" && "$bundle_display_name" != "(null)" ]]; then
|
||||
display_name="$bundle_display_name"
|
||||
elif [[ -n "$bundle_name" && "$bundle_name" != "(null)" ]]; then
|
||||
display_name="$bundle_name"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Parallel size calculation
|
||||
local app_size="N/A"
|
||||
local app_size_kb="0"
|
||||
if [[ -d "$app_path" ]]; then
|
||||
# Get size in KB, then format for display
|
||||
app_size_kb=$(get_path_size_kb "$app_path")
|
||||
app_size=$(bytes_to_human "$((app_size_kb * 1024))")
|
||||
fi
|
||||
|
||||
# Get last used date
|
||||
local last_used="Never"
|
||||
local last_used_epoch=0
|
||||
|
||||
if [[ -d "$app_path" ]]; then
|
||||
# Try mdls first with short timeout (0.1s) for accuracy, fallback to mtime for speed
|
||||
local metadata_date
|
||||
metadata_date=$(run_with_timeout 0.1 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "")
|
||||
|
||||
if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then
|
||||
last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0")
|
||||
fi
|
||||
|
||||
# Fallback if mdls failed or returned nothing
|
||||
if [[ "$last_used_epoch" -eq 0 ]]; then
|
||||
last_used_epoch=$(get_file_mtime "$app_path")
|
||||
fi
|
||||
|
||||
if [[ $last_used_epoch -gt 0 ]]; then
|
||||
local days_ago=$(((current_epoch - last_used_epoch) / 86400))
|
||||
|
||||
if [[ $days_ago -eq 0 ]]; then
|
||||
last_used="Today"
|
||||
elif [[ $days_ago -eq 1 ]]; then
|
||||
last_used="Yesterday"
|
||||
elif [[ $days_ago -lt 7 ]]; then
|
||||
last_used="${days_ago} days ago"
|
||||
elif [[ $days_ago -lt 30 ]]; then
|
||||
local weeks_ago=$((days_ago / 7))
|
||||
[[ $weeks_ago -eq 1 ]] && last_used="1 week ago" || last_used="${weeks_ago} weeks ago"
|
||||
elif [[ $days_ago -lt 365 ]]; then
|
||||
local months_ago=$((days_ago / 30))
|
||||
[[ $months_ago -eq 1 ]] && last_used="1 month ago" || last_used="${months_ago} months ago"
|
||||
else
|
||||
local years_ago=$((days_ago / 365))
|
||||
[[ $years_ago -eq 1 ]] && last_used="1 year ago" || last_used="${years_ago} years ago"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Write to output file atomically
|
||||
# Fields: epoch|app_path|display_name|bundle_id|size_human|last_used|size_kb
|
||||
echo "${last_used_epoch}|${app_path}|${display_name}|${bundle_id}|${app_size}|${last_used}|${app_size_kb}" >> "$output_file"
|
||||
}
|
||||
|
||||
export -f process_app_metadata
|
||||
|
||||
# Create a temporary file to track progress
|
||||
local progress_file="${temp_file}.progress"
|
||||
echo "0" > "$progress_file"
|
||||
|
||||
# Start a background spinner that reads progress from file
|
||||
local spinner_pid=""
|
||||
(
|
||||
# shellcheck disable=SC2329 # Function invoked indirectly via trap
|
||||
cleanup_spinner() { exit 0; }
|
||||
trap cleanup_spinner TERM INT EXIT
|
||||
local spinner_chars="|/-\\"
|
||||
local i=0
|
||||
while true; do
|
||||
local completed=$(cat "$progress_file" 2> /dev/null || echo 0)
|
||||
local c="${spinner_chars:$((i % 4)):1}"
|
||||
if [[ $inline_loading == true ]]; then
|
||||
printf "\033[H\033[2K%s Scanning applications... %d/%d\n" "$c" "$completed" "$total_apps" >&2
|
||||
else
|
||||
printf "\r\033[K%s Scanning applications... %d/%d" "$c" "$completed" "$total_apps" >&2
|
||||
fi
|
||||
((i++))
|
||||
sleep 0.1 2> /dev/null || sleep 1
|
||||
done
|
||||
) &
|
||||
spinner_pid=$!
|
||||
|
||||
# Process apps in parallel batches
|
||||
for app_data_tuple in "${app_data_tuples[@]}"; do
|
||||
((app_count++))
|
||||
|
||||
# Launch background process
|
||||
process_app_metadata "$app_data_tuple" "$temp_file" "$current_epoch" &
|
||||
pids+=($!)
|
||||
|
||||
# Update progress to show scanning progress (use app_count as it increments smoothly)
|
||||
echo "$app_count" > "$progress_file"
|
||||
|
||||
# Wait if we've hit max parallel limit
|
||||
if ((${#pids[@]} >= max_parallel)); then
|
||||
wait "${pids[0]}" 2> /dev/null
|
||||
pids=("${pids[@]:1}") # Remove first pid
|
||||
fi
|
||||
done
|
||||
|
||||
# Wait for remaining background processes
|
||||
for pid in "${pids[@]}"; do
|
||||
wait "$pid" 2> /dev/null
|
||||
done
|
||||
|
||||
# Stop the spinner and clear the line
|
||||
if [[ -n "$spinner_pid" ]]; then
|
||||
kill -TERM "$spinner_pid" 2> /dev/null || true
|
||||
wait "$spinner_pid" 2> /dev/null || true
|
||||
fi
|
||||
if [[ $inline_loading == true ]]; then
|
||||
printf "\033[H\033[2K" >&2
|
||||
else
|
||||
echo -ne "\r\033[K" >&2
|
||||
fi
|
||||
rm -f "$progress_file"
|
||||
|
||||
# Check if we found any applications
|
||||
if [[ ! -s "$temp_file" ]]; then
|
||||
echo "No applications found to uninstall" >&2
|
||||
rm -f "$temp_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Sort by last used (oldest first) and cache the result
|
||||
# Show brief processing message for large app lists
|
||||
if [[ $total_apps -gt 50 ]]; then
|
||||
if [[ $inline_loading == true ]]; then
|
||||
printf "\033[H\033[2KProcessing %d applications...\n" "$total_apps" >&2
|
||||
else
|
||||
printf "\rProcessing %d applications... " "$total_apps" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
sort -t'|' -k1,1n "$temp_file" > "${temp_file}.sorted" || {
|
||||
rm -f "$temp_file"
|
||||
return 1
|
||||
}
|
||||
rm -f "$temp_file"
|
||||
|
||||
# Clear processing message
|
||||
if [[ $total_apps -gt 50 ]]; then
|
||||
if [[ $inline_loading == true ]]; then
|
||||
printf "\033[H\033[2K" >&2
|
||||
else
|
||||
printf "\r\033[K" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# Save to cache (simplified - no metadata)
|
||||
ensure_user_file "$cache_file"
|
||||
cp "${temp_file}.sorted" "$cache_file" 2> /dev/null || true
|
||||
|
||||
# Return sorted file
|
||||
if [[ -f "${temp_file}.sorted" ]]; then
|
||||
echo "${temp_file}.sorted"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
load_applications() {
|
||||
local apps_file="$1"
|
||||
|
||||
if [[ ! -f "$apps_file" || ! -s "$apps_file" ]]; then
|
||||
log_warning "No applications found for uninstallation"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Clear arrays
|
||||
apps_data=()
|
||||
selection_state=()
|
||||
|
||||
# Read apps into array, skip non-existent apps
|
||||
while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do
|
||||
# Skip if app path no longer exists
|
||||
[[ ! -e "$app_path" ]] && continue
|
||||
|
||||
apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}")
|
||||
selection_state+=(false)
|
||||
done < "$apps_file"
|
||||
|
||||
if [[ ${#apps_data[@]} -eq 0 ]]; then
|
||||
log_warning "No applications available for uninstallation"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Cleanup function - restore cursor and clean up
|
||||
cleanup() {
|
||||
# Restore cursor using common function
|
||||
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
|
||||
leave_alt_screen
|
||||
unset MOLE_ALT_SCREEN_ACTIVE
|
||||
fi
|
||||
if [[ -n "${sudo_keepalive_pid:-}" ]]; then
|
||||
kill "$sudo_keepalive_pid" 2> /dev/null || true
|
||||
wait "$sudo_keepalive_pid" 2> /dev/null || true
|
||||
sudo_keepalive_pid=""
|
||||
fi
|
||||
show_cursor
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
# Set trap for cleanup on exit
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
main() {
|
||||
local force_rescan=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
"--debug")
|
||||
export MO_DEBUG=1
|
||||
;;
|
||||
"--force-rescan")
|
||||
force_rescan=true
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
local use_inline_loading=false
|
||||
if [[ -t 1 && -t 2 ]]; then
|
||||
use_inline_loading=true
|
||||
fi
|
||||
|
||||
# Hide cursor during operation
|
||||
hide_cursor
|
||||
|
||||
# Main interaction loop
|
||||
while true; do
|
||||
# Simplified: always check if we need alt screen for scanning
|
||||
# (scan_applications handles cache internally)
|
||||
local needs_scanning=true
|
||||
local cache_file="$HOME/.cache/mole/app_scan_cache"
|
||||
if [[ $force_rescan == false && -f "$cache_file" ]]; then
|
||||
local cache_age=$(($(get_epoch_seconds) - $(get_file_mtime "$cache_file")))
|
||||
[[ $cache_age -eq $(get_epoch_seconds) ]] && cache_age=86401 # Handle missing file
|
||||
[[ $cache_age -lt 86400 ]] && needs_scanning=false
|
||||
fi
|
||||
|
||||
# Only enter alt screen if we need scanning (shows progress)
|
||||
if [[ $needs_scanning == true && $use_inline_loading == true ]]; then
|
||||
# Only enter if not already active
|
||||
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" != "1" ]]; then
|
||||
enter_alt_screen
|
||||
export MOLE_ALT_SCREEN_ACTIVE=1
|
||||
export MOLE_INLINE_LOADING=1
|
||||
export MOLE_MANAGED_ALT_SCREEN=1
|
||||
fi
|
||||
printf "\033[2J\033[H" >&2
|
||||
else
|
||||
# If we don't need scanning but have alt screen from previous iteration, keep it?
|
||||
# Actually, scan_applications might output to stderr.
|
||||
# Let's just unset the flags if we don't need scanning, but keep alt screen if it was active?
|
||||
# No, select_apps_for_uninstall will handle its own screen management.
|
||||
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN MOLE_ALT_SCREEN_ACTIVE
|
||||
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
|
||||
leave_alt_screen
|
||||
unset MOLE_ALT_SCREEN_ACTIVE
|
||||
fi
|
||||
fi
|
||||
|
||||
# Scan applications
|
||||
local apps_file=""
|
||||
if ! apps_file=$(scan_applications "$force_rescan"); then
|
||||
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
|
||||
printf "\033[2J\033[H" >&2
|
||||
leave_alt_screen
|
||||
unset MOLE_ALT_SCREEN_ACTIVE
|
||||
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
|
||||
printf "\033[2J\033[H" >&2
|
||||
fi
|
||||
|
||||
if [[ ! -f "$apps_file" ]]; then
|
||||
# Error message already shown by scan_applications
|
||||
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
|
||||
leave_alt_screen
|
||||
unset MOLE_ALT_SCREEN_ACTIVE
|
||||
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Load applications
|
||||
if ! load_applications "$apps_file"; then
|
||||
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
|
||||
leave_alt_screen
|
||||
unset MOLE_ALT_SCREEN_ACTIVE
|
||||
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN
|
||||
fi
|
||||
rm -f "$apps_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Interactive selection using paginated menu
|
||||
set +e
|
||||
select_apps_for_uninstall
|
||||
local exit_code=$?
|
||||
set -e
|
||||
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
|
||||
leave_alt_screen
|
||||
unset MOLE_ALT_SCREEN_ACTIVE
|
||||
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN
|
||||
fi
|
||||
show_cursor
|
||||
clear_screen
|
||||
printf '\033[2J\033[H' >&2 # Also clear stderr
|
||||
rm -f "$apps_file"
|
||||
|
||||
# Handle Refresh (code 10)
|
||||
if [[ $exit_code -eq 10 ]]; then
|
||||
force_rescan=true
|
||||
continue
|
||||
fi
|
||||
|
||||
# User cancelled selection, exit the loop
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Always clear on exit from selection, regardless of alt screen state
|
||||
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
|
||||
leave_alt_screen
|
||||
unset MOLE_ALT_SCREEN_ACTIVE
|
||||
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN
|
||||
fi
|
||||
|
||||
# Restore cursor and clear screen (output to both stdout and stderr for reliability)
|
||||
show_cursor
|
||||
clear_screen
|
||||
printf '\033[2J\033[H' >&2 # Also clear stderr in case of mixed output
|
||||
local selection_count=${#selected_apps[@]}
|
||||
if [[ $selection_count -eq 0 ]]; then
|
||||
echo "No apps selected"
|
||||
rm -f "$apps_file"
|
||||
# Loop back or exit? If select_apps_for_uninstall returns 0 but empty selection,
|
||||
# it technically shouldn't happen based on that function's logic.
|
||||
continue
|
||||
fi
|
||||
# Show selected apps with clean alignment
|
||||
echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} app(s):"
|
||||
local -a summary_rows=()
|
||||
local max_name_width=0
|
||||
local max_size_width=0
|
||||
local max_last_width=0
|
||||
# First pass: get actual max widths for all columns
|
||||
for selected_app in "${selected_apps[@]}"; do
|
||||
IFS='|' read -r _ _ app_name _ size last_used _ <<< "$selected_app"
|
||||
[[ ${#app_name} -gt $max_name_width ]] && max_name_width=${#app_name}
|
||||
local size_display="$size"
|
||||
[[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]] && size_display="Unknown"
|
||||
[[ ${#size_display} -gt $max_size_width ]] && max_size_width=${#size_display}
|
||||
local last_display=$(format_last_used_summary "$last_used")
|
||||
[[ ${#last_display} -gt $max_last_width ]] && max_last_width=${#last_display}
|
||||
done
|
||||
((max_size_width < 5)) && max_size_width=5
|
||||
((max_last_width < 5)) && max_last_width=5
|
||||
|
||||
# Calculate name width: use actual max, but constrain by terminal width
|
||||
# Fixed elements: "99. " (4) + " " (2) + " | Last: " (11) = 17
|
||||
local term_width=$(tput cols 2> /dev/null || echo 100)
|
||||
local available_for_name=$((term_width - 17 - max_size_width - max_last_width))
|
||||
|
||||
# Dynamic minimum for better spacing on wide terminals
|
||||
local min_name_width=24
|
||||
if [[ $term_width -ge 120 ]]; then
|
||||
min_name_width=50
|
||||
elif [[ $term_width -ge 100 ]]; then
|
||||
min_name_width=42
|
||||
elif [[ $term_width -ge 80 ]]; then
|
||||
min_name_width=30
|
||||
fi
|
||||
|
||||
# Constrain name width: dynamic min, max min(actual_max, available, 60)
|
||||
local name_trunc_limit=$max_name_width
|
||||
[[ $name_trunc_limit -lt $min_name_width ]] && name_trunc_limit=$min_name_width
|
||||
[[ $name_trunc_limit -gt $available_for_name ]] && name_trunc_limit=$available_for_name
|
||||
[[ $name_trunc_limit -gt 60 ]] && name_trunc_limit=60
|
||||
|
||||
# Reset for second pass
|
||||
max_name_width=0
|
||||
|
||||
for selected_app in "${selected_apps[@]}"; do
|
||||
IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$selected_app"
|
||||
|
||||
local display_name="$app_name"
|
||||
if [[ ${#display_name} -gt $name_trunc_limit ]]; then
|
||||
display_name="${display_name:0:$((name_trunc_limit - 3))}..."
|
||||
fi
|
||||
[[ ${#display_name} -gt $max_name_width ]] && max_name_width=${#display_name}
|
||||
|
||||
local size_display="$size"
|
||||
if [[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]]; then
|
||||
size_display="Unknown"
|
||||
fi
|
||||
|
||||
local last_display
|
||||
last_display=$(format_last_used_summary "$last_used")
|
||||
|
||||
summary_rows+=("$display_name|$size_display|$last_display")
|
||||
done
|
||||
|
||||
((max_name_width < 16)) && max_name_width=16
|
||||
|
||||
local index=1
|
||||
for row in "${summary_rows[@]}"; do
|
||||
IFS='|' read -r name_cell size_cell last_cell <<< "$row"
|
||||
printf "%d. %-*s %*s | Last: %s\n" "$index" "$max_name_width" "$name_cell" "$max_size_width" "$size_cell" "$last_cell"
|
||||
((index++))
|
||||
done
|
||||
|
||||
# Execute batch uninstallation (handles confirmation)
|
||||
batch_uninstall_applications
|
||||
|
||||
# Cleanup current apps file
|
||||
rm -f "$apps_file"
|
||||
|
||||
# Pause before looping back
|
||||
echo -e "${GRAY}Press Enter to return to application list, ESC to exit...${NC}"
|
||||
local key
|
||||
IFS= read -r -s -n1 key || key=""
|
||||
drain_pending_input # Clean up any escape sequence remnants
|
||||
case "$key" in
|
||||
$'\e' | q | Q)
|
||||
show_cursor
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
# Continue loop
|
||||
;;
|
||||
esac
|
||||
|
||||
# Reset force_rescan to false for subsequent loops,
|
||||
# but relying on batch_uninstall's cache deletion for actual update
|
||||
force_rescan=false
|
||||
done
|
||||
}
|
||||
|
||||
# Run main function
|
||||
Reference in New Issue
Block a user