1
0
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:
Tw93
2026-01-10 13:23:29 +08:00
parent e84a457c2f
commit edf5ed09a9
140 changed files with 1472 additions and 34059 deletions

File diff suppressed because it is too large Load Diff

396
lib/core/base.ps1 Normal file
View 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.

View File

@@ -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
}

View File

@@ -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
View 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.

View File

@@ -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
View 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.

View File

@@ -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
View 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.

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
View 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.

View File

@@ -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
}