mirror of
https://github.com/tw93/Mole.git
synced 2026-02-07 22:04:21 +00:00
chore: restructure windows branch (move windows/ content to root, remove macos files)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
396
lib/core/base.ps1
Normal file
396
lib/core/base.ps1
Normal file
@@ -0,0 +1,396 @@
|
||||
# Mole - Base Definitions and Utilities
|
||||
# Core definitions, constants, and basic utility functions used by all modules
|
||||
|
||||
#Requires -Version 5.1
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Prevent multiple sourcing
|
||||
if ((Get-Variable -Name 'MOLE_BASE_LOADED' -Scope Script -ErrorAction SilentlyContinue) -and $script:MOLE_BASE_LOADED) { return }
|
||||
$script:MOLE_BASE_LOADED = $true
|
||||
|
||||
# ============================================================================
|
||||
# Color Definitions (ANSI escape codes for modern terminals)
|
||||
# ============================================================================
|
||||
$script:ESC = [char]27
|
||||
$script:Colors = @{
|
||||
Green = "$ESC[0;32m"
|
||||
Blue = "$ESC[0;34m"
|
||||
Cyan = "$ESC[0;36m"
|
||||
Yellow = "$ESC[0;33m"
|
||||
Purple = "$ESC[0;35m"
|
||||
PurpleBold = "$ESC[1;35m"
|
||||
Red = "$ESC[0;31m"
|
||||
Gray = "$ESC[0;90m"
|
||||
White = "$ESC[0;37m"
|
||||
NC = "$ESC[0m" # No Color / Reset
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Icon Definitions
|
||||
# ============================================================================
|
||||
$script:Icons = @{
|
||||
Confirm = [char]0x25CE # ◎
|
||||
Admin = [char]0x2699 # ⚙
|
||||
Success = [char]0x2713 # ✓
|
||||
Error = [char]0x263B # ☻
|
||||
Warning = [char]0x25CF # ●
|
||||
Empty = [char]0x25CB # ○
|
||||
Solid = [char]0x25CF # ●
|
||||
List = [char]0x2022 # •
|
||||
Arrow = [char]0x27A4 # ➤
|
||||
DryRun = [char]0x2192 # →
|
||||
NavUp = [char]0x2191 # ↑
|
||||
NavDown = [char]0x2193 # ↓
|
||||
Folder = [char]0x25A0 # ■ (folder substitute)
|
||||
File = [char]0x25A1 # □ (file substitute)
|
||||
Trash = [char]0x2718 # ✘ (trash substitute)
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Global Configuration Constants
|
||||
# ============================================================================
|
||||
$script:Config = @{
|
||||
TempFileAgeDays = 7 # Temp file retention (days)
|
||||
OrphanAgeDays = 60 # Orphaned data retention (days)
|
||||
MaxParallelJobs = 15 # Parallel job limit
|
||||
LogAgeDays = 7 # Log retention (days)
|
||||
CrashReportAgeDays = 7 # Crash report retention (days)
|
||||
MaxIterations = 100 # Max iterations for scans
|
||||
ConfigPath = "$env:USERPROFILE\.config\mole"
|
||||
CachePath = "$env:USERPROFILE\.cache\mole"
|
||||
WhitelistFile = "$env:USERPROFILE\.config\mole\whitelist.txt"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Default Whitelist Patterns (paths to never clean)
|
||||
# ============================================================================
|
||||
$script:DefaultWhitelistPatterns = @(
|
||||
"$env:LOCALAPPDATA\Microsoft\Windows\Explorer" # Windows Explorer cache
|
||||
"$env:LOCALAPPDATA\Microsoft\Windows\Fonts" # User fonts
|
||||
"$env:APPDATA\Microsoft\Windows\Recent" # Recent files (used by shell)
|
||||
"$env:LOCALAPPDATA\Packages\*" # UWP app data
|
||||
"$env:USERPROFILE\.vscode\extensions" # VS Code extensions
|
||||
"$env:USERPROFILE\.nuget" # NuGet packages
|
||||
"$env:USERPROFILE\.cargo" # Rust packages
|
||||
"$env:USERPROFILE\.rustup" # Rust toolchain
|
||||
"$env:USERPROFILE\.m2\repository" # Maven repository
|
||||
"$env:USERPROFILE\.gradle\caches\modules-2\files-*" # Gradle modules
|
||||
"$env:USERPROFILE\.ollama\models" # Ollama AI models
|
||||
"$env:LOCALAPPDATA\JetBrains" # JetBrains IDEs
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# Protected System Paths (NEVER touch these)
|
||||
# ============================================================================
|
||||
$script:ProtectedPaths = @(
|
||||
"C:\Windows"
|
||||
"C:\Windows\System32"
|
||||
"C:\Windows\SysWOW64"
|
||||
"C:\Program Files"
|
||||
"C:\Program Files (x86)"
|
||||
"C:\Program Files\Windows Defender"
|
||||
"C:\Program Files (x86)\Windows Defender"
|
||||
"C:\ProgramData\Microsoft\Windows Defender"
|
||||
"$env:SYSTEMROOT"
|
||||
"$env:WINDIR"
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# System Utilities
|
||||
# ============================================================================
|
||||
|
||||
function Test-IsAdmin {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Check if running with administrator privileges
|
||||
#>
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
||||
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
function Get-FreeSpace {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get free disk space on system drive
|
||||
.OUTPUTS
|
||||
Human-readable string (e.g., "100GB")
|
||||
#>
|
||||
param([string]$Drive = $env:SystemDrive)
|
||||
|
||||
$disk = Get-WmiObject Win32_LogicalDisk -Filter "DeviceID='$Drive'" -ErrorAction SilentlyContinue
|
||||
if ($disk) {
|
||||
return Format-ByteSize -Bytes $disk.FreeSpace
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
function Get-WindowsVersion {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get Windows version information
|
||||
#>
|
||||
$os = Get-WmiObject Win32_OperatingSystem
|
||||
return @{
|
||||
Name = $os.Caption
|
||||
Version = $os.Version
|
||||
Build = $os.BuildNumber
|
||||
Arch = $os.OSArchitecture
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CPUCores {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get number of CPU cores
|
||||
#>
|
||||
return (Get-WmiObject Win32_Processor).NumberOfLogicalProcessors
|
||||
}
|
||||
|
||||
function Get-OptimalParallelJobs {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get optimal number of parallel jobs based on CPU cores
|
||||
#>
|
||||
param(
|
||||
[ValidateSet('scan', 'io', 'compute', 'default')]
|
||||
[string]$OperationType = 'default'
|
||||
)
|
||||
|
||||
$cores = Get-CPUCores
|
||||
switch ($OperationType) {
|
||||
'scan' { return [Math]::Min($cores * 2, 32) }
|
||||
'io' { return [Math]::Min($cores * 2, 32) }
|
||||
'compute' { return $cores }
|
||||
default { return [Math]::Min($cores + 2, 20) }
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Path Utilities
|
||||
# ============================================================================
|
||||
|
||||
function Test-ProtectedPath {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Check if a path is protected and should never be modified
|
||||
#>
|
||||
param([string]$Path)
|
||||
|
||||
$normalizedPath = [System.IO.Path]::GetFullPath($Path).TrimEnd('\')
|
||||
|
||||
foreach ($protected in $script:ProtectedPaths) {
|
||||
$normalizedProtected = [System.IO.Path]::GetFullPath($protected).TrimEnd('\')
|
||||
if ($normalizedPath -eq $normalizedProtected -or
|
||||
$normalizedPath.StartsWith("$normalizedProtected\", [StringComparison]::OrdinalIgnoreCase)) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-Whitelisted {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Check if path matches a whitelist pattern
|
||||
#>
|
||||
param([string]$Path)
|
||||
|
||||
# Check default patterns
|
||||
foreach ($pattern in $script:DefaultWhitelistPatterns) {
|
||||
$expandedPattern = [Environment]::ExpandEnvironmentVariables($pattern)
|
||||
if ($Path -like $expandedPattern) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
|
||||
# Check user whitelist file
|
||||
if (Test-Path $script:Config.WhitelistFile) {
|
||||
$userPatterns = Get-Content $script:Config.WhitelistFile -ErrorAction SilentlyContinue
|
||||
foreach ($pattern in $userPatterns) {
|
||||
$pattern = $pattern.Trim()
|
||||
if ($pattern -and -not $pattern.StartsWith('#')) {
|
||||
if ($Path -like $pattern) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
function Resolve-SafePath {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Resolve and validate a path for safe operations
|
||||
#>
|
||||
param([string]$Path)
|
||||
|
||||
try {
|
||||
$resolved = [System.IO.Path]::GetFullPath($Path)
|
||||
return $resolved
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Formatting Utilities
|
||||
# ============================================================================
|
||||
|
||||
function Format-ByteSize {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Convert bytes to human-readable format
|
||||
#>
|
||||
param([long]$Bytes)
|
||||
|
||||
if ($Bytes -ge 1TB) {
|
||||
return "{0:N2}TB" -f ($Bytes / 1TB)
|
||||
}
|
||||
elseif ($Bytes -ge 1GB) {
|
||||
return "{0:N2}GB" -f ($Bytes / 1GB)
|
||||
}
|
||||
elseif ($Bytes -ge 1MB) {
|
||||
return "{0:N1}MB" -f ($Bytes / 1MB)
|
||||
}
|
||||
elseif ($Bytes -ge 1KB) {
|
||||
return "{0:N0}KB" -f ($Bytes / 1KB)
|
||||
}
|
||||
else {
|
||||
return "{0}B" -f $Bytes
|
||||
}
|
||||
}
|
||||
|
||||
function Format-Number {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Format a number with thousands separators
|
||||
#>
|
||||
param([long]$Number)
|
||||
return $Number.ToString("N0")
|
||||
}
|
||||
|
||||
function Format-TimeSpan {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Format a timespan to human-readable string
|
||||
#>
|
||||
param([TimeSpan]$Duration)
|
||||
|
||||
if ($Duration.TotalHours -ge 1) {
|
||||
return "{0:N1}h" -f $Duration.TotalHours
|
||||
}
|
||||
elseif ($Duration.TotalMinutes -ge 1) {
|
||||
return "{0:N0}m" -f $Duration.TotalMinutes
|
||||
}
|
||||
else {
|
||||
return "{0:N0}s" -f $Duration.TotalSeconds
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Environment Detection
|
||||
# ============================================================================
|
||||
|
||||
function Get-UserHome {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get the current user's home directory
|
||||
#>
|
||||
return $env:USERPROFILE
|
||||
}
|
||||
|
||||
function Get-TempPath {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get the system temp path
|
||||
#>
|
||||
return [System.IO.Path]::GetTempPath()
|
||||
}
|
||||
|
||||
function Get-ConfigPath {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get Mole config directory, creating it if needed
|
||||
#>
|
||||
$path = $script:Config.ConfigPath
|
||||
if (-not (Test-Path $path)) {
|
||||
New-Item -ItemType Directory -Path $path -Force | Out-Null
|
||||
}
|
||||
return $path
|
||||
}
|
||||
|
||||
function Get-CachePath {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get Mole cache directory, creating it if needed
|
||||
#>
|
||||
$path = $script:Config.CachePath
|
||||
if (-not (Test-Path $path)) {
|
||||
New-Item -ItemType Directory -Path $path -Force | Out-Null
|
||||
}
|
||||
return $path
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Temporary File Management
|
||||
# ============================================================================
|
||||
|
||||
$script:TempFiles = [System.Collections.ArrayList]::new()
|
||||
$script:TempDirs = [System.Collections.ArrayList]::new()
|
||||
|
||||
function New-TempFile {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Create a tracked temporary file
|
||||
#>
|
||||
param([string]$Prefix = "winmole")
|
||||
|
||||
$tempPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "$Prefix-$([Guid]::NewGuid().ToString('N').Substring(0,8)).tmp")
|
||||
New-Item -ItemType File -Path $tempPath -Force | Out-Null
|
||||
[void]$script:TempFiles.Add($tempPath)
|
||||
return $tempPath
|
||||
}
|
||||
|
||||
function New-TempDirectory {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Create a tracked temporary directory
|
||||
#>
|
||||
param([string]$Prefix = "winmole")
|
||||
|
||||
$tempPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "$Prefix-$([Guid]::NewGuid().ToString('N').Substring(0,8))")
|
||||
New-Item -ItemType Directory -Path $tempPath -Force | Out-Null
|
||||
[void]$script:TempDirs.Add($tempPath)
|
||||
return $tempPath
|
||||
}
|
||||
|
||||
function Clear-TempFiles {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clean up all tracked temporary files and directories
|
||||
#>
|
||||
foreach ($file in $script:TempFiles) {
|
||||
if (Test-Path $file) {
|
||||
Remove-Item $file -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
$script:TempFiles.Clear()
|
||||
|
||||
foreach ($dir in $script:TempDirs) {
|
||||
if (Test-Path $dir) {
|
||||
Remove-Item $dir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
$script:TempDirs.Clear()
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Exports (functions and variables are available via dot-sourcing)
|
||||
# ============================================================================
|
||||
# Variables: Colors, Icons, Config, ProtectedPaths, DefaultWhitelistPatterns
|
||||
# Functions: Test-IsAdmin, Get-FreeSpace, Get-WindowsVersion, etc.
|
||||
864
lib/core/base.sh
864
lib/core/base.sh
@@ -1,864 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Mole - Base Definitions and Utilities
|
||||
# Core definitions, constants, and basic utility functions used by all modules
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Prevent multiple sourcing
|
||||
if [[ -n "${MOLE_BASE_LOADED:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
readonly MOLE_BASE_LOADED=1
|
||||
|
||||
# ============================================================================
|
||||
# Color Definitions
|
||||
# ============================================================================
|
||||
readonly ESC=$'\033'
|
||||
readonly GREEN="${ESC}[0;32m"
|
||||
readonly BLUE="${ESC}[0;34m"
|
||||
readonly CYAN="${ESC}[0;36m"
|
||||
readonly YELLOW="${ESC}[0;33m"
|
||||
readonly PURPLE="${ESC}[0;35m"
|
||||
readonly PURPLE_BOLD="${ESC}[1;35m"
|
||||
readonly RED="${ESC}[0;31m"
|
||||
readonly GRAY="${ESC}[0;90m"
|
||||
readonly NC="${ESC}[0m"
|
||||
|
||||
# ============================================================================
|
||||
# Icon Definitions
|
||||
# ============================================================================
|
||||
readonly ICON_CONFIRM="◎"
|
||||
readonly ICON_ADMIN="⚙"
|
||||
readonly ICON_SUCCESS="✓"
|
||||
readonly ICON_ERROR="☻"
|
||||
readonly ICON_WARNING="●"
|
||||
readonly ICON_EMPTY="○"
|
||||
readonly ICON_SOLID="●"
|
||||
readonly ICON_LIST="•"
|
||||
readonly ICON_ARROW="➤"
|
||||
readonly ICON_DRY_RUN="→"
|
||||
readonly ICON_NAV_UP="↑"
|
||||
readonly ICON_NAV_DOWN="↓"
|
||||
|
||||
# ============================================================================
|
||||
# Global Configuration Constants
|
||||
# ============================================================================
|
||||
readonly MOLE_TEMP_FILE_AGE_DAYS=7 # Temp file retention (days)
|
||||
readonly MOLE_ORPHAN_AGE_DAYS=60 # Orphaned data retention (days)
|
||||
readonly MOLE_MAX_PARALLEL_JOBS=15 # Parallel job limit
|
||||
readonly MOLE_MAIL_DOWNLOADS_MIN_KB=5120 # Mail attachment size threshold
|
||||
readonly MOLE_MAIL_AGE_DAYS=30 # Mail attachment retention (days)
|
||||
readonly MOLE_LOG_AGE_DAYS=7 # Log retention (days)
|
||||
readonly MOLE_CRASH_REPORT_AGE_DAYS=7 # Crash report retention (days)
|
||||
readonly MOLE_SAVED_STATE_AGE_DAYS=30 # Saved state retention (days) - increased for safety
|
||||
readonly MOLE_TM_BACKUP_SAFE_HOURS=48 # TM backup safety window (hours)
|
||||
readonly MOLE_MAX_DS_STORE_FILES=500 # Max .DS_Store files to clean per scan
|
||||
readonly MOLE_MAX_ORPHAN_ITERATIONS=100 # Max iterations for orphaned app data scan
|
||||
|
||||
# ============================================================================
|
||||
# Whitelist Configuration
|
||||
# ============================================================================
|
||||
readonly FINDER_METADATA_SENTINEL="FINDER_METADATA"
|
||||
declare -a DEFAULT_WHITELIST_PATTERNS=(
|
||||
"$HOME/Library/Caches/ms-playwright*"
|
||||
"$HOME/.cache/huggingface*"
|
||||
"$HOME/.m2/repository/*"
|
||||
"$HOME/.ollama/models/*"
|
||||
"$HOME/Library/Caches/com.nssurge.surge-mac/*"
|
||||
"$HOME/Library/Application Support/com.nssurge.surge-mac/*"
|
||||
"$HOME/Library/Caches/org.R-project.R/R/renv/*"
|
||||
"$HOME/Library/Caches/pypoetry/virtualenvs*"
|
||||
"$HOME/Library/Caches/JetBrains*"
|
||||
"$HOME/Library/Caches/com.jetbrains.toolbox*"
|
||||
"$HOME/Library/Application Support/JetBrains*"
|
||||
"$HOME/Library/Caches/com.apple.finder"
|
||||
"$HOME/Library/Mobile Documents*"
|
||||
# System-critical caches that affect macOS functionality and stability
|
||||
# CRITICAL: Removing these will cause system search and UI issues
|
||||
"$HOME/Library/Caches/com.apple.FontRegistry*"
|
||||
"$HOME/Library/Caches/com.apple.spotlight*"
|
||||
"$HOME/Library/Caches/com.apple.Spotlight*"
|
||||
"$HOME/Library/Caches/CloudKit*"
|
||||
"$FINDER_METADATA_SENTINEL"
|
||||
)
|
||||
|
||||
declare -a DEFAULT_OPTIMIZE_WHITELIST_PATTERNS=(
|
||||
"check_brew_health"
|
||||
"check_touchid"
|
||||
"check_git_config"
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# BSD Stat Compatibility
|
||||
# ============================================================================
|
||||
readonly STAT_BSD="/usr/bin/stat"
|
||||
|
||||
# Get file size in bytes
|
||||
get_file_size() {
|
||||
local file="$1"
|
||||
local result
|
||||
result=$($STAT_BSD -f%z "$file" 2> /dev/null)
|
||||
echo "${result:-0}"
|
||||
}
|
||||
|
||||
# Get file modification time in epoch seconds
|
||||
get_file_mtime() {
|
||||
local file="$1"
|
||||
[[ -z "$file" ]] && {
|
||||
echo "0"
|
||||
return
|
||||
}
|
||||
local result
|
||||
result=$($STAT_BSD -f%m "$file" 2> /dev/null || echo "")
|
||||
if [[ "$result" =~ ^[0-9]+$ ]]; then
|
||||
echo "$result"
|
||||
else
|
||||
echo "0"
|
||||
fi
|
||||
}
|
||||
|
||||
# Determine date command once
|
||||
if [[ -x /bin/date ]]; then
|
||||
_DATE_CMD="/bin/date"
|
||||
else
|
||||
_DATE_CMD="date"
|
||||
fi
|
||||
|
||||
# Get current time in epoch seconds (defensive against locale/aliases)
|
||||
get_epoch_seconds() {
|
||||
local result
|
||||
result=$($_DATE_CMD +%s 2> /dev/null || echo "")
|
||||
if [[ "$result" =~ ^[0-9]+$ ]]; then
|
||||
echo "$result"
|
||||
else
|
||||
echo "0"
|
||||
fi
|
||||
}
|
||||
|
||||
# Get file owner username
|
||||
get_file_owner() {
|
||||
local file="$1"
|
||||
$STAT_BSD -f%Su "$file" 2> /dev/null || echo ""
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# System Utilities
|
||||
# ============================================================================
|
||||
|
||||
# Check if System Integrity Protection is enabled
|
||||
# Returns: 0 if SIP is enabled, 1 if disabled or cannot determine
|
||||
is_sip_enabled() {
|
||||
if ! command -v csrutil > /dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local sip_status
|
||||
sip_status=$(csrutil status 2> /dev/null || echo "")
|
||||
|
||||
if echo "$sip_status" | grep -qi "enabled"; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if running in an interactive terminal
|
||||
is_interactive() {
|
||||
[[ -t 1 ]]
|
||||
}
|
||||
|
||||
# Detect CPU architecture
|
||||
# Returns: "Apple Silicon" or "Intel"
|
||||
detect_architecture() {
|
||||
if [[ "$(uname -m)" == "arm64" ]]; then
|
||||
echo "Apple Silicon"
|
||||
else
|
||||
echo "Intel"
|
||||
fi
|
||||
}
|
||||
|
||||
# Get free disk space on root volume
|
||||
# Returns: human-readable string (e.g., "100G")
|
||||
get_free_space() {
|
||||
local target="/"
|
||||
if [[ -d "/System/Volumes/Data" ]]; then
|
||||
target="/System/Volumes/Data"
|
||||
fi
|
||||
|
||||
df -h "$target" | awk 'NR==2 {print $4}'
|
||||
}
|
||||
|
||||
# Get Darwin kernel major version (e.g., 24 for 24.2.0)
|
||||
# Returns 999 on failure to adopt conservative behavior (assume modern system)
|
||||
get_darwin_major() {
|
||||
local kernel
|
||||
kernel=$(uname -r 2> /dev/null || true)
|
||||
local major="${kernel%%.*}"
|
||||
if [[ ! "$major" =~ ^[0-9]+$ ]]; then
|
||||
# Return high number to skip potentially dangerous operations on unknown systems
|
||||
major=999
|
||||
fi
|
||||
echo "$major"
|
||||
}
|
||||
|
||||
# Check if Darwin kernel major version meets minimum
|
||||
is_darwin_ge() {
|
||||
local minimum="$1"
|
||||
local major
|
||||
major=$(get_darwin_major)
|
||||
[[ "$major" -ge "$minimum" ]]
|
||||
}
|
||||
|
||||
# Get optimal parallel jobs for operation type (scan|io|compute|default)
|
||||
get_optimal_parallel_jobs() {
|
||||
local operation_type="${1:-default}"
|
||||
local cpu_cores
|
||||
cpu_cores=$(sysctl -n hw.ncpu 2> /dev/null || echo 4)
|
||||
case "$operation_type" in
|
||||
scan | io)
|
||||
echo $((cpu_cores * 2))
|
||||
;;
|
||||
compute)
|
||||
echo "$cpu_cores"
|
||||
;;
|
||||
*)
|
||||
echo $((cpu_cores + 2))
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# User Context Utilities
|
||||
# ============================================================================
|
||||
|
||||
is_root_user() {
|
||||
[[ "$(id -u)" == "0" ]]
|
||||
}
|
||||
|
||||
get_user_home() {
|
||||
local user="$1"
|
||||
local home=""
|
||||
|
||||
if [[ -z "$user" ]]; then
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v dscl > /dev/null 2>&1; then
|
||||
home=$(dscl . -read "/Users/$user" NFSHomeDirectory 2> /dev/null | awk '{print $2}' | head -1 || true)
|
||||
fi
|
||||
|
||||
if [[ -z "$home" ]]; then
|
||||
home=$(eval echo "~$user" 2> /dev/null || true)
|
||||
fi
|
||||
|
||||
if [[ "$home" == "~"* ]]; then
|
||||
home=""
|
||||
fi
|
||||
|
||||
echo "$home"
|
||||
}
|
||||
|
||||
get_invoking_user() {
|
||||
if [[ -n "${SUDO_USER:-}" && "${SUDO_USER:-}" != "root" ]]; then
|
||||
echo "$SUDO_USER"
|
||||
return 0
|
||||
fi
|
||||
echo "${USER:-}"
|
||||
}
|
||||
|
||||
get_invoking_uid() {
|
||||
if [[ -n "${SUDO_UID:-}" ]]; then
|
||||
echo "$SUDO_UID"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local uid
|
||||
uid=$(id -u 2> /dev/null || true)
|
||||
echo "$uid"
|
||||
}
|
||||
|
||||
get_invoking_gid() {
|
||||
if [[ -n "${SUDO_GID:-}" ]]; then
|
||||
echo "$SUDO_GID"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local gid
|
||||
gid=$(id -g 2> /dev/null || true)
|
||||
echo "$gid"
|
||||
}
|
||||
|
||||
get_invoking_home() {
|
||||
if [[ -n "${SUDO_USER:-}" && "${SUDO_USER:-}" != "root" ]]; then
|
||||
get_user_home "$SUDO_USER"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "${HOME:-}"
|
||||
}
|
||||
|
||||
ensure_user_dir() {
|
||||
local raw_path="$1"
|
||||
if [[ -z "$raw_path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local target_path="$raw_path"
|
||||
if [[ "$target_path" == "~"* ]]; then
|
||||
target_path="${target_path/#\~/$HOME}"
|
||||
fi
|
||||
|
||||
mkdir -p "$target_path" 2> /dev/null || true
|
||||
|
||||
if ! is_root_user; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local sudo_user="${SUDO_USER:-}"
|
||||
if [[ -z "$sudo_user" || "$sudo_user" == "root" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local user_home
|
||||
user_home=$(get_user_home "$sudo_user")
|
||||
if [[ -z "$user_home" ]]; then
|
||||
return 0
|
||||
fi
|
||||
user_home="${user_home%/}"
|
||||
|
||||
if [[ "$target_path" != "$user_home" && "$target_path" != "$user_home/"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local owner_uid="${SUDO_UID:-}"
|
||||
local owner_gid="${SUDO_GID:-}"
|
||||
if [[ -z "$owner_uid" || -z "$owner_gid" ]]; then
|
||||
owner_uid=$(id -u "$sudo_user" 2> /dev/null || true)
|
||||
owner_gid=$(id -g "$sudo_user" 2> /dev/null || true)
|
||||
fi
|
||||
|
||||
if [[ -z "$owner_uid" || -z "$owner_gid" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local dir="$target_path"
|
||||
while [[ -n "$dir" && "$dir" != "/" ]]; do
|
||||
# Early stop: if ownership is already correct, no need to continue up the tree
|
||||
if [[ -d "$dir" ]]; then
|
||||
local current_uid
|
||||
current_uid=$("$STAT_BSD" -f%u "$dir" 2> /dev/null || echo "")
|
||||
if [[ "$current_uid" == "$owner_uid" ]]; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
|
||||
chown "$owner_uid:$owner_gid" "$dir" 2> /dev/null || true
|
||||
|
||||
if [[ "$dir" == "$user_home" ]]; then
|
||||
break
|
||||
fi
|
||||
dir=$(dirname "$dir")
|
||||
if [[ "$dir" == "." ]]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
ensure_user_file() {
|
||||
local raw_path="$1"
|
||||
if [[ -z "$raw_path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local target_path="$raw_path"
|
||||
if [[ "$target_path" == "~"* ]]; then
|
||||
target_path="${target_path/#\~/$HOME}"
|
||||
fi
|
||||
|
||||
ensure_user_dir "$(dirname "$target_path")"
|
||||
touch "$target_path" 2> /dev/null || true
|
||||
|
||||
if ! is_root_user; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local sudo_user="${SUDO_USER:-}"
|
||||
if [[ -z "$sudo_user" || "$sudo_user" == "root" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local user_home
|
||||
user_home=$(get_user_home "$sudo_user")
|
||||
if [[ -z "$user_home" ]]; then
|
||||
return 0
|
||||
fi
|
||||
user_home="${user_home%/}"
|
||||
|
||||
if [[ "$target_path" != "$user_home" && "$target_path" != "$user_home/"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local owner_uid="${SUDO_UID:-}"
|
||||
local owner_gid="${SUDO_GID:-}"
|
||||
if [[ -z "$owner_uid" || -z "$owner_gid" ]]; then
|
||||
owner_uid=$(id -u "$sudo_user" 2> /dev/null || true)
|
||||
owner_gid=$(id -g "$sudo_user" 2> /dev/null || true)
|
||||
fi
|
||||
|
||||
if [[ -n "$owner_uid" && -n "$owner_gid" ]]; then
|
||||
chown "$owner_uid:$owner_gid" "$target_path" 2> /dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Formatting Utilities
|
||||
# ============================================================================
|
||||
|
||||
# Convert bytes to human-readable format (e.g., 1.5GB)
|
||||
bytes_to_human() {
|
||||
local bytes="$1"
|
||||
[[ "$bytes" =~ ^[0-9]+$ ]] || {
|
||||
echo "0B"
|
||||
return 1
|
||||
}
|
||||
|
||||
# GB: >= 1073741824 bytes
|
||||
if ((bytes >= 1073741824)); then
|
||||
printf "%d.%02dGB\n" $((bytes / 1073741824)) $(((bytes % 1073741824) * 100 / 1073741824))
|
||||
# MB: >= 1048576 bytes
|
||||
elif ((bytes >= 1048576)); then
|
||||
printf "%d.%01dMB\n" $((bytes / 1048576)) $(((bytes % 1048576) * 10 / 1048576))
|
||||
# KB: >= 1024 bytes (round up)
|
||||
elif ((bytes >= 1024)); then
|
||||
printf "%dKB\n" $(((bytes + 512) / 1024))
|
||||
else
|
||||
printf "%dB\n" "$bytes"
|
||||
fi
|
||||
}
|
||||
|
||||
# Convert kilobytes to human-readable format
|
||||
# Args: $1 - size in KB
|
||||
# Returns: formatted string
|
||||
bytes_to_human_kb() {
|
||||
bytes_to_human "$((${1:-0} * 1024))"
|
||||
}
|
||||
|
||||
# Get brand-friendly localized name for an application
|
||||
get_brand_name() {
|
||||
local name="$1"
|
||||
|
||||
# Detect if system primary language is Chinese (Cached)
|
||||
if [[ -z "${MOLE_IS_CHINESE_SYSTEM:-}" ]]; then
|
||||
local sys_lang
|
||||
sys_lang=$(defaults read -g AppleLanguages 2> /dev/null | grep -o 'zh-Hans\|zh-Hant\|zh' | head -1 || echo "")
|
||||
if [[ -n "$sys_lang" ]]; then
|
||||
export MOLE_IS_CHINESE_SYSTEM="true"
|
||||
else
|
||||
export MOLE_IS_CHINESE_SYSTEM="false"
|
||||
fi
|
||||
fi
|
||||
|
||||
local is_chinese="${MOLE_IS_CHINESE_SYSTEM}"
|
||||
|
||||
# Return localized names based on system language
|
||||
if [[ "$is_chinese" == true ]]; then
|
||||
# Chinese system - prefer Chinese names
|
||||
case "$name" in
|
||||
"qiyimac" | "iQiyi") echo "爱奇艺" ;;
|
||||
"wechat" | "WeChat") echo "微信" ;;
|
||||
"QQ") echo "QQ" ;;
|
||||
"VooV Meeting") echo "腾讯会议" ;;
|
||||
"dingtalk" | "DingTalk") echo "钉钉" ;;
|
||||
"NeteaseMusic" | "NetEase Music") echo "网易云音乐" ;;
|
||||
"BaiduNetdisk" | "Baidu NetDisk") echo "百度网盘" ;;
|
||||
"alipay" | "Alipay") echo "支付宝" ;;
|
||||
"taobao" | "Taobao") echo "淘宝" ;;
|
||||
"futunn" | "Futu NiuNiu") echo "富途牛牛" ;;
|
||||
"tencent lemon" | "Tencent Lemon Cleaner" | "Tencent Lemon") echo "腾讯柠檬清理" ;;
|
||||
*) echo "$name" ;;
|
||||
esac
|
||||
else
|
||||
# Non-Chinese system - use English names
|
||||
case "$name" in
|
||||
"qiyimac" | "爱奇艺") echo "iQiyi" ;;
|
||||
"wechat" | "微信") echo "WeChat" ;;
|
||||
"QQ") echo "QQ" ;;
|
||||
"腾讯会议") echo "VooV Meeting" ;;
|
||||
"dingtalk" | "钉钉") echo "DingTalk" ;;
|
||||
"网易云音乐") echo "NetEase Music" ;;
|
||||
"百度网盘") echo "Baidu NetDisk" ;;
|
||||
"alipay" | "支付宝") echo "Alipay" ;;
|
||||
"taobao" | "淘宝") echo "Taobao" ;;
|
||||
"富途牛牛") echo "Futu NiuNiu" ;;
|
||||
"腾讯柠檬清理" | "Tencent Lemon Cleaner") echo "Tencent Lemon" ;;
|
||||
"keynote" | "Keynote") echo "Keynote" ;;
|
||||
"pages" | "Pages") echo "Pages" ;;
|
||||
"numbers" | "Numbers") echo "Numbers" ;;
|
||||
*) echo "$name" ;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Temporary File Management
|
||||
# ============================================================================
|
||||
|
||||
# Tracked temporary files and directories
|
||||
declare -a MOLE_TEMP_FILES=()
|
||||
declare -a MOLE_TEMP_DIRS=()
|
||||
|
||||
# Create tracked temporary file
|
||||
create_temp_file() {
|
||||
local temp
|
||||
temp=$(mktemp) || return 1
|
||||
MOLE_TEMP_FILES+=("$temp")
|
||||
echo "$temp"
|
||||
}
|
||||
|
||||
# Create tracked temporary directory
|
||||
create_temp_dir() {
|
||||
local temp
|
||||
temp=$(mktemp -d) || return 1
|
||||
MOLE_TEMP_DIRS+=("$temp")
|
||||
echo "$temp"
|
||||
}
|
||||
|
||||
# Register existing file for cleanup
|
||||
register_temp_file() {
|
||||
MOLE_TEMP_FILES+=("$1")
|
||||
}
|
||||
|
||||
# Register existing directory for cleanup
|
||||
register_temp_dir() {
|
||||
MOLE_TEMP_DIRS+=("$1")
|
||||
}
|
||||
|
||||
# Create temp file with prefix (for analyze.sh compatibility)
|
||||
# Compatible with both BSD mktemp (macOS default) and GNU mktemp (coreutils)
|
||||
mktemp_file() {
|
||||
local prefix="${1:-mole}"
|
||||
# Use TMPDIR if set, otherwise /tmp
|
||||
# Add .XXXXXX suffix to work with both BSD and GNU mktemp
|
||||
mktemp "${TMPDIR:-/tmp}/${prefix}.XXXXXX"
|
||||
}
|
||||
|
||||
# Cleanup all tracked temp files and directories
|
||||
cleanup_temp_files() {
|
||||
stop_inline_spinner 2> /dev/null || true
|
||||
local file
|
||||
if [[ ${#MOLE_TEMP_FILES[@]} -gt 0 ]]; then
|
||||
for file in "${MOLE_TEMP_FILES[@]}"; do
|
||||
[[ -f "$file" ]] && rm -f "$file" 2> /dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ ${#MOLE_TEMP_DIRS[@]} -gt 0 ]]; then
|
||||
for file in "${MOLE_TEMP_DIRS[@]}"; do
|
||||
[[ -d "$file" ]] && rm -rf "$file" 2> /dev/null || true # SAFE: cleanup_temp_files
|
||||
done
|
||||
fi
|
||||
|
||||
MOLE_TEMP_FILES=()
|
||||
MOLE_TEMP_DIRS=()
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Section Tracking (for progress indication)
|
||||
# ============================================================================
|
||||
|
||||
# Global section tracking variables
|
||||
TRACK_SECTION=0
|
||||
SECTION_ACTIVITY=0
|
||||
|
||||
# Start a new section
|
||||
# Args: $1 - section title
|
||||
start_section() {
|
||||
TRACK_SECTION=1
|
||||
SECTION_ACTIVITY=0
|
||||
echo ""
|
||||
echo -e "${PURPLE_BOLD}${ICON_ARROW} $1${NC}"
|
||||
}
|
||||
|
||||
# End a section
|
||||
# Shows "Nothing to tidy" if no activity was recorded
|
||||
end_section() {
|
||||
if [[ "${TRACK_SECTION:-0}" == "1" && "${SECTION_ACTIVITY:-0}" == "0" ]]; then
|
||||
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Nothing to tidy"
|
||||
fi
|
||||
TRACK_SECTION=0
|
||||
}
|
||||
|
||||
# Mark activity in current section
|
||||
note_activity() {
|
||||
if [[ "${TRACK_SECTION:-0}" == "1" ]]; then
|
||||
SECTION_ACTIVITY=1
|
||||
fi
|
||||
}
|
||||
|
||||
# Start a section spinner with optional message
|
||||
# Usage: start_section_spinner "message"
|
||||
start_section_spinner() {
|
||||
local message="${1:-Scanning...}"
|
||||
stop_inline_spinner 2> /dev/null || true
|
||||
if [[ -t 1 ]]; then
|
||||
MOLE_SPINNER_PREFIX=" " start_inline_spinner "$message"
|
||||
fi
|
||||
}
|
||||
|
||||
# Stop spinner and clear the line
|
||||
# Usage: stop_section_spinner
|
||||
stop_section_spinner() {
|
||||
stop_inline_spinner 2> /dev/null || true
|
||||
if [[ -t 1 ]]; then
|
||||
echo -ne "\r\033[K" >&2 || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Safe terminal line clearing with terminal type detection
|
||||
# Usage: safe_clear_lines <num_lines> [tty_device]
|
||||
# Returns: 0 on success, 1 if terminal doesn't support ANSI
|
||||
safe_clear_lines() {
|
||||
local lines="${1:-1}"
|
||||
local tty_device="${2:-/dev/tty}"
|
||||
|
||||
# Use centralized ANSI support check (defined below)
|
||||
# Note: This forward reference works because functions are parsed before execution
|
||||
is_ansi_supported 2> /dev/null || return 1
|
||||
|
||||
# Clear lines one by one (more reliable than multi-line sequences)
|
||||
local i
|
||||
for ((i = 0; i < lines; i++)); do
|
||||
printf "\033[1A\r\033[K" > "$tty_device" 2> /dev/null || return 1
|
||||
done
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Safe single line clear with fallback
|
||||
# Usage: safe_clear_line [tty_device]
|
||||
safe_clear_line() {
|
||||
local tty_device="${1:-/dev/tty}"
|
||||
|
||||
# Use centralized ANSI support check
|
||||
is_ansi_supported 2> /dev/null || return 1
|
||||
|
||||
printf "\r\033[K" > "$tty_device" 2> /dev/null || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# Update progress spinner if enough time has elapsed
|
||||
# Usage: update_progress_if_needed <completed> <total> <last_update_time_var> [interval]
|
||||
# Example: update_progress_if_needed "$completed" "$total" last_progress_update 2
|
||||
# Returns: 0 if updated, 1 if skipped
|
||||
update_progress_if_needed() {
|
||||
local completed="$1"
|
||||
local total="$2"
|
||||
local last_update_var="$3" # Name of variable holding last update time
|
||||
local interval="${4:-2}" # Default: update every 2 seconds
|
||||
|
||||
# Get current time
|
||||
local current_time
|
||||
current_time=$(get_epoch_seconds)
|
||||
|
||||
# Get last update time from variable
|
||||
local last_time
|
||||
eval "last_time=\${$last_update_var:-0}"
|
||||
[[ "$last_time" =~ ^[0-9]+$ ]] || last_time=0
|
||||
|
||||
# Check if enough time has elapsed
|
||||
if [[ $((current_time - last_time)) -ge $interval ]]; then
|
||||
# Update the spinner with progress
|
||||
stop_section_spinner
|
||||
start_section_spinner "Scanning items... ($completed/$total)"
|
||||
|
||||
# Update the last_update_time variable
|
||||
eval "$last_update_var=$current_time"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Spinner Stack Management (prevents nesting issues)
|
||||
# ============================================================================
|
||||
|
||||
# Global spinner stack
|
||||
declare -a MOLE_SPINNER_STACK=()
|
||||
|
||||
# Push current spinner state onto stack
|
||||
# Usage: push_spinner_state
|
||||
push_spinner_state() {
|
||||
local current_state=""
|
||||
|
||||
# Save current spinner PID if running
|
||||
if [[ -n "${MOLE_SPINNER_PID:-}" ]] && kill -0 "$MOLE_SPINNER_PID" 2> /dev/null; then
|
||||
current_state="running:$MOLE_SPINNER_PID"
|
||||
else
|
||||
current_state="stopped"
|
||||
fi
|
||||
|
||||
MOLE_SPINNER_STACK+=("$current_state")
|
||||
debug_log "Pushed spinner state: $current_state (stack depth: ${#MOLE_SPINNER_STACK[@]})"
|
||||
}
|
||||
|
||||
# Pop and restore spinner state from stack
|
||||
# Usage: pop_spinner_state
|
||||
pop_spinner_state() {
|
||||
if [[ ${#MOLE_SPINNER_STACK[@]} -eq 0 ]]; then
|
||||
debug_log "Warning: Attempted to pop from empty spinner stack"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Stack depth safety check
|
||||
if [[ ${#MOLE_SPINNER_STACK[@]} -gt 10 ]]; then
|
||||
debug_log "Warning: Spinner stack depth excessive (${#MOLE_SPINNER_STACK[@]}), possible leak"
|
||||
fi
|
||||
|
||||
local last_idx=$((${#MOLE_SPINNER_STACK[@]} - 1))
|
||||
local state="${MOLE_SPINNER_STACK[$last_idx]}"
|
||||
|
||||
# Remove from stack (Bash 3.2 compatible way)
|
||||
# Instead of unset, rebuild array without last element
|
||||
local -a new_stack=()
|
||||
local i
|
||||
for ((i = 0; i < last_idx; i++)); do
|
||||
new_stack+=("${MOLE_SPINNER_STACK[$i]}")
|
||||
done
|
||||
MOLE_SPINNER_STACK=("${new_stack[@]}")
|
||||
|
||||
debug_log "Popped spinner state: $state (remaining depth: ${#MOLE_SPINNER_STACK[@]})"
|
||||
|
||||
# Restore state if needed
|
||||
if [[ "$state" == running:* ]]; then
|
||||
# Previous spinner was running - we don't restart it automatically
|
||||
# This is intentional to avoid UI conflicts
|
||||
:
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Safe spinner start with stack management
|
||||
# Usage: safe_start_spinner <message>
|
||||
safe_start_spinner() {
|
||||
local message="${1:-Working...}"
|
||||
|
||||
# Push current state
|
||||
push_spinner_state
|
||||
|
||||
# Stop any existing spinner
|
||||
stop_section_spinner 2> /dev/null || true
|
||||
|
||||
# Start new spinner
|
||||
start_section_spinner "$message"
|
||||
}
|
||||
|
||||
# Safe spinner stop with stack management
|
||||
# Usage: safe_stop_spinner
|
||||
safe_stop_spinner() {
|
||||
# Stop current spinner
|
||||
stop_section_spinner 2> /dev/null || true
|
||||
|
||||
# Pop previous state
|
||||
pop_spinner_state || true
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Terminal Compatibility Checks
|
||||
# ============================================================================
|
||||
|
||||
# Check if terminal supports ANSI escape codes
|
||||
# Usage: is_ansi_supported
|
||||
# Returns: 0 if supported, 1 if not
|
||||
is_ansi_supported() {
|
||||
# Check if running in interactive terminal
|
||||
[[ -t 1 ]] || return 1
|
||||
|
||||
# Check TERM variable
|
||||
[[ -n "${TERM:-}" ]] || return 1
|
||||
|
||||
# Check for known ANSI-compatible terminals
|
||||
case "$TERM" in
|
||||
xterm* | vt100 | vt220 | screen* | tmux* | ansi | linux | rxvt* | konsole*)
|
||||
return 0
|
||||
;;
|
||||
dumb | unknown)
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
# Check terminfo database if available
|
||||
if command -v tput > /dev/null 2>&1; then
|
||||
# Test if terminal supports colors (good proxy for ANSI support)
|
||||
local colors=$(tput colors 2> /dev/null || echo "0")
|
||||
[[ "$colors" -ge 8 ]] && return 0
|
||||
fi
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Get terminal capability info
|
||||
# Usage: get_terminal_info
|
||||
get_terminal_info() {
|
||||
local info="Terminal: ${TERM:-unknown}"
|
||||
|
||||
if is_ansi_supported; then
|
||||
info+=" (ANSI supported)"
|
||||
|
||||
if command -v tput > /dev/null 2>&1; then
|
||||
local cols=$(tput cols 2> /dev/null || echo "?")
|
||||
local lines=$(tput lines 2> /dev/null || echo "?")
|
||||
local colors=$(tput colors 2> /dev/null || echo "?")
|
||||
info+=" ${cols}x${lines}, ${colors} colors"
|
||||
fi
|
||||
else
|
||||
info+=" (ANSI not supported)"
|
||||
fi
|
||||
|
||||
echo "$info"
|
||||
}
|
||||
|
||||
# Validate terminal environment before running
|
||||
# Usage: validate_terminal_environment
|
||||
# Returns: 0 if OK, 1 with warning if issues detected
|
||||
validate_terminal_environment() {
|
||||
local warnings=0
|
||||
|
||||
# Check if TERM is set
|
||||
if [[ -z "${TERM:-}" ]]; then
|
||||
log_warning "TERM environment variable not set"
|
||||
((warnings++))
|
||||
fi
|
||||
|
||||
# Check if running in a known problematic terminal
|
||||
case "${TERM:-}" in
|
||||
dumb)
|
||||
log_warning "Running in 'dumb' terminal - limited functionality"
|
||||
((warnings++))
|
||||
;;
|
||||
unknown)
|
||||
log_warning "Terminal type unknown - may have display issues"
|
||||
((warnings++))
|
||||
;;
|
||||
esac
|
||||
|
||||
# Check terminal size if available
|
||||
if command -v tput > /dev/null 2>&1; then
|
||||
local cols=$(tput cols 2> /dev/null || echo "80")
|
||||
if [[ "$cols" -lt 60 ]]; then
|
||||
log_warning "Terminal width ($cols cols) is narrow - output may wrap"
|
||||
((warnings++))
|
||||
fi
|
||||
fi
|
||||
|
||||
# Report compatibility
|
||||
if [[ $warnings -eq 0 ]]; then
|
||||
debug_log "Terminal environment validated: $(get_terminal_info)"
|
||||
return 0
|
||||
else
|
||||
debug_log "Terminal compatibility warnings: $warnings"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Shared command list for help text and completions.
|
||||
MOLE_COMMANDS=(
|
||||
"clean:Free up disk space"
|
||||
"uninstall:Remove apps completely"
|
||||
"optimize:Check and maintain system"
|
||||
"analyze:Explore disk usage"
|
||||
"status:Monitor system health"
|
||||
"purge:Remove old project artifacts"
|
||||
"installer:Find and remove installer files"
|
||||
"touchid:Configure Touch ID for sudo"
|
||||
"completion:Setup shell tab completion"
|
||||
"update:Update to latest version"
|
||||
"remove:Remove Mole from system"
|
||||
"help:Show help"
|
||||
"version:Show version"
|
||||
)
|
||||
130
lib/core/common.ps1
Normal file
130
lib/core/common.ps1
Normal file
@@ -0,0 +1,130 @@
|
||||
# Mole - Common Functions Library
|
||||
# Main entry point that loads all core modules
|
||||
|
||||
#Requires -Version 5.1
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Prevent multiple sourcing
|
||||
if ((Get-Variable -Name 'MOLE_COMMON_LOADED' -Scope Script -ErrorAction SilentlyContinue) -and $script:MOLE_COMMON_LOADED) {
|
||||
return
|
||||
}
|
||||
$script:MOLE_COMMON_LOADED = $true
|
||||
|
||||
# Get the directory containing this script
|
||||
$script:MOLE_CORE_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$script:MOLE_LIB_DIR = Split-Path -Parent $script:MOLE_CORE_DIR
|
||||
$script:MOLE_ROOT_DIR = Split-Path -Parent $script:MOLE_LIB_DIR
|
||||
|
||||
# ============================================================================
|
||||
# Load Core Modules
|
||||
# ============================================================================
|
||||
|
||||
# Base definitions (colors, icons, constants)
|
||||
. "$script:MOLE_CORE_DIR\base.ps1"
|
||||
|
||||
# Logging functions
|
||||
. "$script:MOLE_CORE_DIR\log.ps1"
|
||||
|
||||
# Safe file operations
|
||||
. "$script:MOLE_CORE_DIR\file_ops.ps1"
|
||||
|
||||
# UI components
|
||||
. "$script:MOLE_CORE_DIR\ui.ps1"
|
||||
|
||||
# ============================================================================
|
||||
# Version Information
|
||||
# ============================================================================
|
||||
|
||||
$script:MOLE_VERSION = "1.0.0"
|
||||
$script:MOLE_BUILD_DATE = "2026-01-07"
|
||||
|
||||
function Get-MoleVersion {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get Mole version information
|
||||
#>
|
||||
return @{
|
||||
Version = $script:MOLE_VERSION
|
||||
BuildDate = $script:MOLE_BUILD_DATE
|
||||
PowerShell = $PSVersionTable.PSVersion.ToString()
|
||||
Windows = (Get-WindowsVersion).Version
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Initialization
|
||||
# ============================================================================
|
||||
|
||||
function Initialize-Mole {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Initialize Mole environment
|
||||
#>
|
||||
|
||||
# Ensure config directory exists
|
||||
$configPath = Get-ConfigPath
|
||||
|
||||
# Ensure cache directory exists
|
||||
$cachePath = Get-CachePath
|
||||
|
||||
# Set up cleanup trap
|
||||
$null = Register-EngineEvent -SourceIdentifier PowerShell.Exiting -Action {
|
||||
Clear-TempFiles
|
||||
}
|
||||
|
||||
Write-Debug "Mole initialized"
|
||||
Write-Debug "Config: $configPath"
|
||||
Write-Debug "Cache: $cachePath"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Admin Elevation
|
||||
# ============================================================================
|
||||
|
||||
function Request-AdminPrivileges {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Request admin privileges if not already running as admin
|
||||
.DESCRIPTION
|
||||
Restarts the script with elevated privileges using UAC
|
||||
#>
|
||||
if (-not (Test-IsAdmin)) {
|
||||
Write-MoleWarning "Some operations require administrator privileges."
|
||||
|
||||
if (Read-Confirmation -Prompt "Restart with admin privileges?" -Default $true) {
|
||||
$scriptPath = $MyInvocation.PSCommandPath
|
||||
if ($scriptPath) {
|
||||
Start-Process powershell.exe -ArgumentList "-ExecutionPolicy Bypass -File `"$scriptPath`"" -Verb RunAs
|
||||
exit
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Invoke-AsAdmin {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Run a script block with admin privileges
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[scriptblock]$ScriptBlock
|
||||
)
|
||||
|
||||
if (Test-IsAdmin) {
|
||||
& $ScriptBlock
|
||||
}
|
||||
else {
|
||||
$command = $ScriptBlock.ToString()
|
||||
Start-Process powershell.exe -ArgumentList "-ExecutionPolicy Bypass -Command `"$command`"" -Verb RunAs -Wait
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Exports (functions are available via dot-sourcing)
|
||||
# ============================================================================
|
||||
# All functions from base.ps1, log.ps1, file_ops.ps1, and ui.ps1 are
|
||||
# automatically available when this file is dot-sourced.
|
||||
@@ -1,188 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Mole - Common Functions Library
|
||||
# Main entry point that loads all core modules
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Prevent multiple sourcing
|
||||
if [[ -n "${MOLE_COMMON_LOADED:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
readonly MOLE_COMMON_LOADED=1
|
||||
|
||||
_MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Load core modules
|
||||
source "$_MOLE_CORE_DIR/base.sh"
|
||||
source "$_MOLE_CORE_DIR/log.sh"
|
||||
|
||||
source "$_MOLE_CORE_DIR/timeout.sh"
|
||||
source "$_MOLE_CORE_DIR/file_ops.sh"
|
||||
source "$_MOLE_CORE_DIR/ui.sh"
|
||||
source "$_MOLE_CORE_DIR/app_protection.sh"
|
||||
|
||||
# Load sudo management if available
|
||||
if [[ -f "$_MOLE_CORE_DIR/sudo.sh" ]]; then
|
||||
source "$_MOLE_CORE_DIR/sudo.sh"
|
||||
fi
|
||||
|
||||
# Update via Homebrew
|
||||
update_via_homebrew() {
|
||||
local current_version="$1"
|
||||
local temp_update temp_upgrade
|
||||
temp_update=$(mktemp_file "brew_update")
|
||||
temp_upgrade=$(mktemp_file "brew_upgrade")
|
||||
|
||||
# Set up trap for interruption (Ctrl+C) with inline cleanup
|
||||
trap 'stop_inline_spinner 2>/dev/null; rm -f "$temp_update" "$temp_upgrade" 2>/dev/null; echo ""; exit 130' INT TERM
|
||||
|
||||
# Update Homebrew
|
||||
if [[ -t 1 ]]; then
|
||||
start_inline_spinner "Updating Homebrew..."
|
||||
else
|
||||
echo "Updating Homebrew..."
|
||||
fi
|
||||
|
||||
brew update > "$temp_update" 2>&1 &
|
||||
local update_pid=$!
|
||||
wait $update_pid 2> /dev/null || true # Continue even if brew update fails
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
stop_inline_spinner
|
||||
fi
|
||||
|
||||
# Upgrade Mole
|
||||
if [[ -t 1 ]]; then
|
||||
start_inline_spinner "Upgrading Mole..."
|
||||
else
|
||||
echo "Upgrading Mole..."
|
||||
fi
|
||||
|
||||
brew upgrade mole > "$temp_upgrade" 2>&1 &
|
||||
local upgrade_pid=$!
|
||||
wait $upgrade_pid 2> /dev/null || true # Continue even if brew upgrade fails
|
||||
|
||||
local upgrade_output
|
||||
upgrade_output=$(cat "$temp_upgrade")
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
stop_inline_spinner
|
||||
fi
|
||||
|
||||
# Clear trap
|
||||
trap - INT TERM
|
||||
|
||||
# Cleanup temp files
|
||||
rm -f "$temp_update" "$temp_upgrade"
|
||||
|
||||
if echo "$upgrade_output" | grep -q "already installed"; then
|
||||
local installed_version
|
||||
installed_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}')
|
||||
echo ""
|
||||
echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version (${installed_version:-$current_version})"
|
||||
echo ""
|
||||
elif echo "$upgrade_output" | grep -q "Error:"; then
|
||||
log_error "Homebrew upgrade failed"
|
||||
echo "$upgrade_output" | grep "Error:" >&2
|
||||
return 1
|
||||
else
|
||||
echo "$upgrade_output" | grep -Ev "^(==>|Updating Homebrew|Warning:)" || true
|
||||
local new_version
|
||||
new_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}')
|
||||
echo ""
|
||||
echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-$current_version})"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Clear update cache (suppress errors if cache doesn't exist or is locked)
|
||||
rm -f "$HOME/.cache/mole/version_check" "$HOME/.cache/mole/update_message" 2> /dev/null || true
|
||||
}
|
||||
|
||||
# Remove applications from Dock
|
||||
remove_apps_from_dock() {
|
||||
if [[ $# -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local plist="$HOME/Library/Preferences/com.apple.dock.plist"
|
||||
[[ -f "$plist" ]] || return 0
|
||||
|
||||
if ! command -v python3 > /dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Prune dock entries using Python helper
|
||||
python3 - "$@" << 'PY' 2> /dev/null || return 0
|
||||
import os
|
||||
import plistlib
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
plist_path = os.path.expanduser('~/Library/Preferences/com.apple.dock.plist')
|
||||
if not os.path.exists(plist_path):
|
||||
sys.exit(0)
|
||||
|
||||
def normalise(path):
|
||||
if not path:
|
||||
return ''
|
||||
return os.path.normpath(os.path.realpath(path.rstrip('/')))
|
||||
|
||||
targets = {normalise(arg) for arg in sys.argv[1:] if arg}
|
||||
targets = {t for t in targets if t}
|
||||
if not targets:
|
||||
sys.exit(0)
|
||||
|
||||
with open(plist_path, 'rb') as fh:
|
||||
try:
|
||||
data = plistlib.load(fh)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
|
||||
apps = data.get('persistent-apps')
|
||||
if not isinstance(apps, list):
|
||||
sys.exit(0)
|
||||
|
||||
changed = False
|
||||
filtered = []
|
||||
for item in apps:
|
||||
try:
|
||||
url = item['tile-data']['file-data']['_CFURLString']
|
||||
except (KeyError, TypeError):
|
||||
filtered.append(item)
|
||||
continue
|
||||
|
||||
if not isinstance(url, str):
|
||||
filtered.append(item)
|
||||
continue
|
||||
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
path = urllib.parse.unquote(parsed.path or '')
|
||||
if not path:
|
||||
filtered.append(item)
|
||||
continue
|
||||
|
||||
candidate = normalise(path)
|
||||
if any(candidate == t or candidate.startswith(t + os.sep) for t in targets):
|
||||
changed = True
|
||||
continue
|
||||
|
||||
filtered.append(item)
|
||||
|
||||
if not changed:
|
||||
sys.exit(0)
|
||||
|
||||
data['persistent-apps'] = filtered
|
||||
with open(plist_path, 'wb') as fh:
|
||||
try:
|
||||
plistlib.dump(data, fh, fmt=plistlib.FMT_BINARY)
|
||||
except Exception:
|
||||
plistlib.dump(data, fh)
|
||||
|
||||
# Restart Dock to apply changes
|
||||
try:
|
||||
subprocess.run(['killall', 'Dock'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
|
||||
except Exception:
|
||||
pass
|
||||
PY
|
||||
}
|
||||
439
lib/core/file_ops.ps1
Normal file
439
lib/core/file_ops.ps1
Normal file
@@ -0,0 +1,439 @@
|
||||
# Mole - Safe File Operations Module
|
||||
# Provides safe file deletion and manipulation functions with protection checks
|
||||
|
||||
#Requires -Version 5.1
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
# Prevent multiple sourcing
|
||||
if ((Get-Variable -Name 'MOLE_FILEOPS_LOADED' -Scope Script -ErrorAction SilentlyContinue) -and $script:MOLE_FILEOPS_LOADED) { return }
|
||||
$script:MOLE_FILEOPS_LOADED = $true
|
||||
|
||||
# Import dependencies
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
. "$scriptDir\base.ps1"
|
||||
. "$scriptDir\log.ps1"
|
||||
|
||||
# ============================================================================
|
||||
# Global State
|
||||
# ============================================================================
|
||||
|
||||
$script:MoleDryRunMode = $env:MOLE_DRY_RUN -eq "1"
|
||||
$script:TotalSizeCleaned = 0
|
||||
$script:FilesCleaned = 0
|
||||
$script:TotalItems = 0
|
||||
|
||||
# ============================================================================
|
||||
# Safety Validation Functions
|
||||
# ============================================================================
|
||||
|
||||
function Test-SafePath {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Validate that a path is safe to operate on
|
||||
.DESCRIPTION
|
||||
Checks against protected paths and whitelist
|
||||
.OUTPUTS
|
||||
$true if safe, $false if protected
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
# Must have a path
|
||||
if ([string]::IsNullOrWhiteSpace($Path)) {
|
||||
Write-Debug "Empty path rejected"
|
||||
return $false
|
||||
}
|
||||
|
||||
# Resolve to full path
|
||||
$fullPath = Resolve-SafePath -Path $Path
|
||||
if (-not $fullPath) {
|
||||
Write-Debug "Could not resolve path: $Path"
|
||||
return $false
|
||||
}
|
||||
|
||||
# Check protected paths
|
||||
if (Test-ProtectedPath -Path $fullPath) {
|
||||
Write-Debug "Protected path rejected: $fullPath"
|
||||
return $false
|
||||
}
|
||||
|
||||
# Check whitelist
|
||||
if (Test-Whitelisted -Path $fullPath) {
|
||||
Write-Debug "Whitelisted path rejected: $fullPath"
|
||||
return $false
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
function Get-PathSize {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get the size of a file or directory in bytes
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
try {
|
||||
if (Test-Path $Path -PathType Container) {
|
||||
$size = (Get-ChildItem -Path $Path -Recurse -Force -ErrorAction SilentlyContinue |
|
||||
Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($null -eq $size) { return 0 }
|
||||
return [long]$size
|
||||
}
|
||||
else {
|
||||
return (Get-Item $Path -Force -ErrorAction SilentlyContinue).Length
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PathSizeKB {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get the size of a file or directory in kilobytes
|
||||
#>
|
||||
param([string]$Path)
|
||||
|
||||
$bytes = Get-PathSize -Path $Path
|
||||
return [Math]::Ceiling($bytes / 1024)
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Safe Removal Functions
|
||||
# ============================================================================
|
||||
|
||||
function Remove-SafeItem {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Safely remove a file or directory with all protection checks
|
||||
.DESCRIPTION
|
||||
This is the main safe deletion function. It:
|
||||
- Validates the path is not protected
|
||||
- Checks against whitelist
|
||||
- Supports dry-run mode
|
||||
- Tracks cleanup statistics
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Path,
|
||||
|
||||
[string]$Description = "",
|
||||
|
||||
[switch]$Force,
|
||||
|
||||
[switch]$Recurse
|
||||
)
|
||||
|
||||
# Validate path safety
|
||||
if (-not (Test-SafePath -Path $Path)) {
|
||||
Write-Debug "Skipping protected/whitelisted path: $Path"
|
||||
return $false
|
||||
}
|
||||
|
||||
# Check if path exists
|
||||
if (-not (Test-Path $Path)) {
|
||||
Write-Debug "Path does not exist: $Path"
|
||||
return $false
|
||||
}
|
||||
|
||||
# Get size before removal
|
||||
$size = Get-PathSize -Path $Path
|
||||
$sizeKB = [Math]::Ceiling($size / 1024)
|
||||
$sizeHuman = Format-ByteSize -Bytes $size
|
||||
|
||||
# Handle dry run
|
||||
if ($script:MoleDryRunMode) {
|
||||
$name = if ($Description) { $Description } else { Split-Path -Leaf $Path }
|
||||
Write-DryRun "$name $($script:Colors.Yellow)($sizeHuman dry)$($script:Colors.NC)"
|
||||
Set-SectionActivity
|
||||
return $true
|
||||
}
|
||||
|
||||
# Perform removal
|
||||
try {
|
||||
$isDirectory = Test-Path $Path -PathType Container
|
||||
|
||||
if ($isDirectory) {
|
||||
Remove-Item -Path $Path -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
else {
|
||||
Remove-Item -Path $Path -Force -ErrorAction Stop
|
||||
}
|
||||
|
||||
# Update statistics
|
||||
$script:TotalSizeCleaned += $sizeKB
|
||||
$script:FilesCleaned++
|
||||
$script:TotalItems++
|
||||
|
||||
# Log success
|
||||
$name = if ($Description) { $Description } else { Split-Path -Leaf $Path }
|
||||
Write-Success "$name $($script:Colors.Green)($sizeHuman)$($script:Colors.NC)"
|
||||
Set-SectionActivity
|
||||
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-Debug "Failed to remove $Path : $_"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-SafeItems {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Safely remove multiple items with a collective description
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string[]]$Paths,
|
||||
|
||||
[string]$Description = "Items"
|
||||
)
|
||||
|
||||
$totalSize = 0
|
||||
$removedCount = 0
|
||||
$failedCount = 0
|
||||
|
||||
foreach ($path in $Paths) {
|
||||
if (-not (Test-SafePath -Path $path)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (-not (Test-Path $path)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$size = Get-PathSize -Path $path
|
||||
|
||||
if ($script:MoleDryRunMode) {
|
||||
$totalSize += $size
|
||||
$removedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
$isDirectory = Test-Path $path -PathType Container
|
||||
if ($isDirectory) {
|
||||
Remove-Item -Path $path -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
else {
|
||||
Remove-Item -Path $path -Force -ErrorAction Stop
|
||||
}
|
||||
$totalSize += $size
|
||||
$removedCount++
|
||||
}
|
||||
catch {
|
||||
$failedCount++
|
||||
Write-Debug "Failed to remove: $path - $_"
|
||||
}
|
||||
}
|
||||
|
||||
if ($removedCount -gt 0) {
|
||||
$sizeKB = [Math]::Ceiling($totalSize / 1024)
|
||||
$sizeHuman = Format-ByteSize -Bytes $totalSize
|
||||
|
||||
if ($script:MoleDryRunMode) {
|
||||
Write-DryRun "$Description $($script:Colors.Yellow)($removedCount items, $sizeHuman dry)$($script:Colors.NC)"
|
||||
}
|
||||
else {
|
||||
$script:TotalSizeCleaned += $sizeKB
|
||||
$script:FilesCleaned += $removedCount
|
||||
$script:TotalItems++
|
||||
Write-Success "$Description $($script:Colors.Green)($removedCount items, $sizeHuman)$($script:Colors.NC)"
|
||||
}
|
||||
Set-SectionActivity
|
||||
}
|
||||
|
||||
return @{
|
||||
Removed = $removedCount
|
||||
Failed = $failedCount
|
||||
Size = $totalSize
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Pattern-Based Cleanup Functions
|
||||
# ============================================================================
|
||||
|
||||
function Remove-OldFiles {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Remove files older than specified days
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Path,
|
||||
|
||||
[int]$DaysOld = 7,
|
||||
|
||||
[string]$Filter = "*",
|
||||
|
||||
[string]$Description = "Old files"
|
||||
)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
return @{ Removed = 0; Size = 0 }
|
||||
}
|
||||
|
||||
$cutoffDate = (Get-Date).AddDays(-$DaysOld)
|
||||
|
||||
$oldFiles = Get-ChildItem -Path $Path -Filter $Filter -File -Force -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.LastWriteTime -lt $cutoffDate }
|
||||
|
||||
if ($oldFiles) {
|
||||
$paths = $oldFiles | ForEach-Object { $_.FullName }
|
||||
return Remove-SafeItems -Paths $paths -Description "$Description (>${DaysOld}d old)"
|
||||
}
|
||||
|
||||
return @{ Removed = 0; Size = 0 }
|
||||
}
|
||||
|
||||
function Remove-EmptyDirectories {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Remove empty directories recursively
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Path,
|
||||
|
||||
[string]$Description = "Empty directories"
|
||||
)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
return @{ Removed = 0 }
|
||||
}
|
||||
|
||||
$removedCount = 0
|
||||
$maxIterations = 5
|
||||
|
||||
for ($i = 0; $i -lt $maxIterations; $i++) {
|
||||
$emptyDirs = Get-ChildItem -Path $Path -Directory -Recurse -Force -ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
(Get-ChildItem -Path $_.FullName -Force -ErrorAction SilentlyContinue | Measure-Object).Count -eq 0
|
||||
}
|
||||
|
||||
if (-not $emptyDirs -or $emptyDirs.Count -eq 0) {
|
||||
break
|
||||
}
|
||||
|
||||
foreach ($dir in $emptyDirs) {
|
||||
if (Test-SafePath -Path $dir.FullName) {
|
||||
if (-not $script:MoleDryRunMode) {
|
||||
try {
|
||||
Remove-Item -Path $dir.FullName -Force -ErrorAction Stop
|
||||
$removedCount++
|
||||
}
|
||||
catch {
|
||||
Write-Debug "Could not remove empty dir: $($dir.FullName)"
|
||||
}
|
||||
}
|
||||
else {
|
||||
$removedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($removedCount -gt 0) {
|
||||
if ($script:MoleDryRunMode) {
|
||||
Write-DryRun "$Description $($script:Colors.Yellow)($removedCount dirs dry)$($script:Colors.NC)"
|
||||
}
|
||||
else {
|
||||
Write-Success "$Description $($script:Colors.Green)($removedCount dirs)$($script:Colors.NC)"
|
||||
}
|
||||
Set-SectionActivity
|
||||
}
|
||||
|
||||
return @{ Removed = $removedCount }
|
||||
}
|
||||
|
||||
function Clear-DirectoryContents {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clear all contents of a directory but keep the directory itself
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Path,
|
||||
|
||||
[string]$Description = ""
|
||||
)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
return @{ Removed = 0; Size = 0 }
|
||||
}
|
||||
|
||||
if (-not (Test-SafePath -Path $Path)) {
|
||||
return @{ Removed = 0; Size = 0 }
|
||||
}
|
||||
|
||||
$items = Get-ChildItem -Path $Path -Force -ErrorAction SilentlyContinue
|
||||
if ($items) {
|
||||
$paths = $items | ForEach-Object { $_.FullName }
|
||||
$desc = if ($Description) { $Description } else { Split-Path -Leaf $Path }
|
||||
return Remove-SafeItems -Paths $paths -Description $desc
|
||||
}
|
||||
|
||||
return @{ Removed = 0; Size = 0 }
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Statistics Functions
|
||||
# ============================================================================
|
||||
|
||||
function Get-CleanupStats {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get current cleanup statistics
|
||||
#>
|
||||
return @{
|
||||
TotalSizeKB = $script:TotalSizeCleaned
|
||||
TotalSizeHuman = Format-ByteSize -Bytes ($script:TotalSizeCleaned * 1024)
|
||||
FilesCleaned = $script:FilesCleaned
|
||||
TotalItems = $script:TotalItems
|
||||
}
|
||||
}
|
||||
|
||||
function Reset-CleanupStats {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Reset cleanup statistics
|
||||
#>
|
||||
$script:TotalSizeCleaned = 0
|
||||
$script:FilesCleaned = 0
|
||||
$script:TotalItems = 0
|
||||
}
|
||||
|
||||
function Set-DryRunMode {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Enable or disable dry-run mode
|
||||
#>
|
||||
param([bool]$Enabled)
|
||||
$script:MoleDryRunMode = $Enabled
|
||||
}
|
||||
|
||||
function Test-DryRunMode {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Check if dry-run mode is enabled
|
||||
#>
|
||||
return $script:MoleDryRunMode
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Exports (functions are available via dot-sourcing)
|
||||
# ============================================================================
|
||||
# Functions: Test-SafePath, Get-PathSize, Remove-SafeItem, etc.
|
||||
@@ -1,351 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Mole - File Operations
|
||||
# Safe file and directory manipulation with validation
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Prevent multiple sourcing
|
||||
if [[ -n "${MOLE_FILE_OPS_LOADED:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
readonly MOLE_FILE_OPS_LOADED=1
|
||||
|
||||
# Ensure dependencies are loaded
|
||||
_MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
if [[ -z "${MOLE_BASE_LOADED:-}" ]]; then
|
||||
# shellcheck source=lib/core/base.sh
|
||||
source "$_MOLE_CORE_DIR/base.sh"
|
||||
fi
|
||||
if [[ -z "${MOLE_LOG_LOADED:-}" ]]; then
|
||||
# shellcheck source=lib/core/log.sh
|
||||
source "$_MOLE_CORE_DIR/log.sh"
|
||||
fi
|
||||
if [[ -z "${MOLE_TIMEOUT_LOADED:-}" ]]; then
|
||||
# shellcheck source=lib/core/timeout.sh
|
||||
source "$_MOLE_CORE_DIR/timeout.sh"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Path Validation
|
||||
# ============================================================================
|
||||
|
||||
# Validate path for deletion (absolute, no traversal, not system dir)
|
||||
validate_path_for_deletion() {
|
||||
local path="$1"
|
||||
|
||||
# Check path is not empty
|
||||
if [[ -z "$path" ]]; then
|
||||
log_error "Path validation failed: empty path"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check path is absolute
|
||||
if [[ "$path" != /* ]]; then
|
||||
log_error "Path validation failed: path must be absolute: $path"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check for path traversal attempts
|
||||
# Only reject .. when it appears as a complete path component (/../ or /.. or ../)
|
||||
# This allows legitimate directory names containing .. (e.g., Firefox's "name..files")
|
||||
if [[ "$path" =~ (^|/)\.\.(\/|$) ]]; then
|
||||
log_error "Path validation failed: path traversal not allowed: $path"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check path doesn't contain dangerous characters
|
||||
if [[ "$path" =~ [[:cntrl:]] ]] || [[ "$path" =~ $'\n' ]]; then
|
||||
log_error "Path validation failed: contains control characters: $path"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Allow deletion of coresymbolicationd cache (safe system cache that can be rebuilt)
|
||||
case "$path" in
|
||||
/System/Library/Caches/com.apple.coresymbolicationd/data | /System/Library/Caches/com.apple.coresymbolicationd/data/*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# Check path isn't critical system directory
|
||||
case "$path" in
|
||||
/ | /bin | /sbin | /usr | /usr/bin | /usr/sbin | /etc | /var | /System | /System/* | /Library/Extensions)
|
||||
log_error "Path validation failed: critical system directory: $path"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Safe Removal Operations
|
||||
# ============================================================================
|
||||
|
||||
# Safe wrapper around rm -rf with validation
|
||||
safe_remove() {
|
||||
local path="$1"
|
||||
local silent="${2:-false}"
|
||||
|
||||
# Validate path
|
||||
if ! validate_path_for_deletion "$path"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if path exists
|
||||
if [[ ! -e "$path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Dry-run mode: log but don't delete
|
||||
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
|
||||
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
||||
local file_type="file"
|
||||
[[ -d "$path" ]] && file_type="directory"
|
||||
[[ -L "$path" ]] && file_type="symlink"
|
||||
|
||||
local file_size=""
|
||||
local file_age=""
|
||||
|
||||
if [[ -e "$path" ]]; then
|
||||
local size_kb
|
||||
size_kb=$(get_path_size_kb "$path" 2> /dev/null || echo "0")
|
||||
if [[ "$size_kb" -gt 0 ]]; then
|
||||
file_size=$(bytes_to_human "$((size_kb * 1024))")
|
||||
fi
|
||||
|
||||
if [[ -f "$path" || -d "$path" ]] && ! [[ -L "$path" ]]; then
|
||||
local mod_time
|
||||
mod_time=$(stat -f%m "$path" 2> /dev/null || echo "0")
|
||||
local now
|
||||
now=$(date +%s 2> /dev/null || echo "0")
|
||||
if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then
|
||||
file_age=$(((now - mod_time) / 86400))
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
debug_file_action "[DRY RUN] Would remove" "$path" "$file_size" "$file_age"
|
||||
else
|
||||
debug_log "[DRY RUN] Would remove: $path"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
debug_log "Removing: $path"
|
||||
|
||||
# Perform the deletion
|
||||
# Use || to capture the exit code so set -e won't abort on rm failures
|
||||
local error_msg
|
||||
local rm_exit=0
|
||||
error_msg=$(rm -rf "$path" 2>&1) || rm_exit=$? # safe_remove
|
||||
|
||||
if [[ $rm_exit -eq 0 ]]; then
|
||||
return 0
|
||||
else
|
||||
# Check if it's a permission error
|
||||
if [[ "$error_msg" == *"Permission denied"* ]] || [[ "$error_msg" == *"Operation not permitted"* ]]; then
|
||||
MOLE_PERMISSION_DENIED_COUNT=${MOLE_PERMISSION_DENIED_COUNT:-0}
|
||||
MOLE_PERMISSION_DENIED_COUNT=$((MOLE_PERMISSION_DENIED_COUNT + 1))
|
||||
export MOLE_PERMISSION_DENIED_COUNT
|
||||
debug_log "Permission denied: $path (may need Full Disk Access)"
|
||||
else
|
||||
[[ "$silent" != "true" ]] && log_error "Failed to remove: $path"
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Safe sudo removal with symlink protection
|
||||
safe_sudo_remove() {
|
||||
local path="$1"
|
||||
|
||||
# Validate path
|
||||
if ! validate_path_for_deletion "$path"; then
|
||||
log_error "Path validation failed for sudo remove: $path"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if path exists
|
||||
if [[ ! -e "$path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Additional check: reject symlinks for sudo operations
|
||||
if [[ -L "$path" ]]; then
|
||||
log_error "Refusing to sudo remove symlink: $path"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Dry-run mode: log but don't delete
|
||||
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
|
||||
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
||||
local file_type="file"
|
||||
[[ -d "$path" ]] && file_type="directory"
|
||||
|
||||
local file_size=""
|
||||
local file_age=""
|
||||
|
||||
if sudo test -e "$path" 2> /dev/null; then
|
||||
local size_kb
|
||||
size_kb=$(sudo du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0")
|
||||
if [[ "$size_kb" -gt 0 ]]; then
|
||||
file_size=$(bytes_to_human "$((size_kb * 1024))")
|
||||
fi
|
||||
|
||||
if sudo test -f "$path" 2> /dev/null || sudo test -d "$path" 2> /dev/null; then
|
||||
local mod_time
|
||||
mod_time=$(sudo stat -f%m "$path" 2> /dev/null || echo "0")
|
||||
local now
|
||||
now=$(date +%s 2> /dev/null || echo "0")
|
||||
if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then
|
||||
file_age=$(((now - mod_time) / 86400))
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
debug_file_action "[DRY RUN] Would remove (sudo)" "$path" "$file_size" "$file_age"
|
||||
else
|
||||
debug_log "[DRY RUN] Would remove (sudo): $path"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
debug_log "Removing (sudo): $path"
|
||||
|
||||
# Perform the deletion
|
||||
if sudo rm -rf "$path" 2> /dev/null; then # SAFE: safe_sudo_remove implementation
|
||||
return 0
|
||||
else
|
||||
log_error "Failed to remove (sudo): $path"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Safe Find and Delete Operations
|
||||
# ============================================================================
|
||||
|
||||
# Safe file discovery and deletion with depth and age limits
|
||||
safe_find_delete() {
|
||||
local base_dir="$1"
|
||||
local pattern="$2"
|
||||
local age_days="${3:-7}"
|
||||
local type_filter="${4:-f}"
|
||||
|
||||
# Validate base directory exists and is not a symlink
|
||||
if [[ ! -d "$base_dir" ]]; then
|
||||
log_error "Directory does not exist: $base_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -L "$base_dir" ]]; then
|
||||
log_error "Refusing to search symlinked directory: $base_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Validate type filter
|
||||
if [[ "$type_filter" != "f" && "$type_filter" != "d" ]]; then
|
||||
log_error "Invalid type filter: $type_filter (must be 'f' or 'd')"
|
||||
return 1
|
||||
fi
|
||||
|
||||
debug_log "Finding in $base_dir: $pattern (age: ${age_days}d, type: $type_filter)"
|
||||
|
||||
local find_args=("-maxdepth" "5" "-name" "$pattern" "-type" "$type_filter")
|
||||
if [[ "$age_days" -gt 0 ]]; then
|
||||
find_args+=("-mtime" "+$age_days")
|
||||
fi
|
||||
|
||||
# Iterate results to respect should_protect_path
|
||||
while IFS= read -r -d '' match; do
|
||||
if should_protect_path "$match"; then
|
||||
continue
|
||||
fi
|
||||
safe_remove "$match" true || true
|
||||
done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Safe sudo discovery and deletion
|
||||
safe_sudo_find_delete() {
|
||||
local base_dir="$1"
|
||||
local pattern="$2"
|
||||
local age_days="${3:-7}"
|
||||
local type_filter="${4:-f}"
|
||||
|
||||
# Validate base directory (use sudo for permission-restricted dirs)
|
||||
if ! sudo test -d "$base_dir" 2> /dev/null; then
|
||||
debug_log "Directory does not exist (skipping): $base_dir"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if sudo test -L "$base_dir" 2> /dev/null; then
|
||||
log_error "Refusing to search symlinked directory: $base_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Validate type filter
|
||||
if [[ "$type_filter" != "f" && "$type_filter" != "d" ]]; then
|
||||
log_error "Invalid type filter: $type_filter (must be 'f' or 'd')"
|
||||
return 1
|
||||
fi
|
||||
|
||||
debug_log "Finding (sudo) in $base_dir: $pattern (age: ${age_days}d, type: $type_filter)"
|
||||
|
||||
local find_args=("-maxdepth" "5" "-name" "$pattern" "-type" "$type_filter")
|
||||
if [[ "$age_days" -gt 0 ]]; then
|
||||
find_args+=("-mtime" "+$age_days")
|
||||
fi
|
||||
|
||||
# Iterate results to respect should_protect_path
|
||||
while IFS= read -r -d '' match; do
|
||||
if should_protect_path "$match"; then
|
||||
continue
|
||||
fi
|
||||
safe_sudo_remove "$match" || true
|
||||
done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Size Calculation
|
||||
# ============================================================================
|
||||
|
||||
# Get path size in KB (returns 0 if not found)
|
||||
get_path_size_kb() {
|
||||
local path="$1"
|
||||
[[ -z "$path" || ! -e "$path" ]] && {
|
||||
echo "0"
|
||||
return
|
||||
}
|
||||
# Direct execution without timeout overhead - critical for performance in loops
|
||||
# Use || echo 0 to ensure failure in du (e.g. permission error) doesn't exit script under set -e
|
||||
# Pipefail would normally cause the pipeline to fail if du fails, but || handle catches it.
|
||||
local size
|
||||
size=$(command du -sk "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true)
|
||||
|
||||
# Ensure size is a valid number (fix for non-numeric du output)
|
||||
if [[ "$size" =~ ^[0-9]+$ ]]; then
|
||||
echo "$size"
|
||||
else
|
||||
echo "0"
|
||||
fi
|
||||
}
|
||||
|
||||
# Calculate total size for multiple paths
|
||||
calculate_total_size() {
|
||||
local files="$1"
|
||||
local total_kb=0
|
||||
|
||||
while IFS= read -r file; do
|
||||
if [[ -n "$file" && -e "$file" ]]; then
|
||||
local size_kb
|
||||
size_kb=$(get_path_size_kb "$file")
|
||||
((total_kb += size_kb))
|
||||
fi
|
||||
done <<< "$files"
|
||||
|
||||
echo "$total_kb"
|
||||
}
|
||||
285
lib/core/log.ps1
Normal file
285
lib/core/log.ps1
Normal file
@@ -0,0 +1,285 @@
|
||||
# Mole - Logging Module
|
||||
# Provides consistent logging functions with colors and icons
|
||||
|
||||
#Requires -Version 5.1
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
# Prevent multiple sourcing
|
||||
if ((Get-Variable -Name 'MOLE_LOG_LOADED' -Scope Script -ErrorAction SilentlyContinue) -and $script:MOLE_LOG_LOADED) { return }
|
||||
$script:MOLE_LOG_LOADED = $true
|
||||
|
||||
# Import base module
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
. "$scriptDir\base.ps1"
|
||||
|
||||
# ============================================================================
|
||||
# Log Configuration
|
||||
# ============================================================================
|
||||
|
||||
$script:LogConfig = @{
|
||||
DebugEnabled = $env:MOLE_DEBUG -eq "1"
|
||||
LogFile = $null
|
||||
Verbose = $false
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Core Logging Functions
|
||||
# ============================================================================
|
||||
|
||||
function Write-LogMessage {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Internal function to write formatted log message
|
||||
#>
|
||||
param(
|
||||
[string]$Message,
|
||||
[string]$Level,
|
||||
[string]$Color,
|
||||
[string]$Icon
|
||||
)
|
||||
|
||||
$timestamp = Get-Date -Format "HH:mm:ss"
|
||||
$colorCode = $script:Colors[$Color]
|
||||
$nc = $script:Colors.NC
|
||||
|
||||
$formattedIcon = if ($Icon) { "$Icon " } else { "" }
|
||||
$output = " ${colorCode}${formattedIcon}${nc}${Message}"
|
||||
|
||||
Write-Host $output
|
||||
|
||||
# Also write to log file if configured
|
||||
if ($script:LogConfig.LogFile) {
|
||||
"$timestamp [$Level] $Message" | Out-File -Append -FilePath $script:LogConfig.LogFile -Encoding UTF8
|
||||
}
|
||||
}
|
||||
|
||||
function Write-Info {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Write an informational message
|
||||
#>
|
||||
param([string]$Message)
|
||||
Write-LogMessage -Message $Message -Level "INFO" -Color "Cyan" -Icon $script:Icons.List
|
||||
}
|
||||
|
||||
function Write-Success {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Write a success message
|
||||
#>
|
||||
param([string]$Message)
|
||||
Write-LogMessage -Message $Message -Level "SUCCESS" -Color "Green" -Icon $script:Icons.Success
|
||||
}
|
||||
|
||||
|
||||
function Write-MoleWarning {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Write a warning message
|
||||
#>
|
||||
param([string]$Message)
|
||||
Write-LogMessage -Message $Message -Level "WARN" -Color "Yellow" -Icon $script:Icons.Warning
|
||||
}
|
||||
|
||||
function Write-MoleError {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Write an error message
|
||||
#>
|
||||
param([string]$Message)
|
||||
Write-LogMessage -Message $Message -Level "ERROR" -Color "Red" -Icon $script:Icons.Error
|
||||
}
|
||||
|
||||
|
||||
function Write-Debug {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Write a debug message (only if debug mode is enabled)
|
||||
#>
|
||||
param([string]$Message)
|
||||
|
||||
if ($script:LogConfig.DebugEnabled) {
|
||||
$gray = $script:Colors.Gray
|
||||
$nc = $script:Colors.NC
|
||||
Write-Host " ${gray}[DEBUG] $Message${nc}"
|
||||
}
|
||||
}
|
||||
|
||||
function Write-DryRun {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Write a dry-run message (action that would be taken)
|
||||
#>
|
||||
param([string]$Message)
|
||||
Write-LogMessage -Message $Message -Level "DRYRUN" -Color "Yellow" -Icon $script:Icons.DryRun
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Section Functions (for progress indication)
|
||||
# ============================================================================
|
||||
|
||||
$script:CurrentSection = @{
|
||||
Active = $false
|
||||
Activity = $false
|
||||
Name = ""
|
||||
}
|
||||
|
||||
function Start-Section {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Start a new section with a title
|
||||
#>
|
||||
param([string]$Title)
|
||||
|
||||
$script:CurrentSection.Active = $true
|
||||
$script:CurrentSection.Activity = $false
|
||||
$script:CurrentSection.Name = $Title
|
||||
|
||||
$purple = $script:Colors.PurpleBold
|
||||
$nc = $script:Colors.NC
|
||||
$arrow = $script:Icons.Arrow
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "${purple}${arrow} ${Title}${nc}"
|
||||
}
|
||||
|
||||
function Stop-Section {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
End the current section
|
||||
#>
|
||||
if ($script:CurrentSection.Active -and -not $script:CurrentSection.Activity) {
|
||||
Write-Success "Nothing to tidy"
|
||||
}
|
||||
$script:CurrentSection.Active = $false
|
||||
}
|
||||
|
||||
function Set-SectionActivity {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Mark that activity occurred in current section
|
||||
#>
|
||||
if ($script:CurrentSection.Active) {
|
||||
$script:CurrentSection.Activity = $true
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Progress Spinner
|
||||
# ============================================================================
|
||||
|
||||
$script:SpinnerFrames = @('|', '/', '-', '\')
|
||||
$script:SpinnerIndex = 0
|
||||
$script:SpinnerJob = $null
|
||||
|
||||
function Start-Spinner {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Start an inline spinner with message
|
||||
#>
|
||||
param([string]$Message = "Working...")
|
||||
|
||||
$script:SpinnerIndex = 0
|
||||
$gray = $script:Colors.Gray
|
||||
$nc = $script:Colors.NC
|
||||
|
||||
Write-Host -NoNewline " ${gray}$($script:SpinnerFrames[0]) $Message${nc}"
|
||||
}
|
||||
|
||||
function Update-Spinner {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Update the spinner animation
|
||||
#>
|
||||
param([string]$Message)
|
||||
|
||||
$script:SpinnerIndex = ($script:SpinnerIndex + 1) % $script:SpinnerFrames.Count
|
||||
$frame = $script:SpinnerFrames[$script:SpinnerIndex]
|
||||
$gray = $script:Colors.Gray
|
||||
$nc = $script:Colors.NC
|
||||
|
||||
# Move cursor to beginning of line and clear
|
||||
Write-Host -NoNewline "`r ${gray}$frame $Message${nc} "
|
||||
}
|
||||
|
||||
function Stop-Spinner {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Stop the spinner and clear the line
|
||||
#>
|
||||
Write-Host -NoNewline "`r `r"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Progress Bar
|
||||
# ============================================================================
|
||||
|
||||
function Write-Progress {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Write a progress bar
|
||||
#>
|
||||
param(
|
||||
[int]$Current,
|
||||
[int]$Total,
|
||||
[string]$Message = "",
|
||||
[int]$Width = 30
|
||||
)
|
||||
|
||||
$percent = if ($Total -gt 0) { [Math]::Round(($Current / $Total) * 100) } else { 0 }
|
||||
$filled = [Math]::Round(($Width * $Current) / [Math]::Max($Total, 1))
|
||||
$empty = $Width - $filled
|
||||
|
||||
$bar = ("[" + ("=" * $filled) + (" " * $empty) + "]")
|
||||
$cyan = $script:Colors.Cyan
|
||||
$nc = $script:Colors.NC
|
||||
|
||||
Write-Host -NoNewline "`r ${cyan}$bar${nc} ${percent}% $Message "
|
||||
}
|
||||
|
||||
function Complete-Progress {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clear the progress bar line
|
||||
#>
|
||||
Write-Host -NoNewline "`r" + (" " * 80) + "`r"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Log File Management
|
||||
# ============================================================================
|
||||
|
||||
function Set-LogFile {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Set a log file for persistent logging
|
||||
#>
|
||||
param([string]$Path)
|
||||
|
||||
$script:LogConfig.LogFile = $Path
|
||||
$dir = Split-Path -Parent $Path
|
||||
if ($dir -and -not (Test-Path $dir)) {
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
function Enable-DebugMode {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Enable debug logging
|
||||
#>
|
||||
$script:LogConfig.DebugEnabled = $true
|
||||
}
|
||||
|
||||
function Disable-DebugMode {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Disable debug logging
|
||||
#>
|
||||
$script:LogConfig.DebugEnabled = $false
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Exports (functions are available via dot-sourcing)
|
||||
# ============================================================================
|
||||
# Functions: Write-Info, Write-Success, Write-Warning, Write-Error, etc.
|
||||
291
lib/core/log.sh
291
lib/core/log.sh
@@ -1,291 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Mole - Logging System
|
||||
# Centralized logging with rotation support
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Prevent multiple sourcing
|
||||
if [[ -n "${MOLE_LOG_LOADED:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
readonly MOLE_LOG_LOADED=1
|
||||
|
||||
# Ensure base.sh is loaded for colors and icons
|
||||
if [[ -z "${MOLE_BASE_LOADED:-}" ]]; then
|
||||
_MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/core/base.sh
|
||||
source "$_MOLE_CORE_DIR/base.sh"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Logging Configuration
|
||||
# ============================================================================
|
||||
|
||||
readonly LOG_FILE="${HOME}/.config/mole/mole.log"
|
||||
readonly DEBUG_LOG_FILE="${HOME}/.config/mole/mole_debug_session.log"
|
||||
readonly LOG_MAX_SIZE_DEFAULT=1048576 # 1MB
|
||||
|
||||
# Ensure log directory and file exist with correct ownership
|
||||
ensure_user_file "$LOG_FILE"
|
||||
|
||||
# ============================================================================
|
||||
# Log Rotation
|
||||
# ============================================================================
|
||||
|
||||
# Rotate log file if it exceeds maximum size
|
||||
rotate_log_once() {
|
||||
# Skip if already checked this session
|
||||
[[ -n "${MOLE_LOG_ROTATED:-}" ]] && return 0
|
||||
export MOLE_LOG_ROTATED=1
|
||||
|
||||
local max_size="$LOG_MAX_SIZE_DEFAULT"
|
||||
if [[ -f "$LOG_FILE" ]] && [[ $(get_file_size "$LOG_FILE") -gt "$max_size" ]]; then
|
||||
mv "$LOG_FILE" "${LOG_FILE}.old" 2> /dev/null || true
|
||||
ensure_user_file "$LOG_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Logging Functions
|
||||
# ============================================================================
|
||||
|
||||
# Log informational message
|
||||
log_info() {
|
||||
echo -e "${BLUE}$1${NC}"
|
||||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
echo "[$timestamp] INFO: $1" >> "$LOG_FILE" 2> /dev/null || true
|
||||
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
||||
echo "[$timestamp] INFO: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Log success message
|
||||
log_success() {
|
||||
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1"
|
||||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
echo "[$timestamp] SUCCESS: $1" >> "$LOG_FILE" 2> /dev/null || true
|
||||
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
||||
echo "[$timestamp] SUCCESS: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Log warning message
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}$1${NC}"
|
||||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
echo "[$timestamp] WARNING: $1" >> "$LOG_FILE" 2> /dev/null || true
|
||||
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
||||
echo "[$timestamp] WARNING: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Log error message
|
||||
log_error() {
|
||||
echo -e "${YELLOW}${ICON_ERROR}${NC} $1" >&2
|
||||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
echo "[$timestamp] ERROR: $1" >> "$LOG_FILE" 2> /dev/null || true
|
||||
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
||||
echo "[$timestamp] ERROR: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Debug logging (active when MO_DEBUG=1)
|
||||
debug_log() {
|
||||
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
||||
echo -e "${GRAY}[DEBUG]${NC} $*" >&2
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] DEBUG: $*" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Enhanced debug logging for operations
|
||||
debug_operation_start() {
|
||||
local operation_name="$1"
|
||||
local operation_desc="${2:-}"
|
||||
|
||||
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
||||
# Output to stderr for immediate feedback
|
||||
echo -e "${GRAY}[DEBUG] === $operation_name ===${NC}" >&2
|
||||
[[ -n "$operation_desc" ]] && echo -e "${GRAY}[DEBUG] $operation_desc${NC}" >&2
|
||||
|
||||
# Also log to file
|
||||
{
|
||||
echo ""
|
||||
echo "=== $operation_name ==="
|
||||
[[ -n "$operation_desc" ]] && echo "Description: $operation_desc"
|
||||
} >> "$DEBUG_LOG_FILE" 2> /dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Log detailed operation information
|
||||
debug_operation_detail() {
|
||||
local detail_type="$1" # e.g., "Method", "Target", "Expected Outcome"
|
||||
local detail_value="$2"
|
||||
|
||||
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
||||
# Output to stderr
|
||||
echo -e "${GRAY}[DEBUG] $detail_type: $detail_value${NC}" >&2
|
||||
|
||||
# Also log to file
|
||||
echo "$detail_type: $detail_value" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Log individual file action with metadata
|
||||
debug_file_action() {
|
||||
local action="$1" # e.g., "Would remove", "Removing"
|
||||
local file_path="$2"
|
||||
local file_size="${3:-}"
|
||||
local file_age="${4:-}"
|
||||
|
||||
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
||||
local msg=" - $file_path"
|
||||
[[ -n "$file_size" ]] && msg+=" ($file_size"
|
||||
[[ -n "$file_age" ]] && msg+=", ${file_age} days old"
|
||||
[[ -n "$file_size" ]] && msg+=")"
|
||||
|
||||
# Output to stderr
|
||||
echo -e "${GRAY}[DEBUG] $action: $msg${NC}" >&2
|
||||
|
||||
# Also log to file
|
||||
echo "$action: $msg" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Log risk level for operations
|
||||
debug_risk_level() {
|
||||
local risk_level="$1" # LOW, MEDIUM, HIGH
|
||||
local reason="$2"
|
||||
|
||||
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
||||
local color="$GRAY"
|
||||
case "$risk_level" in
|
||||
LOW) color="$GREEN" ;;
|
||||
MEDIUM) color="$YELLOW" ;;
|
||||
HIGH) color="$RED" ;;
|
||||
esac
|
||||
|
||||
# Output to stderr with color
|
||||
echo -e "${GRAY}[DEBUG] Risk Level: ${color}${risk_level}${GRAY} ($reason)${NC}" >&2
|
||||
|
||||
# Also log to file
|
||||
echo "Risk Level: $risk_level ($reason)" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Log system information for debugging
|
||||
log_system_info() {
|
||||
# Only allow once per session
|
||||
[[ -n "${MOLE_SYS_INFO_LOGGED:-}" ]] && return 0
|
||||
export MOLE_SYS_INFO_LOGGED=1
|
||||
|
||||
# Reset debug log file for this new session
|
||||
ensure_user_file "$DEBUG_LOG_FILE"
|
||||
: > "$DEBUG_LOG_FILE"
|
||||
|
||||
# Start block in debug log file
|
||||
{
|
||||
echo "----------------------------------------------------------------------"
|
||||
echo "Mole Debug Session - $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "----------------------------------------------------------------------"
|
||||
echo "User: $USER"
|
||||
echo "Hostname: $(hostname)"
|
||||
echo "Architecture: $(uname -m)"
|
||||
echo "Kernel: $(uname -r)"
|
||||
if command -v sw_vers > /dev/null; then
|
||||
echo "macOS: $(sw_vers -productVersion) ($(sw_vers -buildVersion))"
|
||||
fi
|
||||
echo "Shell: ${SHELL:-unknown} (${TERM:-unknown})"
|
||||
|
||||
# Check sudo status non-interactively
|
||||
if sudo -n true 2> /dev/null; then
|
||||
echo "Sudo Access: Active"
|
||||
else
|
||||
echo "Sudo Access: Required"
|
||||
fi
|
||||
echo "----------------------------------------------------------------------"
|
||||
} >> "$DEBUG_LOG_FILE" 2> /dev/null || true
|
||||
|
||||
# Notification to stderr
|
||||
echo -e "${GRAY}[DEBUG] Debug logging enabled. Session log: $DEBUG_LOG_FILE${NC}" >&2
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Command Execution Wrappers
|
||||
# ============================================================================
|
||||
|
||||
# Run command silently (ignore errors)
|
||||
run_silent() {
|
||||
"$@" > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# Run command with error logging
|
||||
run_logged() {
|
||||
local cmd="$1"
|
||||
# Log to main file, and also to debug file if enabled
|
||||
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
||||
if ! "$@" 2>&1 | tee -a "$LOG_FILE" | tee -a "$DEBUG_LOG_FILE" > /dev/null; then
|
||||
log_warning "Command failed: $cmd"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
if ! "$@" 2>&1 | tee -a "$LOG_FILE" > /dev/null; then
|
||||
log_warning "Command failed: $cmd"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Formatted Output
|
||||
# ============================================================================
|
||||
|
||||
# Print formatted summary block
|
||||
print_summary_block() {
|
||||
local heading=""
|
||||
local -a details=()
|
||||
local saw_heading=false
|
||||
|
||||
# Parse arguments
|
||||
for arg in "$@"; do
|
||||
if [[ "$saw_heading" == "false" ]]; then
|
||||
saw_heading=true
|
||||
heading="$arg"
|
||||
else
|
||||
details+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
local divider="======================================================================"
|
||||
|
||||
# Print with dividers
|
||||
echo ""
|
||||
echo "$divider"
|
||||
if [[ -n "$heading" ]]; then
|
||||
echo -e "${BLUE}${heading}${NC}"
|
||||
fi
|
||||
|
||||
# Print details
|
||||
for detail in "${details[@]}"; do
|
||||
[[ -z "$detail" ]] && continue
|
||||
echo -e "${detail}"
|
||||
done
|
||||
echo "$divider"
|
||||
|
||||
# If debug mode is on, remind user about the log file location
|
||||
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
||||
echo -e "${GRAY}Debug session log saved to:${NC} ${DEBUG_LOG_FILE}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Initialize Logging
|
||||
# ============================================================================
|
||||
|
||||
# Perform log rotation check on module load
|
||||
rotate_log_once
|
||||
|
||||
# If debug mode is enabled, log system info immediately
|
||||
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
||||
log_system_info
|
||||
fi
|
||||
319
lib/core/sudo.sh
319
lib/core/sudo.sh
@@ -1,319 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Sudo Session Manager
|
||||
# Unified sudo authentication and keepalive management
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ============================================================================
|
||||
# Touch ID and Clamshell Detection
|
||||
# ============================================================================
|
||||
|
||||
check_touchid_support() {
|
||||
# Check sudo_local first (Sonoma+)
|
||||
if [[ -f /etc/pam.d/sudo_local ]]; then
|
||||
grep -q "pam_tid.so" /etc/pam.d/sudo_local 2> /dev/null
|
||||
return $?
|
||||
fi
|
||||
|
||||
# Fallback to checking sudo directly
|
||||
if [[ -f /etc/pam.d/sudo ]]; then
|
||||
grep -q "pam_tid.so" /etc/pam.d/sudo 2> /dev/null
|
||||
return $?
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Detect clamshell mode (lid closed)
|
||||
is_clamshell_mode() {
|
||||
# ioreg is missing (not macOS) -> treat as lid open
|
||||
if ! command -v ioreg > /dev/null 2>&1; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if lid is closed; ignore pipeline failures so set -e doesn't exit
|
||||
local clamshell_state=""
|
||||
clamshell_state=$( (ioreg -r -k AppleClamshellState -d 4 2> /dev/null |
|
||||
grep "AppleClamshellState" |
|
||||
head -1) || true)
|
||||
|
||||
if [[ "$clamshell_state" =~ \"AppleClamshellState\"\ =\ Yes ]]; then
|
||||
return 0 # Lid is closed
|
||||
fi
|
||||
return 1 # Lid is open
|
||||
}
|
||||
|
||||
_request_password() {
|
||||
local tty_path="$1"
|
||||
local attempts=0
|
||||
local show_hint=true
|
||||
|
||||
# Extra safety: ensure sudo cache is cleared before password input
|
||||
sudo -k 2> /dev/null
|
||||
|
||||
# Save original terminal settings and ensure they're restored on exit
|
||||
local stty_orig
|
||||
stty_orig=$(stty -g < "$tty_path" 2> /dev/null || echo "")
|
||||
trap '[[ -n "${stty_orig:-}" ]] && stty "${stty_orig:-}" < "$tty_path" 2> /dev/null || true' RETURN
|
||||
|
||||
while ((attempts < 3)); do
|
||||
local password=""
|
||||
|
||||
# Show hint on first attempt about Touch ID appearing again
|
||||
if [[ $show_hint == true ]] && check_touchid_support; then
|
||||
echo -e "${GRAY}Note: Touch ID dialog may appear once more - just cancel it${NC}" > "$tty_path"
|
||||
show_hint=false
|
||||
fi
|
||||
|
||||
printf "${PURPLE}${ICON_ARROW}${NC} Password: " > "$tty_path"
|
||||
|
||||
# Disable terminal echo to hide password input
|
||||
stty -echo -icanon min 1 time 0 < "$tty_path" 2> /dev/null || true
|
||||
IFS= read -r password < "$tty_path" || password=""
|
||||
# Restore terminal echo immediately
|
||||
stty echo icanon < "$tty_path" 2> /dev/null || true
|
||||
|
||||
printf "\n" > "$tty_path"
|
||||
|
||||
if [[ -z "$password" ]]; then
|
||||
unset password
|
||||
((attempts++))
|
||||
if [[ $attempts -lt 3 ]]; then
|
||||
echo -e "${YELLOW}${ICON_WARNING}${NC} Password cannot be empty" > "$tty_path"
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
# Verify password with sudo
|
||||
# NOTE: macOS PAM will trigger Touch ID before password auth - this is system behavior
|
||||
if printf '%s\n' "$password" | sudo -S -p "" -v > /dev/null 2>&1; then
|
||||
unset password
|
||||
return 0
|
||||
fi
|
||||
|
||||
unset password
|
||||
((attempts++))
|
||||
if [[ $attempts -lt 3 ]]; then
|
||||
echo -e "${YELLOW}${ICON_WARNING}${NC} Incorrect password, try again" > "$tty_path"
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
request_sudo_access() {
|
||||
local prompt_msg="${1:-Admin access required}"
|
||||
|
||||
# Check if already have sudo access
|
||||
if sudo -n true 2> /dev/null; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Get TTY path
|
||||
local tty_path="/dev/tty"
|
||||
if [[ ! -r "$tty_path" || ! -w "$tty_path" ]]; then
|
||||
tty_path=$(tty 2> /dev/null || echo "")
|
||||
if [[ -z "$tty_path" || ! -r "$tty_path" || ! -w "$tty_path" ]]; then
|
||||
log_error "No interactive terminal available"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
sudo -k
|
||||
|
||||
# Check if in clamshell mode - if yes, skip Touch ID entirely
|
||||
if is_clamshell_mode; then
|
||||
echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg}"
|
||||
if _request_password "$tty_path"; then
|
||||
# Clear all prompt lines (use safe clearing method)
|
||||
safe_clear_lines 3 "$tty_path"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Not in clamshell mode - try Touch ID if configured
|
||||
if ! check_touchid_support; then
|
||||
echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg}"
|
||||
if _request_password "$tty_path"; then
|
||||
# Clear all prompt lines (use safe clearing method)
|
||||
safe_clear_lines 3 "$tty_path"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Touch ID is available and not in clamshell mode
|
||||
echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg} ${GRAY}(Touch ID or password)${NC}"
|
||||
|
||||
# Start sudo in background so we can monitor and control it
|
||||
sudo -v < /dev/null > /dev/null 2>&1 &
|
||||
local sudo_pid=$!
|
||||
|
||||
# Wait for sudo to complete or timeout (5 seconds)
|
||||
local elapsed=0
|
||||
local timeout=50 # 50 * 0.1s = 5 seconds
|
||||
while ((elapsed < timeout)); do
|
||||
if ! kill -0 "$sudo_pid" 2> /dev/null; then
|
||||
# Process exited
|
||||
wait "$sudo_pid" 2> /dev/null
|
||||
local exit_code=$?
|
||||
if [[ $exit_code -eq 0 ]] && sudo -n true 2> /dev/null; then
|
||||
# Touch ID succeeded - clear the prompt line
|
||||
safe_clear_lines 1 "$tty_path"
|
||||
return 0
|
||||
fi
|
||||
# Touch ID failed or cancelled
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
((elapsed++))
|
||||
done
|
||||
|
||||
# Touch ID failed/cancelled - clean up thoroughly before password input
|
||||
|
||||
# Kill the sudo process if still running
|
||||
if kill -0 "$sudo_pid" 2> /dev/null; then
|
||||
kill -9 "$sudo_pid" 2> /dev/null
|
||||
wait "$sudo_pid" 2> /dev/null || true
|
||||
fi
|
||||
|
||||
# Clear sudo state immediately
|
||||
sudo -k 2> /dev/null
|
||||
|
||||
# IMPORTANT: Wait longer for macOS to fully close Touch ID UI and SecurityAgent
|
||||
# Without this delay, subsequent sudo calls may re-trigger Touch ID
|
||||
sleep 1
|
||||
|
||||
# Clear any leftover prompts on the screen
|
||||
safe_clear_line "$tty_path"
|
||||
|
||||
# Now use our password input (this should not trigger Touch ID again)
|
||||
if _request_password "$tty_path"; then
|
||||
# Clear all prompt lines (use safe clearing method)
|
||||
safe_clear_lines 3 "$tty_path"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Sudo Session Management
|
||||
# ============================================================================
|
||||
|
||||
# Global state
|
||||
MOLE_SUDO_KEEPALIVE_PID=""
|
||||
MOLE_SUDO_ESTABLISHED="false"
|
||||
|
||||
# Start sudo keepalive
|
||||
_start_sudo_keepalive() {
|
||||
# Start background keepalive process with all outputs redirected
|
||||
# This is critical: command substitution waits for all file descriptors to close
|
||||
(
|
||||
# Initial delay to let sudo cache stabilize after password entry
|
||||
# This prevents immediately triggering Touch ID again
|
||||
sleep 2
|
||||
|
||||
local retry_count=0
|
||||
while true; do
|
||||
if ! sudo -n -v 2> /dev/null; then
|
||||
((retry_count++))
|
||||
if [[ $retry_count -ge 3 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
sleep 5
|
||||
continue
|
||||
fi
|
||||
retry_count=0
|
||||
sleep 30
|
||||
kill -0 "$$" 2> /dev/null || exit
|
||||
done
|
||||
) > /dev/null 2>&1 &
|
||||
|
||||
local pid=$!
|
||||
echo $pid
|
||||
}
|
||||
|
||||
# Stop sudo keepalive
|
||||
_stop_sudo_keepalive() {
|
||||
local pid="${1:-}"
|
||||
if [[ -n "$pid" ]]; then
|
||||
kill "$pid" 2> /dev/null || true
|
||||
wait "$pid" 2> /dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if sudo session is active
|
||||
has_sudo_session() {
|
||||
sudo -n true 2> /dev/null
|
||||
}
|
||||
|
||||
# Request administrative access
|
||||
request_sudo() {
|
||||
local prompt_msg="${1:-Admin access required}"
|
||||
|
||||
if has_sudo_session; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Use the robust implementation from common.sh
|
||||
if request_sudo_access "$prompt_msg"; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Maintain active sudo session with keepalive
|
||||
ensure_sudo_session() {
|
||||
local prompt="${1:-Admin access required}"
|
||||
|
||||
# Check if already established
|
||||
if has_sudo_session && [[ "$MOLE_SUDO_ESTABLISHED" == "true" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Stop old keepalive if exists
|
||||
if [[ -n "$MOLE_SUDO_KEEPALIVE_PID" ]]; then
|
||||
_stop_sudo_keepalive "$MOLE_SUDO_KEEPALIVE_PID"
|
||||
MOLE_SUDO_KEEPALIVE_PID=""
|
||||
fi
|
||||
|
||||
# Request sudo access
|
||||
if ! request_sudo "$prompt"; then
|
||||
MOLE_SUDO_ESTABLISHED="false"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Start keepalive
|
||||
MOLE_SUDO_KEEPALIVE_PID=$(_start_sudo_keepalive)
|
||||
|
||||
MOLE_SUDO_ESTABLISHED="true"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Stop sudo session and cleanup
|
||||
stop_sudo_session() {
|
||||
if [[ -n "$MOLE_SUDO_KEEPALIVE_PID" ]]; then
|
||||
_stop_sudo_keepalive "$MOLE_SUDO_KEEPALIVE_PID"
|
||||
MOLE_SUDO_KEEPALIVE_PID=""
|
||||
fi
|
||||
MOLE_SUDO_ESTABLISHED="false"
|
||||
}
|
||||
|
||||
# Register cleanup on script exit
|
||||
register_sudo_cleanup() {
|
||||
trap stop_sudo_session EXIT INT TERM
|
||||
}
|
||||
|
||||
# Predict if operation requires administrative access
|
||||
will_need_sudo() {
|
||||
local -a operations=("$@")
|
||||
for op in "${operations[@]}"; do
|
||||
case "$op" in
|
||||
system_update | appstore_update | macos_update | firewall | touchid | rosetta | system_fix)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Mole - Timeout Control
|
||||
# Command execution with timeout support
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Prevent multiple sourcing
|
||||
if [[ -n "${MOLE_TIMEOUT_LOADED:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
readonly MOLE_TIMEOUT_LOADED=1
|
||||
|
||||
# ============================================================================
|
||||
# Timeout Command Initialization
|
||||
# ============================================================================
|
||||
|
||||
# Initialize timeout command (prefer gtimeout from coreutils, fallback to timeout)
|
||||
# Sets MO_TIMEOUT_BIN to the available timeout command
|
||||
#
|
||||
# Recommendation: Install coreutils for reliable timeout support
|
||||
# brew install coreutils
|
||||
#
|
||||
# The shell-based fallback has known limitations:
|
||||
# - May not clean up all child processes
|
||||
# - Has race conditions in edge cases
|
||||
# - Less reliable than native timeout command
|
||||
if [[ -z "${MO_TIMEOUT_INITIALIZED:-}" ]]; then
|
||||
MO_TIMEOUT_BIN=""
|
||||
for candidate in gtimeout timeout; do
|
||||
if command -v "$candidate" > /dev/null 2>&1; then
|
||||
MO_TIMEOUT_BIN="$candidate"
|
||||
if [[ "${MO_DEBUG:-0}" == "1" ]]; then
|
||||
echo "[TIMEOUT] Using command: $candidate" >&2
|
||||
fi
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Log warning if no timeout command available
|
||||
if [[ -z "$MO_TIMEOUT_BIN" ]] && [[ "${MO_DEBUG:-0}" == "1" ]]; then
|
||||
echo "[TIMEOUT] No timeout command found, using shell fallback" >&2
|
||||
echo "[TIMEOUT] Install coreutils for better reliability: brew install coreutils" >&2
|
||||
fi
|
||||
|
||||
export MO_TIMEOUT_INITIALIZED=1
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Timeout Execution
|
||||
# ============================================================================
|
||||
|
||||
# Run command with timeout
|
||||
# Uses gtimeout/timeout if available, falls back to shell-based implementation
|
||||
#
|
||||
# Args:
|
||||
# $1 - duration in seconds (0 or invalid = no timeout)
|
||||
# $@ - command and arguments to execute
|
||||
#
|
||||
# Returns:
|
||||
# Command exit code, or 124 if timed out (matches gtimeout behavior)
|
||||
#
|
||||
# Environment:
|
||||
# MO_DEBUG - Set to 1 to enable debug logging to stderr
|
||||
#
|
||||
# Implementation notes:
|
||||
# - Prefers gtimeout (coreutils) or timeout for reliability
|
||||
# - Shell fallback uses SIGTERM → SIGKILL escalation
|
||||
# - Attempts process group cleanup to handle child processes
|
||||
# - Returns exit code 124 on timeout (standard timeout exit code)
|
||||
#
|
||||
# Known limitations of shell-based fallback:
|
||||
# - Race condition: If command exits during signal delivery, the signal
|
||||
# may target a reused PID (very rare, requires quick PID reuse)
|
||||
# - Zombie processes: Brief zombies until wait completes
|
||||
# - Nested children: SIGKILL may not reach all descendants
|
||||
# - No process group: Cannot guarantee cleanup of detached children
|
||||
#
|
||||
# For mission-critical timeouts, install coreutils.
|
||||
run_with_timeout() {
|
||||
local duration="${1:-0}"
|
||||
shift || true
|
||||
|
||||
# No timeout if duration is invalid or zero
|
||||
if [[ ! "$duration" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ $(echo "$duration <= 0" | bc -l 2> /dev/null) -eq 1 ]]; then
|
||||
"$@"
|
||||
return $?
|
||||
fi
|
||||
|
||||
# Use timeout command if available (preferred path)
|
||||
if [[ -n "${MO_TIMEOUT_BIN:-}" ]]; then
|
||||
if [[ "${MO_DEBUG:-0}" == "1" ]]; then
|
||||
echo "[TIMEOUT] Running with ${duration}s timeout: $*" >&2
|
||||
fi
|
||||
"$MO_TIMEOUT_BIN" "$duration" "$@"
|
||||
return $?
|
||||
fi
|
||||
|
||||
# ========================================================================
|
||||
# Shell-based fallback implementation
|
||||
# ========================================================================
|
||||
|
||||
if [[ "${MO_DEBUG:-0}" == "1" ]]; then
|
||||
echo "[TIMEOUT] Shell fallback (${duration}s): $*" >&2
|
||||
fi
|
||||
|
||||
# Start command in background
|
||||
"$@" &
|
||||
local cmd_pid=$!
|
||||
|
||||
# Start timeout killer in background
|
||||
(
|
||||
# Wait for timeout duration
|
||||
sleep "$duration"
|
||||
|
||||
# Check if process still exists
|
||||
if kill -0 "$cmd_pid" 2> /dev/null; then
|
||||
# Try to kill process group first (negative PID), fallback to single process
|
||||
# Process group kill is best effort - may not work if setsid was used
|
||||
kill -TERM -"$cmd_pid" 2> /dev/null || kill -TERM "$cmd_pid" 2> /dev/null || true
|
||||
|
||||
# Grace period for clean shutdown
|
||||
sleep 2
|
||||
|
||||
# Escalate to SIGKILL if still alive
|
||||
if kill -0 "$cmd_pid" 2> /dev/null; then
|
||||
kill -KILL -"$cmd_pid" 2> /dev/null || kill -KILL "$cmd_pid" 2> /dev/null || true
|
||||
fi
|
||||
fi
|
||||
) &
|
||||
local killer_pid=$!
|
||||
|
||||
# Wait for command to complete
|
||||
local exit_code=0
|
||||
set +e
|
||||
wait "$cmd_pid" 2> /dev/null
|
||||
exit_code=$?
|
||||
set -e
|
||||
|
||||
# Clean up killer process
|
||||
if kill -0 "$killer_pid" 2> /dev/null; then
|
||||
kill "$killer_pid" 2> /dev/null || true
|
||||
wait "$killer_pid" 2> /dev/null || true
|
||||
fi
|
||||
|
||||
# Check if command was killed by timeout (exit codes 143=SIGTERM, 137=SIGKILL)
|
||||
if [[ $exit_code -eq 143 || $exit_code -eq 137 ]]; then
|
||||
# Command was killed by timeout
|
||||
if [[ "${MO_DEBUG:-0}" == "1" ]]; then
|
||||
echo "[TIMEOUT] Command timed out after ${duration}s" >&2
|
||||
fi
|
||||
return 124
|
||||
fi
|
||||
|
||||
# Command completed normally (or with its own error)
|
||||
return "$exit_code"
|
||||
}
|
||||
449
lib/core/ui.ps1
Normal file
449
lib/core/ui.ps1
Normal file
@@ -0,0 +1,449 @@
|
||||
# Mole - UI Module
|
||||
# Provides interactive UI components (menus, confirmations, etc.)
|
||||
|
||||
#Requires -Version 5.1
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
# Prevent multiple sourcing
|
||||
if ((Get-Variable -Name 'MOLE_UI_LOADED' -Scope Script -ErrorAction SilentlyContinue) -and $script:MOLE_UI_LOADED) { return }
|
||||
$script:MOLE_UI_LOADED = $true
|
||||
|
||||
# Import dependencies
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
. "$scriptDir\base.ps1"
|
||||
. "$scriptDir\log.ps1"
|
||||
|
||||
# ============================================================================
|
||||
# Terminal Utilities
|
||||
# ============================================================================
|
||||
|
||||
function Get-TerminalSize {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get terminal width and height
|
||||
#>
|
||||
try {
|
||||
return @{
|
||||
Width = $Host.UI.RawUI.WindowSize.Width
|
||||
Height = $Host.UI.RawUI.WindowSize.Height
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return @{ Width = 80; Height = 24 }
|
||||
}
|
||||
}
|
||||
|
||||
function Clear-Line {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clear the current line
|
||||
#>
|
||||
$width = (Get-TerminalSize).Width
|
||||
Write-Host -NoNewline ("`r" + (" " * ($width - 1)) + "`r")
|
||||
}
|
||||
|
||||
function Move-CursorUp {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Move cursor up N lines
|
||||
#>
|
||||
param([int]$Lines = 1)
|
||||
Write-Host -NoNewline "$([char]27)[$Lines`A"
|
||||
}
|
||||
|
||||
function Move-CursorDown {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Move cursor down N lines
|
||||
#>
|
||||
param([int]$Lines = 1)
|
||||
Write-Host -NoNewline "$([char]27)[$Lines`B"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Confirmation Dialogs
|
||||
# ============================================================================
|
||||
|
||||
function Read-Confirmation {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Ask for yes/no confirmation
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Prompt,
|
||||
|
||||
[bool]$Default = $false
|
||||
)
|
||||
|
||||
$cyan = $script:Colors.Cyan
|
||||
$nc = $script:Colors.NC
|
||||
$hint = if ($Default) { "[Y/n]" } else { "[y/N]" }
|
||||
|
||||
Write-Host -NoNewline " ${cyan}$($script:Icons.Confirm)${nc} $Prompt $hint "
|
||||
|
||||
$response = Read-Host
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($response)) {
|
||||
return $Default
|
||||
}
|
||||
|
||||
return $response -match '^[Yy]'
|
||||
}
|
||||
|
||||
function Read-ConfirmationDestructive {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Ask for confirmation on destructive operations (requires typing 'yes')
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Prompt,
|
||||
|
||||
[string]$ConfirmText = "yes"
|
||||
)
|
||||
|
||||
$red = $script:Colors.Red
|
||||
$nc = $script:Colors.NC
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ${red}$($script:Icons.Warning) WARNING: $Prompt${nc}"
|
||||
Write-Host " Type '$ConfirmText' to confirm: " -NoNewline
|
||||
|
||||
$response = Read-Host
|
||||
return $response -eq $ConfirmText
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Menu Components
|
||||
# ============================================================================
|
||||
|
||||
function Show-Menu {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Display an interactive menu and return selected option
|
||||
.PARAMETER Title
|
||||
Menu title
|
||||
.PARAMETER Options
|
||||
Array of menu options (hashtables with Name and optionally Description, Action)
|
||||
.PARAMETER AllowBack
|
||||
Show back/exit option
|
||||
#>
|
||||
param(
|
||||
[string]$Title = "Menu",
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[array]$Options,
|
||||
|
||||
[switch]$AllowBack
|
||||
)
|
||||
|
||||
$selected = 0
|
||||
$maxIndex = $Options.Count - 1
|
||||
|
||||
# Add back option if allowed
|
||||
if ($AllowBack) {
|
||||
$Options = $Options + @{ Name = "Back"; Description = "Return to previous menu" }
|
||||
$maxIndex++
|
||||
}
|
||||
|
||||
$purple = $script:Colors.PurpleBold
|
||||
$cyan = $script:Colors.Cyan
|
||||
$gray = $script:Colors.Gray
|
||||
$nc = $script:Colors.NC
|
||||
|
||||
# Hide cursor
|
||||
Write-Host -NoNewline "$([char]27)[?25l"
|
||||
|
||||
try {
|
||||
while ($true) {
|
||||
# Clear screen and show menu
|
||||
Clear-Host
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ${purple}$($script:Icons.Arrow) $Title${nc}"
|
||||
Write-Host ""
|
||||
|
||||
for ($i = 0; $i -le $maxIndex; $i++) {
|
||||
$option = $Options[$i]
|
||||
$name = if ($option -is [hashtable]) { $option.Name } else { $option.ToString() }
|
||||
$desc = if ($option -is [hashtable] -and $option.Description) { " - $($option.Description)" } else { "" }
|
||||
|
||||
if ($i -eq $selected) {
|
||||
Write-Host " ${cyan}> $name${nc}${gray}$desc${nc}"
|
||||
}
|
||||
else {
|
||||
Write-Host " $name${gray}$desc${nc}"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ${gray}Use arrows or j/k to navigate, Enter to select, q to quit${nc}"
|
||||
|
||||
# Read key - handle both VirtualKeyCode and escape sequences
|
||||
$key = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||
|
||||
# Debug: uncomment to see key codes
|
||||
# Write-Host "VKey: $($key.VirtualKeyCode), Char: $([int]$key.Character)"
|
||||
|
||||
# Handle escape sequences for arrow keys (some terminals send these)
|
||||
$moved = $false
|
||||
if ($key.VirtualKeyCode -eq 0 -or $key.Character -eq [char]27) {
|
||||
# Escape sequence - read the next characters
|
||||
if ($Host.UI.RawUI.KeyAvailable) {
|
||||
$key2 = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||
if ($key2.Character -eq '[' -and $Host.UI.RawUI.KeyAvailable) {
|
||||
$key3 = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||
switch ($key3.Character) {
|
||||
'A' { # Up arrow escape sequence
|
||||
$selected = if ($selected -gt 0) { $selected - 1 } else { $maxIndex }
|
||||
$moved = $true
|
||||
}
|
||||
'B' { # Down arrow escape sequence
|
||||
$selected = if ($selected -lt $maxIndex) { $selected + 1 } else { 0 }
|
||||
$moved = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $moved) {
|
||||
switch ($key.VirtualKeyCode) {
|
||||
38 { # Up arrow
|
||||
$selected = if ($selected -gt 0) { $selected - 1 } else { $maxIndex }
|
||||
}
|
||||
40 { # Down arrow
|
||||
$selected = if ($selected -lt $maxIndex) { $selected + 1 } else { 0 }
|
||||
}
|
||||
13 { # Enter
|
||||
# Show cursor
|
||||
Write-Host -NoNewline "$([char]27)[?25h"
|
||||
|
||||
if ($AllowBack -and $selected -eq $maxIndex) {
|
||||
return $null # Back selected
|
||||
}
|
||||
return $Options[$selected]
|
||||
}
|
||||
default {
|
||||
switch ($key.Character) {
|
||||
'k' { $selected = if ($selected -gt 0) { $selected - 1 } else { $maxIndex } }
|
||||
'j' { $selected = if ($selected -lt $maxIndex) { $selected + 1 } else { 0 } }
|
||||
'q' {
|
||||
Write-Host -NoNewline "$([char]27)[?25h"
|
||||
return $null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
# Ensure cursor is shown
|
||||
Write-Host -NoNewline "$([char]27)[?25h"
|
||||
}
|
||||
}
|
||||
|
||||
function Show-SelectionList {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Display a multi-select list
|
||||
#>
|
||||
param(
|
||||
[string]$Title = "Select Items",
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[array]$Items,
|
||||
|
||||
[switch]$MultiSelect
|
||||
)
|
||||
|
||||
$cursor = 0
|
||||
$selected = @{}
|
||||
$maxIndex = $Items.Count - 1
|
||||
|
||||
$purple = $script:Colors.PurpleBold
|
||||
$cyan = $script:Colors.Cyan
|
||||
$green = $script:Colors.Green
|
||||
$gray = $script:Colors.Gray
|
||||
$nc = $script:Colors.NC
|
||||
|
||||
Write-Host -NoNewline "$([char]27)[?25l"
|
||||
|
||||
try {
|
||||
while ($true) {
|
||||
Clear-Host
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ${purple}$($script:Icons.Arrow) $Title${nc}"
|
||||
if ($MultiSelect) {
|
||||
Write-Host " ${gray}Space to toggle, Enter to confirm${nc}"
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
for ($i = 0; $i -le $maxIndex; $i++) {
|
||||
$item = $Items[$i]
|
||||
$name = if ($item -is [hashtable]) { $item.Name } else { $item.ToString() }
|
||||
$check = if ($selected[$i]) { "$($script:Icons.Success)" } else { "$($script:Icons.Empty)" }
|
||||
|
||||
if ($i -eq $cursor) {
|
||||
Write-Host " ${cyan}> ${check} $name${nc}"
|
||||
}
|
||||
else {
|
||||
$checkColor = if ($selected[$i]) { $green } else { $gray }
|
||||
Write-Host " ${checkColor}${check}${nc} $name"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ${gray}j/k or arrows to navigate, space to select, Enter to confirm, q to cancel${nc}"
|
||||
|
||||
$key = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||
|
||||
# Handle escape sequences for arrow keys (some terminals send these)
|
||||
$moved = $false
|
||||
if ($key.VirtualKeyCode -eq 0 -or $key.Character -eq [char]27) {
|
||||
# Escape sequence - read the next characters
|
||||
if ($Host.UI.RawUI.KeyAvailable) {
|
||||
$key2 = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||
if ($key2.Character -eq '[' -and $Host.UI.RawUI.KeyAvailable) {
|
||||
$key3 = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||
switch ($key3.Character) {
|
||||
'A' { # Up arrow escape sequence
|
||||
$cursor = if ($cursor -gt 0) { $cursor - 1 } else { $maxIndex }
|
||||
$moved = $true
|
||||
}
|
||||
'B' { # Down arrow escape sequence
|
||||
$cursor = if ($cursor -lt $maxIndex) { $cursor + 1 } else { 0 }
|
||||
$moved = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $moved) {
|
||||
switch ($key.VirtualKeyCode) {
|
||||
38 { $cursor = if ($cursor -gt 0) { $cursor - 1 } else { $maxIndex } }
|
||||
40 { $cursor = if ($cursor -lt $maxIndex) { $cursor + 1 } else { 0 } }
|
||||
32 { # Space
|
||||
if ($MultiSelect) {
|
||||
$selected[$cursor] = -not $selected[$cursor]
|
||||
}
|
||||
else {
|
||||
$selected = @{ $cursor = $true }
|
||||
}
|
||||
}
|
||||
13 { # Enter
|
||||
Write-Host -NoNewline "$([char]27)[?25h"
|
||||
$result = @()
|
||||
foreach ($selKey in $selected.Keys) {
|
||||
if ($selected[$selKey]) {
|
||||
$result += $Items[$selKey]
|
||||
}
|
||||
}
|
||||
return $result
|
||||
}
|
||||
default {
|
||||
switch ($key.Character) {
|
||||
'k' { $cursor = if ($cursor -gt 0) { $cursor - 1 } else { $maxIndex } }
|
||||
'j' { $cursor = if ($cursor -lt $maxIndex) { $cursor + 1 } else { 0 } }
|
||||
' ' {
|
||||
if ($MultiSelect) {
|
||||
$selected[$cursor] = -not $selected[$cursor]
|
||||
}
|
||||
else {
|
||||
$selected = @{ $cursor = $true }
|
||||
}
|
||||
}
|
||||
'q' {
|
||||
Write-Host -NoNewline "$([char]27)[?25h"
|
||||
return @()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Write-Host -NoNewline "$([char]27)[?25h"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Banner / Header
|
||||
# ============================================================================
|
||||
|
||||
function Show-Banner {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Display the Mole ASCII banner
|
||||
#>
|
||||
$purple = $script:Colors.Purple
|
||||
$cyan = $script:Colors.Cyan
|
||||
$nc = $script:Colors.NC
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ${purple}MOLE${nc}"
|
||||
Write-Host " ${cyan}Windows System Maintenance${nc}"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
function Show-Header {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Display a section header
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Title,
|
||||
|
||||
[string]$Subtitle = ""
|
||||
)
|
||||
|
||||
$purple = $script:Colors.PurpleBold
|
||||
$gray = $script:Colors.Gray
|
||||
$nc = $script:Colors.NC
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ${purple}$Title${nc}"
|
||||
if ($Subtitle) {
|
||||
Write-Host " ${gray}$Subtitle${nc}"
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Summary Display
|
||||
# ============================================================================
|
||||
|
||||
function Show-Summary {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Display cleanup summary
|
||||
#>
|
||||
param(
|
||||
[long]$SizeBytes = 0,
|
||||
[int]$ItemCount = 0,
|
||||
[string]$Action = "Cleaned"
|
||||
)
|
||||
|
||||
$green = $script:Colors.Green
|
||||
$cyan = $script:Colors.Cyan
|
||||
$nc = $script:Colors.NC
|
||||
|
||||
$sizeHuman = Format-ByteSize -Bytes $SizeBytes
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " $($green)━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━$($nc)"
|
||||
Write-Host " $($green)$($script:Icons.Success)$($nc) $($Action): $($cyan)$($sizeHuman)$($nc) across $($cyan)$($ItemCount)$($nc) items"
|
||||
Write-Host " $($green)━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━$($nc)"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Exports (functions are available via dot-sourcing)
|
||||
# ============================================================================
|
||||
# Functions: Show-Menu, Show-Banner, Read-Confirmation, etc.
|
||||
434
lib/core/ui.sh
434
lib/core/ui.sh
@@ -1,434 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Mole - UI Components
|
||||
# Terminal UI utilities: cursor control, keyboard input, spinners, menus
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -n "${MOLE_UI_LOADED:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
readonly MOLE_UI_LOADED=1
|
||||
|
||||
_MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
[[ -z "${MOLE_BASE_LOADED:-}" ]] && source "$_MOLE_CORE_DIR/base.sh"
|
||||
|
||||
# Cursor control
|
||||
clear_screen() { printf '\033[2J\033[H'; }
|
||||
hide_cursor() { [[ -t 1 ]] && printf '\033[?25l' >&2 || true; }
|
||||
show_cursor() { [[ -t 1 ]] && printf '\033[?25h' >&2 || true; }
|
||||
|
||||
# Calculate display width (CJK characters count as 2)
|
||||
get_display_width() {
|
||||
local str="$1"
|
||||
|
||||
# Optimized pure bash implementation without forks
|
||||
local width
|
||||
|
||||
# Save current locale
|
||||
local old_lc="${LC_ALL:-}"
|
||||
|
||||
# Get Char Count (UTF-8)
|
||||
# We must export ensuring it applies to the expansion (though just assignment often works in newer bash, export is safer for all subshells/cmds)
|
||||
export LC_ALL=en_US.UTF-8
|
||||
local char_count=${#str}
|
||||
|
||||
# Get Byte Count (C)
|
||||
export LC_ALL=C
|
||||
local byte_count=${#str}
|
||||
|
||||
# Restore Locale immediately
|
||||
if [[ -n "$old_lc" ]]; then
|
||||
export LC_ALL="$old_lc"
|
||||
else
|
||||
unset LC_ALL
|
||||
fi
|
||||
|
||||
if [[ $byte_count -eq $char_count ]]; then
|
||||
echo "$char_count"
|
||||
return
|
||||
fi
|
||||
|
||||
# CJK Heuristic:
|
||||
# Most CJK chars are 3 bytes in UTF-8 and width 2.
|
||||
# ASCII chars are 1 byte and width 1.
|
||||
# Width ~= CharCount + (ByteCount - CharCount) / 2
|
||||
# "中" (1 char, 3 bytes) -> 1 + (2)/2 = 2.
|
||||
# "A" (1 char, 1 byte) -> 1 + 0 = 1.
|
||||
# This is an approximation but very fast and sufficient for App names.
|
||||
# Integer arithmetic in bash automatically handles floor.
|
||||
local extra_bytes=$((byte_count - char_count))
|
||||
local padding=$((extra_bytes / 2))
|
||||
width=$((char_count + padding))
|
||||
|
||||
# Adjust for zero-width joiners and emoji variation selectors (common in filenames/emojis)
|
||||
# These characters add bytes but no visible width; subtract their count if present.
|
||||
local zwj=$'\u200d' # zero-width joiner
|
||||
local vs16=$'\ufe0f' # emoji variation selector
|
||||
local zero_width=0
|
||||
|
||||
local without_zwj=${str//$zwj/}
|
||||
zero_width=$((zero_width + (char_count - ${#without_zwj})))
|
||||
|
||||
local without_vs=${str//$vs16/}
|
||||
zero_width=$((zero_width + (char_count - ${#without_vs})))
|
||||
|
||||
if ((zero_width > 0 && width > zero_width)); then
|
||||
width=$((width - zero_width))
|
||||
fi
|
||||
|
||||
echo "$width"
|
||||
}
|
||||
|
||||
# Truncate string by display width (handles CJK)
|
||||
truncate_by_display_width() {
|
||||
local str="$1"
|
||||
local max_width="$2"
|
||||
local current_width
|
||||
current_width=$(get_display_width "$str")
|
||||
|
||||
if [[ $current_width -le $max_width ]]; then
|
||||
echo "$str"
|
||||
return
|
||||
fi
|
||||
|
||||
# Fallback: Use pure bash character iteration
|
||||
# Since we need to know the width of *each* character to truncate at the right spot,
|
||||
# we cannot just use the total width formula on the whole string.
|
||||
# However, iterating char-by-char and calling the optimized get_display_width function
|
||||
# is now much faster because it doesn't fork 'wc'.
|
||||
|
||||
# CRITICAL: Switch to UTF-8 for correct character iteration
|
||||
local old_lc="${LC_ALL:-}"
|
||||
export LC_ALL=en_US.UTF-8
|
||||
|
||||
local truncated=""
|
||||
local width=0
|
||||
local i=0
|
||||
local char char_width
|
||||
local strlen=${#str} # Re-calculate in UTF-8
|
||||
|
||||
# Optimization: If total width <= max_width, return original string (checked above)
|
||||
|
||||
while [[ $i -lt $strlen ]]; do
|
||||
char="${str:$i:1}"
|
||||
|
||||
# Inlined width calculation for minimal overhead to avoid recursion overhead
|
||||
# We are already in UTF-8, so ${#char} is char length (1).
|
||||
# We need byte length for the heuristic.
|
||||
# But switching locale inside loop is disastrous for perf.
|
||||
# Logic: If char is ASCII (1 byte), width 1.
|
||||
# If char is wide (3 bytes), width 2.
|
||||
# How to detect byte size without switching locale?
|
||||
# printf %s "$char" | wc -c ? Slow.
|
||||
# Check against ASCII range?
|
||||
# Fast ASCII check: if [[ "$char" < $'\x7f' ]]; then ...
|
||||
|
||||
if [[ "$char" =~ [[:ascii:]] ]]; then
|
||||
char_width=1
|
||||
else
|
||||
# Assume wide for non-ascii in this context (simplified)
|
||||
# Or use LC_ALL=C inside? No.
|
||||
# Most non-ASCII in filenames are either CJK (width 2) or heavy symbols.
|
||||
# Let's assume 2 for simplicity in this fast loop as we know we are usually dealing with CJK.
|
||||
char_width=2
|
||||
fi
|
||||
|
||||
if ((width + char_width + 3 > max_width)); then
|
||||
break
|
||||
fi
|
||||
|
||||
truncated+="$char"
|
||||
((width += char_width))
|
||||
((i++))
|
||||
done
|
||||
|
||||
# Restore locale
|
||||
if [[ -n "$old_lc" ]]; then
|
||||
export LC_ALL="$old_lc"
|
||||
else
|
||||
unset LC_ALL
|
||||
fi
|
||||
|
||||
echo "${truncated}..."
|
||||
}
|
||||
|
||||
# Read single keyboard input
|
||||
read_key() {
|
||||
local key rest read_status
|
||||
IFS= read -r -s -n 1 key
|
||||
read_status=$?
|
||||
[[ $read_status -ne 0 ]] && {
|
||||
echo "QUIT"
|
||||
return 0
|
||||
}
|
||||
|
||||
if [[ "${MOLE_READ_KEY_FORCE_CHAR:-}" == "1" ]]; then
|
||||
[[ -z "$key" ]] && {
|
||||
echo "ENTER"
|
||||
return 0
|
||||
}
|
||||
case "$key" in
|
||||
$'\n' | $'\r') echo "ENTER" ;;
|
||||
$'\x7f' | $'\x08') echo "DELETE" ;;
|
||||
$'\x1b') echo "QUIT" ;;
|
||||
[[:print:]]) echo "CHAR:$key" ;;
|
||||
*) echo "OTHER" ;;
|
||||
esac
|
||||
return 0
|
||||
fi
|
||||
|
||||
[[ -z "$key" ]] && {
|
||||
echo "ENTER"
|
||||
return 0
|
||||
}
|
||||
case "$key" in
|
||||
$'\n' | $'\r') echo "ENTER" ;;
|
||||
' ') echo "SPACE" ;;
|
||||
'/') echo "FILTER" ;;
|
||||
'q' | 'Q') echo "QUIT" ;;
|
||||
'R') echo "RETRY" ;;
|
||||
'm' | 'M') echo "MORE" ;;
|
||||
'u' | 'U') echo "UPDATE" ;;
|
||||
't' | 'T') echo "TOUCHID" ;;
|
||||
'j' | 'J') echo "DOWN" ;;
|
||||
'k' | 'K') echo "UP" ;;
|
||||
'h' | 'H') echo "LEFT" ;;
|
||||
'l' | 'L') echo "RIGHT" ;;
|
||||
$'\x03') echo "QUIT" ;;
|
||||
$'\x7f' | $'\x08') echo "DELETE" ;;
|
||||
$'\x1b')
|
||||
if IFS= read -r -s -n 1 -t 1 rest 2> /dev/null; then
|
||||
if [[ "$rest" == "[" ]]; then
|
||||
if IFS= read -r -s -n 1 -t 1 rest2 2> /dev/null; then
|
||||
case "$rest2" in
|
||||
"A") echo "UP" ;; "B") echo "DOWN" ;;
|
||||
"C") echo "RIGHT" ;; "D") echo "LEFT" ;;
|
||||
"3")
|
||||
IFS= read -r -s -n 1 -t 1 rest3 2> /dev/null
|
||||
[[ "$rest3" == "~" ]] && echo "DELETE" || echo "OTHER"
|
||||
;;
|
||||
*) echo "OTHER" ;;
|
||||
esac
|
||||
else echo "QUIT"; fi
|
||||
elif [[ "$rest" == "O" ]]; then
|
||||
if IFS= read -r -s -n 1 -t 1 rest2 2> /dev/null; then
|
||||
case "$rest2" in
|
||||
"A") echo "UP" ;; "B") echo "DOWN" ;;
|
||||
"C") echo "RIGHT" ;; "D") echo "LEFT" ;;
|
||||
*) echo "OTHER" ;;
|
||||
esac
|
||||
else echo "OTHER"; fi
|
||||
else echo "OTHER"; fi
|
||||
else echo "QUIT"; fi
|
||||
;;
|
||||
[[:print:]]) echo "CHAR:$key" ;;
|
||||
*) echo "OTHER" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
drain_pending_input() {
|
||||
local drained=0
|
||||
while IFS= read -r -s -n 1 -t 0.01 _ 2> /dev/null; do
|
||||
((drained++))
|
||||
[[ $drained -gt 100 ]] && break
|
||||
done
|
||||
}
|
||||
|
||||
# Format menu option display
|
||||
show_menu_option() {
|
||||
local number="$1"
|
||||
local text="$2"
|
||||
local selected="$3"
|
||||
|
||||
if [[ "$selected" == "true" ]]; then
|
||||
echo -e "${CYAN}${ICON_ARROW} $number. $text${NC}"
|
||||
else
|
||||
echo " $number. $text"
|
||||
fi
|
||||
}
|
||||
|
||||
# Background spinner implementation
|
||||
INLINE_SPINNER_PID=""
|
||||
INLINE_SPINNER_STOP_FILE=""
|
||||
|
||||
start_inline_spinner() {
|
||||
stop_inline_spinner 2> /dev/null || true
|
||||
local message="$1"
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
# Create unique stop flag file for this spinner instance
|
||||
INLINE_SPINNER_STOP_FILE="${TMPDIR:-/tmp}/mole_spinner_$$_$RANDOM.stop"
|
||||
|
||||
(
|
||||
local stop_file="$INLINE_SPINNER_STOP_FILE"
|
||||
local chars
|
||||
chars="$(mo_spinner_chars)"
|
||||
[[ -z "$chars" ]] && chars="|/-\\"
|
||||
local i=0
|
||||
|
||||
# Cooperative exit: check for stop file instead of relying on signals
|
||||
while [[ ! -f "$stop_file" ]]; do
|
||||
local c="${chars:$((i % ${#chars})):1}"
|
||||
# Output to stderr to avoid interfering with stdout
|
||||
printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$message" >&2 || break
|
||||
((i++))
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
# Clean up stop file before exiting
|
||||
rm -f "$stop_file" 2> /dev/null || true
|
||||
exit 0
|
||||
) &
|
||||
INLINE_SPINNER_PID=$!
|
||||
disown 2> /dev/null || true
|
||||
else
|
||||
echo -n " ${BLUE}|${NC} $message" >&2 || true
|
||||
fi
|
||||
}
|
||||
|
||||
stop_inline_spinner() {
|
||||
if [[ -n "$INLINE_SPINNER_PID" ]]; then
|
||||
# Cooperative stop: create stop file to signal spinner to exit
|
||||
if [[ -n "$INLINE_SPINNER_STOP_FILE" ]]; then
|
||||
touch "$INLINE_SPINNER_STOP_FILE" 2> /dev/null || true
|
||||
fi
|
||||
|
||||
# Wait briefly for cooperative exit
|
||||
local wait_count=0
|
||||
while kill -0 "$INLINE_SPINNER_PID" 2> /dev/null && [[ $wait_count -lt 5 ]]; do
|
||||
sleep 0.05 2> /dev/null || true
|
||||
((wait_count++))
|
||||
done
|
||||
|
||||
# Only use SIGKILL as last resort if process is stuck
|
||||
if kill -0 "$INLINE_SPINNER_PID" 2> /dev/null; then
|
||||
kill -KILL "$INLINE_SPINNER_PID" 2> /dev/null || true
|
||||
fi
|
||||
|
||||
wait "$INLINE_SPINNER_PID" 2> /dev/null || true
|
||||
|
||||
# Cleanup
|
||||
rm -f "$INLINE_SPINNER_STOP_FILE" 2> /dev/null || true
|
||||
INLINE_SPINNER_PID=""
|
||||
INLINE_SPINNER_STOP_FILE=""
|
||||
|
||||
# Clear the line - use \033[2K to clear entire line, not just to end
|
||||
[[ -t 1 ]] && printf "\r\033[2K" >&2 || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Run command with a terminal spinner
|
||||
with_spinner() {
|
||||
local msg="$1"
|
||||
shift || true
|
||||
local timeout=180
|
||||
start_inline_spinner "$msg"
|
||||
local exit_code=0
|
||||
if [[ -n "${MOLE_TIMEOUT_BIN:-}" ]]; then
|
||||
"$MOLE_TIMEOUT_BIN" "$timeout" "$@" > /dev/null 2>&1 || exit_code=$?
|
||||
else "$@" > /dev/null 2>&1 || exit_code=$?; fi
|
||||
stop_inline_spinner "$msg"
|
||||
return $exit_code
|
||||
}
|
||||
|
||||
# Get spinner characters
|
||||
mo_spinner_chars() {
|
||||
local chars="|/-\\"
|
||||
[[ -z "$chars" ]] && chars="|/-\\"
|
||||
printf "%s" "$chars"
|
||||
}
|
||||
|
||||
# Format relative time for compact display (e.g., 3d ago)
|
||||
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"
|
||||
}
|
||||
|
||||
# Check if terminal has Full Disk Access
|
||||
# Returns 0 if FDA is granted, 1 if denied, 2 if unknown
|
||||
has_full_disk_access() {
|
||||
# Cache the result to avoid repeated checks
|
||||
if [[ -n "${MOLE_HAS_FDA:-}" ]]; then
|
||||
if [[ "$MOLE_HAS_FDA" == "1" ]]; then
|
||||
return 0
|
||||
elif [[ "$MOLE_HAS_FDA" == "unknown" ]]; then
|
||||
return 2
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Test access to protected directories that require FDA
|
||||
# Strategy: Try to access directories that are commonly protected
|
||||
# If ANY of them are accessible, we likely have FDA
|
||||
# If ALL fail, we definitely don't have FDA
|
||||
local -a protected_dirs=(
|
||||
"$HOME/Library/Safari/LocalStorage"
|
||||
"$HOME/Library/Mail/V10"
|
||||
"$HOME/Library/Messages/chat.db"
|
||||
)
|
||||
|
||||
local accessible_count=0
|
||||
local tested_count=0
|
||||
|
||||
for test_path in "${protected_dirs[@]}"; do
|
||||
# Only test when the protected path exists
|
||||
if [[ -e "$test_path" ]]; then
|
||||
tested_count=$((tested_count + 1))
|
||||
# Try to stat the ACTUAL protected path - this requires FDA
|
||||
if stat "$test_path" > /dev/null 2>&1; then
|
||||
accessible_count=$((accessible_count + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Three possible outcomes:
|
||||
# 1. tested_count = 0: Can't determine (test paths don't exist) → unknown
|
||||
# 2. tested_count > 0 && accessible_count > 0: Has FDA → yes
|
||||
# 3. tested_count > 0 && accessible_count = 0: No FDA → no
|
||||
if [[ $tested_count -eq 0 ]]; then
|
||||
# Can't determine - test paths don't exist, treat as unknown
|
||||
export MOLE_HAS_FDA="unknown"
|
||||
return 2
|
||||
elif [[ $accessible_count -gt 0 ]]; then
|
||||
# At least one path is accessible → has FDA
|
||||
export MOLE_HAS_FDA=1
|
||||
return 0
|
||||
else
|
||||
# Tested paths exist but not accessible → no FDA
|
||||
export MOLE_HAS_FDA=0
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
Reference in New Issue
Block a user