Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions LazyWinAdminModule/Classes/ApplicationState.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Requires PowerShell 7.4+

class LazyWinAdminState {
[hashtable] $SyncHash
[System.Management.Automation.Runspaces.RunspacePool] $RunspacePool

# Thread-safe CIM session cache — keyed by ComputerName.
# Private functions open one session per call and close it in finally.
# This cache is available for future cross-call reuse without rebuilding
# the WSMan connection on every button click.
# Adheres to: cim_session.* ALWAYS reuse-before-create (CONTRACTS)
[hashtable] $CimSessions

LazyWinAdminState() {
$this.SyncHash = [hashtable]::Synchronized(@{})
$this.SyncHash.Logs = [System.Collections.ArrayList]::new()
$this.SyncHash.IsBusy = $false
# Set to $true by the cloud-auth OnCompleted handler once [OK] is returned.
# Read by $RequireCloudSession guard in button handlers before dispatching async work.
$this.SyncHash.CloudConnected = $false
# Set to $true by the Exchange OnCompleted handler after a successful connection.
# Read by $RequireExchangeSession guard.
$this.SyncHash.ExchangeConnected = $false
# Thread-safe queue drained by the DispatcherTimer on the WPF UI thread.
# Register-ObjectEvent actions enqueue completed job results here instead of
# calling Dispatcher.InvokeAsync, avoiding all cross-thread delegate issues.
$this.SyncHash.UIQueue = [System.Collections.Concurrent.ConcurrentQueue[object]]::new()

$this.CimSessions = [hashtable]::Synchronized(@{})

# Runspace pool used by Start-ThreadJob for all async UI operations.
# Min 1, max 5 concurrent thread jobs.
$this.RunspacePool = [runspacefactory]::CreateRunspacePool(1, 5)
$this.RunspacePool.Open()
}

[void] Log([string]$Message) {
$timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
$this.SyncHash.Logs.Add("[$timestamp] $Message") | Out-Null
}

# Evict a stale or failed CIM session from the cache by ComputerName.
[void] EvictCimSession([string]$ComputerName) {
if ($this.CimSessions.ContainsKey($ComputerName)) {
try {
Remove-CimSession -CimSession $this.CimSessions[$ComputerName] -ErrorAction SilentlyContinue
}
catch { }
$this.CimSessions.Remove($ComputerName)
}
}

[void] Dispose() {
# Close all cached CIM sessions before tearing down
foreach ($cs in $this.CimSessions.Values) {
try { Remove-CimSession -CimSession $cs -ErrorAction SilentlyContinue } catch { }
}
$this.CimSessions.Clear()

if ($this.RunspacePool) {
$this.RunspacePool.Close()
$this.RunspacePool.Dispose()
}
}
}
36 changes: 36 additions & 0 deletions LazyWinAdminModule/LazyWinAdminModule.psd1
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
@{
RootModule = 'LazyWinAdminModule.psm1'
ModuleVersion = '1.3.0'
GUID = '1b9e5d4a-5c20-4e3a-b892-0b13d2f9a1c2'
Author = 'Francois-Xavier Cat (Modernized)'
CompanyName = 'LazyWinAdmin'
Copyright = '(c) LazyWinAdmin. All rights reserved.'
Description = 'Modernized LazyWinAdmin GUI Module — 2026 Edition (v1.3.0)'

# Minimum PS version per DEPS.SYSTEM in lazywinadmin.speq
PowerShellVersion = '7.4'

# Declaring runtime dependencies here produces a clear import-time error
# when a required module is absent, instead of a cryptic runtime failure.
# Maps directly to DEPS.RUNTIME in lazywinadmin.speq:
# microsoft-graph -> Microsoft.Graph.Authentication (core auth + context)
# az -> Az.Accounts (Connect-AzAccount, Get-AzContext)
# Az.ResourceGraph (Search-AzGraph)
# NOTE: ThreadJob is NOT listed here — Start-ThreadJob is an inbox cmdlet
# in PowerShell 7.4+ (enforced by PowerShellVersion = '7.4' above).
# Listing 'ThreadJob' in RequiredModules causes Import-Module to fail because
# PS cannot resolve it as a named module even though the cmdlet is available.
RequiredModules = @(
'Microsoft.Graph.Authentication',
'Microsoft.Graph.Users',
'Microsoft.Graph.Groups',
'Microsoft.Graph.DeviceManagement',
'Az.Accounts',
'Az.ResourceGraph'
)

FunctionsToExport = @('Start-LazyWinAdmin')
CmdletsToExport = @()
VariablesToExport = @()
AliasesToExport = @()
}
29 changes: 29 additions & 0 deletions LazyWinAdminModule/LazyWinAdminModule.psm1
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Strict mode according to powershell-windows skill
Set-StrictMode -Version Latest
$ErrorActionPreference = "Continue"

# Load Classes
$classFiles = Get-ChildItem -Path (Join-Path $PSScriptRoot "Classes") -Filter "*.ps1"
foreach ($file in $classFiles) {
. $file.FullName
}

# Load Private Functions
$privateFiles = Get-ChildItem -Path (Join-Path $PSScriptRoot "Private") -Filter "*.ps1"
foreach ($file in $privateFiles) {
. $file.FullName
}

# Load Public Functions
$publicFiles = Get-ChildItem -Path (Join-Path $PSScriptRoot "Public") -Filter "*.ps1"
foreach ($file in $publicFiles) {
. $file.FullName
}

# Load UI Functions
$uiFiles = Get-ChildItem -Path (Join-Path $PSScriptRoot "UI") -Filter "*.ps1"
foreach ($file in $uiFiles) {
. $file.FullName
}

Export-ModuleMember -Function Start-LazyWinAdmin
34 changes: 34 additions & 0 deletions LazyWinAdminModule/Private/Connect-ExchangeSession.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
function Connect-ExchangeSession {
<#
.SYNOPSIS
Connects to Exchange Online using modern authentication.
.DESCRIPTION
Checks for the ExchangeOnlineManagement module, imports it, and initiates
an interactive connection. Returns a status string — never surfaces
exception detail or credential fragments to the caller.
#>
[CmdletBinding()]
param (
[string]$UserPrincipalName
)

try {
if (-not (Get-Module -Name ExchangeOnlineManagement -ListAvailable)) {
return "[!] ExchangeOnlineManagement module not found. Install with: Install-Module ExchangeOnlineManagement -Scope CurrentUser"
}

Import-Module ExchangeOnlineManagement -ErrorAction Stop

$params = @{ ShowBanner = $false; ErrorAction = 'Stop' }
if ($UserPrincipalName) { $params.UserPrincipalName = $UserPrincipalName }

Connect-ExchangeOnline @params

$org = Get-OrganizationConfig -ErrorAction Stop
return "[OK] Connected to Exchange Online: $($org.DisplayName)"
}
catch {
Write-Verbose "Exchange connection exception: $_"
return "[!] Connection failed. Verify the ExchangeOnlineManagement module is installed and credentials are correct."
}
}
46 changes: 46 additions & 0 deletions LazyWinAdminModule/Private/Connect-ModernCloud.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
function Connect-ModernCloud {
<#
.SYNOPSIS
Connects to Microsoft Graph / Entra ID using modern authentication.
#>
[CmdletBinding()]
param (
[Parameter(Mandatory=$false)]
[string]$TenantId,

[string]$ClientId,

[SecureString]$ClientSecret,

[switch]$Interactive
)

try {
if ($Interactive) {
Write-Verbose "Triggering interactive login..."
Connect-MgGraph -Scopes "User.ReadBasic.All", "Group.Read.All", "DeviceManagementManagedDevices.Read.All", "DeviceManagementConfiguration.Read.All"
}
elseif ($ClientId -and $ClientSecret) {
Write-Verbose "Connecting via Service Principal..."
$credential = [System.Management.Automation.PSCredential]::new($ClientId, $ClientSecret)
$body = @{
TenantId = $TenantId
ClientId = $ClientId
ClientSecretCredential = $credential
}
Connect-MgGraph @body
}

$context = Get-MgContext
if ($context) {
# TenantId is internal; account is non-sensitive display info
return "[OK] Connected to Tenant: $($context.TenantId) as $($context.Account)"
}
return "[!] Authentication completed but Graph context could not be retrieved."
}
catch {
# Write full exception to Verbose only — never surface token fragments or credentials in the return value
Write-Verbose "Cloud connection exception detail: $_"
return "[!] Connection failed. Verify credentials and network connectivity."
}
}
31 changes: 31 additions & 0 deletions LazyWinAdminModule/Private/Get-AzureResourceSummary.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
function Get-AzureResourceSummary {
<#
.SYNOPSIS
Retrieves a summary of Azure resources grouped by type using Azure Resource Graph.
.DESCRIPTION
Uses Search-AzGraph (Az.ResourceGraph) for server-side aggregation.
Avoids Get-AzResource which enumerates all resources client-side and does not scale.
Requires Az.ResourceGraph module (included in Az).
#>
[CmdletBinding()]
param ()

process {
try {
$azContext = Get-AzContext
if (-not $azContext) {
Write-Warning "Not connected to Azure. Please run Connect-AzAccount."
return $null
}

$query = "Resources | summarize Count=count() by ResourceType=type | order by Count desc"
$results = Search-AzGraph -Query $query -ErrorAction Stop

return $results | Select-Object @{ N = 'Name'; E = { $_.ResourceType } }, Count
}
catch {
Write-Warning "Error querying Azure Resource Graph: $_"
return $null
}
}
}
78 changes: 78 additions & 0 deletions LazyWinAdminModule/Private/Get-ComputerADInfo.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
function Get-ComputerADInfo {
<#
.SYNOPSIS
Queries Active Directory for computer, user, or group objects.
.DESCRIPTION
Uses the ActiveDirectory module (RSAT). Requires rsat-ad-ds installed on the machine
running this function. Validates AdFilter input before passing to AD cmdlets.
Adheres to: ad_computer.* REQUIRES rsat-ad-module (CONTRACTS)
ad_user.samaccountname NEVER logged (CLASSIFY: pii)
#>
[CmdletBinding()]
param (
[Parameter(Mandatory=$true)]
[ValidateSet("Computer", "User", "Group")]
[string]$Type,

# AdFilter: the canonical search term for AD queries (never: ad_search, s, search_str)
[string]$AdFilter,

[string]$ComputerName
)

process {
try {
# Verify RSAT AD module is present — dep.system: rsat-ad-ds
if (-not (Get-Module -Name ActiveDirectory -ListAvailable -ErrorAction SilentlyContinue)) {
Write-Warning "ActiveDirectory module not found. Install via: Get-WindowsCapability -Name Rsat.ActiveDirectory* -Online | Add-WindowsCapability -Online"
return $null
}

Import-Module ActiveDirectory -ErrorAction Stop

# Validate AdFilter — only allow characters safe for LDAP filter strings
if ($AdFilter -and $AdFilter -match "[^a-zA-Z0-9\s\-\.\@_\*]") {
Write-Warning "AdFilter contains characters not permitted in an AD filter."
return $null
}

switch ($Type) {
"Computer" {
$ldapFilter = if ($AdFilter) { "Name -like '$AdFilter'" } `
elseif ($ComputerName) { "Name -eq '$ComputerName'" } `
else { "Name -like '*'" }

return Get-ADComputer -Filter $ldapFilter `
-Properties Description, OperatingSystem, OperatingSystemVersion,
LastLogonDate, DistinguishedName, Enabled -ErrorAction Stop |
Select-Object Name, DNSHostName, OperatingSystem, OperatingSystemVersion,
LastLogonDate, Enabled, Description, DistinguishedName
}
"User" {
$ldapFilter = if ($AdFilter) { "DisplayName -like '$AdFilter' -or SamAccountName -like '$AdFilter'" } `
else { "Enabled -eq `$true" }

# SamAccountName is pii — Select-Object excludes it from the returned object
# to prevent accidental logging in the UI layer
return Get-ADUser -Filter $ldapFilter `
-Properties DisplayName, EmailAddress, Department, Title,
LastLogonDate, Enabled -ErrorAction Stop |
Select-Object DisplayName, EmailAddress, Department, Title,
LastLogonDate, Enabled, DistinguishedName
}
"Group" {
$ldapFilter = if ($AdFilter) { "Name -like '$AdFilter'" } `
else { "Name -like '*'" }

return Get-ADGroup -Filter $ldapFilter `
-Properties Description, MemberOf -ErrorAction Stop |
Select-Object Name, SamAccountName, GroupCategory, GroupScope, Description
}
}
}
catch {
Write-Warning "Error querying Active Directory for $Type`: $_"
return $null
}
}
}
65 changes: 65 additions & 0 deletions LazyWinAdminModule/Private/Get-ComputerHardware.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
function Get-ComputerHardware {
<#
.SYNOPSIS
Retrieves hardware information (System, CPU, RAM, Disks) from a remote computer.
.DESCRIPTION
Opens a single CimSession and reuses it for all queries within the call.
Adheres to: cim_session.* ALWAYS reuse-before-create (CONTRACTS)
#>
[CmdletBinding()]
param (
[Parameter(Mandatory=$true)]
[string]$ComputerName
)

process {
$CimSession = $null
try {
$isLocal = $ComputerName -iin @('localhost', '127.0.0.1', $env:COMPUTERNAME)
$CimSession = if ($isLocal) { New-CimSession -ErrorAction Stop } else { New-CimSession -ComputerName $ComputerName -ErrorAction Stop }

$cs = Get-CimInstance -CimSession $CimSession -ClassName Win32_ComputerSystem -ErrorAction Stop
$os = Get-CimInstance -CimSession $CimSession -ClassName Win32_OperatingSystem -ErrorAction Stop
$bios = Get-CimInstance -CimSession $CimSession -ClassName Win32_Bios -ErrorAction Stop

$cpus = Get-CimInstance -CimSession $CimSession -ClassName Win32_Processor -ErrorAction Stop
$cpuInfo = $cpus | ForEach-Object { "$($_.Name) ($($_.NumberOfCores) Cores)" }

$mem = Get-CimInstance -CimSession $CimSession -ClassName Win32_PhysicalMemory -ErrorAction Stop
$totalRamBytes = ($mem | Measure-Object -Property Capacity -Sum).Sum
$totalRamGB = [Math]::Round($totalRamBytes / 1GB, 2)

$disks = Get-CimInstance -CimSession $CimSession -ClassName Win32_LogicalDisk `
-Filter "DriveType=3" -ErrorAction Stop

$diskResults = foreach ($d in $disks) {
[PSCustomObject]@{
DeviceID = $d.DeviceID
SizeGB = [Math]::Round($d.Size / 1GB, 2)
FreeGB = [Math]::Round($d.FreeSpace / 1GB, 2)
PercentFree = [Math]::Round(($d.FreeSpace / $d.Size) * 100, 2)
}
}

return [PSCustomObject]@{
Model = $cs.Model
Manufacturer = $cs.Manufacturer
RAM_GB = $totalRamGB
CPU = $cpuInfo -join ", "
OS = $os.Caption
OS_Version = $os.Version
SerialNumber = $bios.SerialNumber
Disks = $diskResults
}
}
catch {
Write-Warning "Error retrieving hardware on $ComputerName`: $_"
return $null
}
finally {
if ($null -ne $CimSession) {
Remove-CimSession -CimSession $CimSession -ErrorAction SilentlyContinue
}
}
}
}
Loading