lib/core.ps1
|
# WinMole Core Utilities # Speed-optimized helpers used across all WinMole commands Set-StrictMode -Version Latest # ── Version ────────────────────────────────────────────────────────────────── $script:WINMOLE_VERSION = '0.1.3' # ── PwshSpectreConsole ──────────────────────────────────────────────────────── if (-not (Get-Module PwshSpectreConsole)) { Import-Module PwshSpectreConsole -ErrorAction Stop } function script:Esc { param([string]$Text) if (-not $Text) { return '' } return [Spectre.Console.Markup]::Escape($Text) } function Get-OptionalPropertyValue { param( [object]$InputObject, [string]$Name, $Default = $null ) if ($null -eq $InputObject) { return $Default } $property = $InputObject.PSObject.Properties[$Name] if ($null -eq $property) { return $Default } if ($null -eq $property.Value) { return $Default } return $property.Value } function Split-CommandLine { param([string]$CommandLine) if ([string]::IsNullOrWhiteSpace($CommandLine)) { return [pscustomobject]@{ FilePath = '' ArgumentList = '' } } if ($CommandLine -match '^"([^"]+)"\s*(.*)$') { return [pscustomobject]@{ FilePath = $Matches[1] ArgumentList = $Matches[2] } } if ($CommandLine -match '^(\S+)\s*(.*)$') { return [pscustomobject]@{ FilePath = $Matches[1] ArgumentList = $Matches[2] } } return [pscustomobject]@{ FilePath = $CommandLine ArgumentList = '' } } function Get-UninstallInvocation { param( [string]$UninstallString, [string]$QuietUninstallString ) $commandLine = if ($QuietUninstallString) { $QuietUninstallString } else { $UninstallString } $invocation = Split-CommandLine -CommandLine $commandLine if (-not $invocation.FilePath) { return $null } if ($QuietUninstallString) { return $invocation } $fileName = [System.IO.Path]::GetFileName($invocation.FilePath) $args = [string]$invocation.ArgumentList switch -Regex ($fileName) { '^(?i)msiexec(?:\.exe)?$' { if ($args -match '(?i)(^|\s)/i(?=\s|[{"])') { $args = [regex]::Replace($args, '(?i)(^|\s)/i(?=\s|[{"])', '$1/X', 1) } elseif ($args -notmatch '(?i)(^|\s)/x(?=\s|[{"])') { $args = "/X $args".Trim() } if ($args -notmatch '(?i)(^|\s)/(qn|quiet)(?=\s|$)') { $args = "$args /qn /norestart".Trim() } break } '^(?i)winget(?:\.exe)?$' { if ($args -match '(?i)\buninstall\b') { $neededFlags = @( '--silent' '--disable-interactivity' '--accept-source-agreements' '--accept-package-agreements' ) foreach ($flag in $neededFlags) { if ($args -notmatch [regex]::Escape($flag)) { $args += " $flag" } } $args = $args.Trim() } break } } return [pscustomobject]@{ FilePath = $invocation.FilePath ArgumentList = $args } } # ── Output helpers ──────────────────────────────────────────────────────────── function Write-Success { param([string]$Message, [string]$Right = '') if ($Right) { $pad = [Math]::Max(0, 56 - $Message.Length) Write-SpectreHost " [green]✓[/] $(Esc $Message)$(' ' * $pad)[gold1]$(Esc $Right)[/]" } else { Write-SpectreHost " [green]✓[/] $(Esc $Message)" } } function Write-Info { param([string]$Message) Write-SpectreHost " [teal]→[/] $(Esc $Message)" } function Write-Warn { param([string]$Message) Write-SpectreHost " [yellow]⚠[/] $(Esc $Message)" } function Write-Err { param([string]$Message) Write-SpectreHost " [red]✗[/] $(Esc $Message)" } function Write-Header { param([string]$Title, [string]$Sub = '') Write-Host "" $line = " [bold deepskyblue1]$(Esc $Title)[/]" if ($Sub) { $line += " [grey]$(Esc $Sub)[/]" } Write-SpectreHost $line Write-Host "" } function Write-Divider { Write-SpectreHost "[grey]$('=' * 68)[/]" } function Write-Summary { param([string]$Label, [string]$Value) Write-Divider Write-SpectreHost " [bold]$(Esc $Label)[/] [green]$(Esc $Value)[/]" Write-Divider Write-Host "" } # ── Size formatting ─────────────────────────────────────────────────────────── function Format-Bytes { param([long]$Bytes) if ($Bytes -ge 1TB) { return '{0:F1}TB' -f ($Bytes / 1TB) } if ($Bytes -ge 1GB) { return '{0:F1}GB' -f ($Bytes / 1GB) } if ($Bytes -ge 1MB) { return '{0:F1}MB' -f ($Bytes / 1MB) } if ($Bytes -ge 1KB) { return '{0:F1}KB' -f ($Bytes / 1KB) } return "${Bytes}B" } # ── Fast directory-size calculation using .NET enumeration ──────────────────── function Get-DirectorySize { param([string]$Path) $total = [long]0 try { $opts = [System.IO.EnumerationOptions]::new() $opts.IgnoreInaccessible = $true $opts.RecurseSubdirectories = $true foreach ($f in [System.IO.Directory]::EnumerateFiles($Path, '*', $opts)) { try { $total += (New-Object System.IO.FileInfo($f)).Length } catch {} } } catch {} return $total } # ── Fast parallel directory sizes using runspaces ───────────────────────────── function Get-DirectorySizes { param([string[]]$Paths) $results = @{} if ($Paths.Count -eq 0) { return $results } $pool = [runspacefactory]::CreateRunspacePool( 1, [Math]::Min($Paths.Count, [Environment]::ProcessorCount * 2)) $pool.Open() $sizeBlock = { param($p) $total = [long]0 try { $opts = [System.IO.EnumerationOptions]::new() $opts.IgnoreInaccessible = $true $opts.RecurseSubdirectories = $true foreach ($f in [System.IO.Directory]::EnumerateFiles($p, '*', $opts)) { try { $total += [System.IO.FileInfo]::new($f).Length } catch {} } } catch {} [pscustomobject]@{ Path = $p; Size = $total } } $handles = foreach ($path in $Paths) { $ps = [System.Management.Automation.PowerShell]::Create() $ps.RunspacePool = $pool [void]$ps.AddScript($sizeBlock).AddArgument($path) [pscustomobject]@{ PS = $ps; Handle = $ps.BeginInvoke() } } foreach ($h in $handles) { try { $r = $h.PS.EndInvoke($h.Handle) if ($r) { $results[$r.Path] = $r.Size } } catch {} $h.PS.Dispose() } $pool.Close() $pool.Dispose() return $results } # ── Safe file removal ───────────────────────────────────────────────────────── function Remove-ItemSafe { param([string]$Path, [switch]$Recurse, [switch]$DryRun) if (-not (Test-Path $Path)) { return 0L } $size = [long]0 try { if ($Recurse) { $size = Get-DirectorySize -Path $Path } else { $size = (New-Object System.IO.FileInfo($Path)).Length } if (-not $DryRun) { Remove-Item -LiteralPath $Path -Recurse:$Recurse -Force -ErrorAction Stop } } catch { # Deletion failed — don't count as freed return 0L } return $size } # ── Progress bar ────────────────────────────────────────────────────────────── function Get-ProgressBar { param([double]$Pct, [int]$Width = 20, [string]$Color = '') $filled = [int]([Math]::Round($Pct / 100.0 * $Width)) $filled = [Math]::Max(0, [Math]::Min($filled, $Width)) $empty = $Width - $filled $bar = '█' * $filled + '░' * $empty if ($Color) { return "[$Color]$bar[/]" } return $bar } # ── Mini spark bar (5-block) ────────────────────────────────────────────────── function Get-SparkBar { param([double]$Pct) $blocks = @('▯','▮▯▯▯▯','▮▮▯▯▯','▮▮▮▯▯','▮▮▮▮▯','▮▮▮▮▮') $idx = [int]([Math]::Round($Pct / 100.0 * ($blocks.Count - 1))) $idx = [Math]::Max(0, [Math]::Min($idx, $blocks.Count - 1)) return $blocks[$idx] } # ── Confirmation prompt ─────────────────────────────────────────────────────── function Confirm-Action { param([string]$Message = 'Proceed?') Write-Host "" try { return Read-SpectreConfirm -Prompt $Message -DefaultAnswer n } catch { return $false } } # ── Whitelist support ───────────────────────────────────────────────────────── $_home = if ($env:USERPROFILE) { $env:USERPROFILE } else { $env:HOME } $script:WINMOLE_CONFIG = Join-Path $_home '.config\winmole' $script:WHITELIST_FILE = Join-Path $script:WINMOLE_CONFIG 'whitelist.txt' function Get-Whitelist { if (-not (Test-Path $script:WHITELIST_FILE)) { return @() } return Get-Content $script:WHITELIST_FILE -ErrorAction SilentlyContinue | Where-Object { $_ -and -not $_.StartsWith('#') } } function Test-Whitelisted { param([string]$Path, [string[]]$Whitelist) foreach ($w in $Whitelist) { if ($Path -like $w) { return $true } } return $false } # ── Admin check ─────────────────────────────────────────────────────────────── function Test-IsAdmin { try { if (-not [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform( [System.Runtime.InteropServices.OSPlatform]::Windows)) { return $false } $id = [System.Security.Principal.WindowsIdentity]::GetCurrent() $p = [System.Security.Principal.WindowsPrincipal]$id return $p.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) } catch { return $false } } # ── System info cache ───────────────────────────────────────────────────────── $script:_sysInfoCache = $null function Get-SysInfoSummary { if ($script:_sysInfoCache) { return $script:_sysInfoCache } try { $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue $cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction SilentlyContinue $proc = Get-CimInstance -ClassName Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 1 $disk = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3" -ErrorAction SilentlyContinue | Select-Object -First 1 $totalRam = [Math]::Round($cs.TotalPhysicalMemory / 1GB, 0) $usedRam = [Math]::Round(($cs.TotalPhysicalMemory - $os.FreePhysicalMemory * 1KB) / 1GB, 1) $diskUsed = [Math]::Round(($disk.Size - $disk.FreeSpace) / 1GB, 0) $diskTot = [Math]::Round($disk.Size / 1GB, 0) $uptime = Format-Uptime -Seconds ([long](New-TimeSpan -Start $os.LastBootUpTime -End (Get-Date)).TotalSeconds) $script:_sysInfoCache = [pscustomobject]@{ MachineName = $env:COMPUTERNAME CPU = $proc.Name -replace '\s{2,}',' ' RamTotalGB = $totalRam RamUsedGB = $usedRam DiskUsedGB = $diskUsed DiskTotalGB = $diskTot OSCaption = $os.Caption Uptime = $uptime } } catch { $script:_sysInfoCache = [pscustomobject]@{ MachineName = $env:COMPUTERNAME CPU = 'Unknown'; RamTotalGB = 0; RamUsedGB = 0 DiskUsedGB = 0; DiskTotalGB = 0 OSCaption = 'Windows'; Uptime = '?' } } return $script:_sysInfoCache } function Format-Uptime { param([long]$Seconds) $d = [int]($Seconds / 86400) $h = [int](($Seconds % 86400) / 3600) $m = [int](($Seconds % 3600) / 60) if ($d -gt 0) { return "${d}d ${h}h" } if ($h -gt 0) { return "${h}h ${m}m" } return "${m}m" } # ── Log setup ───────────────────────────────────────────────────────────────── $_homeLog = if ($env:USERPROFILE) { $env:USERPROFILE } else { $env:HOME } $script:LOG_DIR = Join-Path $_homeLog '.cache\winmole\logs' $script:LOG_FILE = Join-Path $script:LOG_DIR 'operations.log' function Write-OpLog { param([string]$Message) if ($env:WINMOLE_NO_OPLOG -eq '1') { return } try { if (-not (Test-Path $script:LOG_DIR)) { [void][System.IO.Directory]::CreateDirectory($script:LOG_DIR) } $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' "$ts $Message" | Add-Content -LiteralPath $script:LOG_FILE -ErrorAction SilentlyContinue } catch {} } |