PSGalleryManager.psm1
|
function Start-PSGalleryManager { <# .SYNOPSIS Launch the PowerShell GUI Module Manager. .DESCRIPTION Opens a WPF-based graphical tool for managing PowerShell modules. Features: Search and install from PSGallery, per-module scope detection, update checking, module details, bulk updates, batch operations, uninstall, and JSON cache for fast startup. Run as Administrator to manage AllUsers (system-wide) modules. .EXAMPLE Start-PSGalleryManager Launches the GUI module manager. .NOTES Author : Nick Geoffroy Cache stored in: %APPDATA%\PSGalleryManager\ #> [CmdletBinding()] param() Add-Type -AssemblyName PresentationFramework, PresentationCore, WindowsBase, System.Windows.Forms # Theme colours $accent = "#0078D4" $accentHover = "#106EBE" $accentLight = "#E8F1FB" $bgDark = "#1E1E1E" $bgPanel = "#252526" $bgCard = "#2D2D2D" $bgCardHover = "#333333" $fgPrimary = "#CCCCCC" $fgSecondary = "#999999" $fgBright = "#FFFFFF" $green = "#16C60C" $orange = "#F9A825" $red = "#E74856" $border = "#3E3E3E" $purple = "#B388FF" # Cache configuration $script:cacheDir = Join-Path $env:APPDATA "PSGalleryManager" $script:cacheInstalled = Join-Path $script:cacheDir "installed.json" $script:cacheUpdates = Join-Path $script:cacheDir "updates.json" if (-not (Test-Path $script:cacheDir)) { New-Item -Path $script:cacheDir -ItemType Directory -Force | Out-Null } [xml]$xaml = @" <Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="PowerShell Module Manager" Height="780" Width="1100" MinHeight="600" MinWidth="850" WindowStartupLocation="CenterScreen" Background="$bgDark" Foreground="$fgPrimary" FontFamily="Segoe UI" FontSize="13"> <Window.Resources> <Style TargetType="ScrollViewer"> <Setter Property="VerticalScrollBarVisibility" Value="Auto"/> </Style> <Style x:Key="BtnBase" TargetType="Button"> <Setter Property="Padding" Value="14,7"/> <Setter Property="Cursor" Value="Hand"/> <Setter Property="BorderThickness" Value="0"/> <Setter Property="FontSize" Value="12"/> <Setter Property="FontWeight" Value="SemiBold"/> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="Button"> <Border x:Name="bd" Background="{TemplateBinding Background}" CornerRadius="4" Padding="{TemplateBinding Padding}" BorderThickness="{TemplateBinding BorderThickness}" BorderBrush="{TemplateBinding BorderBrush}"> <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/> </Border> <ControlTemplate.Triggers> <Trigger Property="IsMouseOver" Value="True"> <Setter TargetName="bd" Property="Opacity" Value="0.85"/> </Trigger> <Trigger Property="IsEnabled" Value="False"> <Setter Property="Opacity" Value="0.4"/> </Trigger> </ControlTemplate.Triggers> </ControlTemplate> </Setter.Value> </Setter> </Style> <Style x:Key="BtnAccent" TargetType="Button" BasedOn="{StaticResource BtnBase}"> <Setter Property="Background" Value="$accent"/> <Setter Property="Foreground" Value="White"/> </Style> <Style x:Key="BtnDanger" TargetType="Button" BasedOn="{StaticResource BtnBase}"> <Setter Property="Background" Value="$red"/> <Setter Property="Foreground" Value="White"/> </Style> <Style x:Key="BtnGhost" TargetType="Button" BasedOn="{StaticResource BtnBase}"> <Setter Property="Background" Value="Transparent"/> <Setter Property="Foreground" Value="$fgPrimary"/> <Setter Property="BorderThickness" Value="1"/> <Setter Property="BorderBrush" Value="$border"/> </Style> <Style x:Key="BtnSuccess" TargetType="Button" BasedOn="{StaticResource BtnBase}"> <Setter Property="Background" Value="$green"/> <Setter Property="Foreground" Value="White"/> </Style> <Style TargetType="TextBox"> <Setter Property="Background" Value="$bgCard"/> <Setter Property="Foreground" Value="$fgBright"/> <Setter Property="BorderBrush" Value="$border"/> <Setter Property="BorderThickness" Value="1"/> <Setter Property="Padding" Value="8,6"/> <Setter Property="CaretBrush" Value="$fgBright"/> <Setter Property="SelectionBrush" Value="$accent"/> </Style> </Window.Resources> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> <RowDefinition Height="Auto"/> </Grid.RowDefinitions> <!-- HEADER --> <Border Grid.Row="0" Background="$bgPanel" BorderBrush="$border" BorderThickness="0,0,0,1" Padding="20,14"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center"> <TextBlock Text="PS" FontSize="18" FontWeight="Bold" Foreground="$accent" Margin="0,0,6,0" VerticalAlignment="Center"/> <TextBlock Text="Module Manager" FontSize="18" FontWeight="Bold" Foreground="$fgBright" VerticalAlignment="Center"/> <TextBlock Text="v1.3" FontSize="11" Foreground="$fgSecondary" Margin="10,4,0,0" VerticalAlignment="Center"/> </StackPanel> <Border Grid.Column="1" Margin="30,0" MaxWidth="550" CornerRadius="4" Background="$bgCard" BorderBrush="$border" BorderThickness="1"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto"/> <ColumnDefinition Width="*"/> <ColumnDefinition Width="Auto"/> </Grid.ColumnDefinitions> <TextBlock Grid.Column="0" Text=" > " FontSize="14" Margin="8,0,2,0" VerticalAlignment="Center" Foreground="$fgSecondary"/> <TextBox x:Name="txtSearch" Grid.Column="1" BorderThickness="0" Background="Transparent" VerticalAlignment="Center" FontSize="13"/> <Button x:Name="btnSearch" Grid.Column="2" Content="Search Gallery" Style="{StaticResource BtnAccent}" Margin="4" Padding="12,5"/> </Grid> </Border> </Grid> </Border> <!-- MAIN CONTENT --> <Grid Grid.Row="1"> <Grid.ColumnDefinitions> <ColumnDefinition Width="200"/> <ColumnDefinition Width="*"/> <ColumnDefinition x:Name="detailColumn" Width="0"/> </Grid.ColumnDefinitions> <!-- LEFT NAV --> <Border Grid.Column="0" Background="$bgPanel" BorderBrush="$border" BorderThickness="0,0,1,0"> <StackPanel Margin="0,8"> <Button x:Name="navInstalled" Content=" Installed Modules" Style="{StaticResource BtnBase}" Background="$accent" Foreground="White" HorizontalContentAlignment="Left" Padding="16,10" Margin="6,2" FontWeight="SemiBold"/> <Button x:Name="navUpdates" Style="{StaticResource BtnBase}" Background="Transparent" Foreground="$fgPrimary" HorizontalContentAlignment="Left" Padding="16,10" Margin="6,2"> <StackPanel Orientation="Horizontal"> <TextBlock Text=" Updates Available"/> <Border x:Name="badgeUpdates" Background="$orange" CornerRadius="8" Padding="6,1" Margin="8,0,0,0" Visibility="Collapsed"> <TextBlock x:Name="txtBadge" Text="0" FontSize="11" Foreground="$bgDark" FontWeight="Bold"/> </Border> </StackPanel> </Button> <Button x:Name="navSearch" Content=" Gallery Search" Style="{StaticResource BtnBase}" Background="Transparent" Foreground="$fgPrimary" HorizontalContentAlignment="Left" Padding="16,10" Margin="6,2"/> <Separator Background="$border" Margin="16,10"/> <Button x:Name="btnRefresh" Content=" Refresh (live scan)" Style="{StaticResource BtnGhost}" Margin="12,4" Padding="12,8"/> <Button x:Name="btnClearCache" Content=" Clear Cache" Style="{StaticResource BtnGhost}" Margin="12,4" Padding="12,8"/> <Button x:Name="btnUpdateAll" Content=" Update All" Style="{StaticResource BtnSuccess}" Margin="12,4" Padding="12,8" Visibility="Collapsed"/> <Separator Background="$border" Margin="16,10"/> <TextBlock x:Name="txtCacheAge" Text="" Foreground="$fgSecondary" FontSize="10.5" Margin="16,2" TextWrapping="Wrap"/> </StackPanel> </Border> <!-- CENTER: MODULE LIST --> <Grid Grid.Column="1"> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <Border Grid.Row="0" Padding="16,10" Background="$bgDark" BorderBrush="$border" BorderThickness="0,0,0,1"> <Grid> <TextBlock x:Name="txtViewTitle" Text="Installed Modules" FontSize="15" FontWeight="SemiBold" Foreground="$fgBright" VerticalAlignment="Center"/> <TextBlock x:Name="txtCount" HorizontalAlignment="Right" Foreground="$fgSecondary" VerticalAlignment="Center" FontSize="12"/> </Grid> </Border> <!-- BATCH ACTION BAR --> <Border x:Name="pnlBatchBar" Grid.Row="1" Padding="12,8" Background="#1A0078D4" BorderBrush="$accent" BorderThickness="0,0,0,1" Visibility="Collapsed"> <Grid> <StackPanel Orientation="Horizontal" VerticalAlignment="Center"> <TextBlock x:Name="txtSelected" Text="0 selected" Foreground="$fgBright" FontWeight="SemiBold" FontSize="12" VerticalAlignment="Center" Margin="4,0,12,0"/> <Button x:Name="btnBatchUpdate" Content="Update Selected" Style="{StaticResource BtnSuccess}" Padding="10,5" Margin="0,0,8,0" Visibility="Collapsed"/> <Button x:Name="btnBatchUninstall" Content="Uninstall Selected" Style="{StaticResource BtnDanger}" Padding="10,5" Margin="0,0,8,0" Visibility="Collapsed"/> </StackPanel> <Button x:Name="btnClearSelection" Content="Clear Selection" Style="{StaticResource BtnGhost}" HorizontalAlignment="Right" Padding="10,5"/> </Grid> </Border> <ListBox x:Name="lstModules" Grid.Row="2" Background="Transparent" BorderThickness="0" ScrollViewer.HorizontalScrollBarVisibility="Disabled" SelectionMode="Extended" HorizontalContentAlignment="Stretch" Padding="8"> <ListBox.ItemContainerStyle> <Style TargetType="ListBoxItem"> <Setter Property="Padding" Value="0"/> <Setter Property="Margin" Value="0,2"/> <Setter Property="HorizontalContentAlignment" Value="Stretch"/> <Setter Property="Background" Value="Transparent"/> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="ListBoxItem"> <Border x:Name="Bd" Background="$bgCard" CornerRadius="6" Padding="14,10" BorderBrush="$border" BorderThickness="1" Margin="4,2"> <ContentPresenter/> </Border> <ControlTemplate.Triggers> <Trigger Property="IsMouseOver" Value="True"> <Setter TargetName="Bd" Property="Background" Value="$bgCardHover"/> <Setter TargetName="Bd" Property="BorderBrush" Value="$accent"/> </Trigger> <Trigger Property="IsSelected" Value="True"> <Setter TargetName="Bd" Property="Background" Value="#1A0078D4"/> <Setter TargetName="Bd" Property="BorderBrush" Value="$accent"/> </Trigger> </ControlTemplate.Triggers> </ControlTemplate> </Setter.Value> </Setter> </Style> </ListBox.ItemContainerStyle> </ListBox> <Border x:Name="pnlLoading" Grid.Row="2" Background="#CC1E1E1E" Visibility="Collapsed"> <StackPanel VerticalAlignment="Center" HorizontalAlignment="Center"> <TextBlock x:Name="txtLoading" Text="Loading..." FontSize="16" Foreground="$fgBright" HorizontalAlignment="Center"/> <ProgressBar x:Name="progBar" IsIndeterminate="True" Width="260" Height="4" Margin="0,12,0,0" Foreground="$accent" Background="$bgCard"/> </StackPanel> </Border> <Border x:Name="pnlEmpty" Grid.Row="2" Visibility="Collapsed"> <StackPanel VerticalAlignment="Center" HorizontalAlignment="Center"> <TextBlock Text="(empty)" FontSize="28" HorizontalAlignment="Center" Foreground="$fgSecondary"/> <TextBlock x:Name="txtEmpty" Text="No modules found" FontSize="15" Foreground="$fgSecondary" Margin="0,8,0,0" HorizontalAlignment="Center"/> </StackPanel> </Border> </Grid> <!-- RIGHT: DETAIL PANEL --> <Border Grid.Column="2" Background="$bgPanel" BorderBrush="$border" BorderThickness="1,0,0,0"> <ScrollViewer Padding="18,14"> <StackPanel x:Name="pnlDetail"> <Grid> <TextBlock x:Name="txtDetailName" FontSize="18" FontWeight="Bold" Foreground="$fgBright" TextWrapping="Wrap"/> <Button x:Name="btnCloseDetail" Content="X" HorizontalAlignment="Right" Style="{StaticResource BtnBase}" Background="Transparent" Foreground="$fgSecondary" Padding="6,2" FontSize="14"/> </Grid> <TextBlock x:Name="txtDetailVersion" Foreground="$fgSecondary" Margin="0,4,0,0"/> <TextBlock x:Name="txtDetailAuthor" Foreground="$fgSecondary" Margin="0,2,0,0"/> <TextBlock x:Name="txtDetailScope" Foreground="$purple" Margin="0,2,0,0" FontWeight="SemiBold"/> <Separator Background="$border" Margin="0,12"/> <TextBlock Text="Description" FontWeight="SemiBold" Foreground="$fgBright"/> <TextBlock x:Name="txtDetailDesc" TextWrapping="Wrap" Foreground="$fgPrimary" Margin="0,6,0,0" LineHeight="20"/> <Separator Background="$border" Margin="0,12"/> <Grid x:Name="gridInfo" Margin="0,0,0,8"> <Grid.ColumnDefinitions> <ColumnDefinition Width="110"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <Grid.RowDefinitions> <RowDefinition/><RowDefinition/><RowDefinition/> <RowDefinition/><RowDefinition/><RowDefinition/> </Grid.RowDefinitions> <TextBlock Grid.Row="0" Grid.Column="0" Text="Published" Foreground="$fgSecondary" Margin="0,3"/> <TextBlock x:Name="txtDetailDate" Grid.Row="0" Grid.Column="1" Foreground="$fgPrimary" Margin="0,3"/> <TextBlock Grid.Row="1" Grid.Column="0" Text="Downloads" Foreground="$fgSecondary" Margin="0,3"/> <TextBlock x:Name="txtDetailDownloads" Grid.Row="1" Grid.Column="1" Foreground="$fgPrimary" Margin="0,3"/> <TextBlock Grid.Row="2" Grid.Column="0" Text="License" Foreground="$fgSecondary" Margin="0,3"/> <TextBlock x:Name="txtDetailLicense" Grid.Row="2" Grid.Column="1" Foreground="$fgPrimary" Margin="0,3"/> <TextBlock Grid.Row="3" Grid.Column="0" Text="Project URL" Foreground="$fgSecondary" Margin="0,3"/> <TextBlock x:Name="txtDetailUrl" Grid.Row="3" Grid.Column="1" Foreground="$accent" Margin="0,3" Cursor="Hand" TextWrapping="Wrap" TextDecorations="Underline"/> <TextBlock Grid.Row="4" Grid.Column="0" Text="Tags" Foreground="$fgSecondary" Margin="0,3"/> <TextBlock x:Name="txtDetailTags" Grid.Row="4" Grid.Column="1" Foreground="$fgPrimary" TextWrapping="Wrap" Margin="0,3"/> <TextBlock Grid.Row="5" Grid.Column="0" Text="Dependencies" Foreground="$fgSecondary" Margin="0,3"/> <TextBlock x:Name="txtDetailDeps" Grid.Row="5" Grid.Column="1" Foreground="$fgPrimary" TextWrapping="Wrap" Margin="0,3"/> </Grid> <Separator Background="$border" Margin="0,6"/> <WrapPanel x:Name="pnlActions" Margin="0,10,0,0"> <Button x:Name="btnDetailInstall" Content="Install" Style="{StaticResource BtnAccent}" Margin="0,0,8,8" Visibility="Collapsed"/> <Button x:Name="btnDetailUpdate" Content="Update" Style="{StaticResource BtnSuccess}" Margin="0,0,8,8" Visibility="Collapsed"/> <Button x:Name="btnDetailUninstall" Content="Uninstall" Style="{StaticResource BtnDanger}" Margin="0,0,8,8" Visibility="Collapsed"/> <Button x:Name="btnOpenGallery" Content="PS Gallery" Style="{StaticResource BtnGhost}" Margin="0,0,8,8"/> <Button x:Name="btnOpenLocation" Content="Open Location" Style="{StaticResource BtnGhost}" Margin="0,0,8,8" Visibility="Collapsed"/> </WrapPanel> </StackPanel> </ScrollViewer> </Border> </Grid> <!-- STATUS BAR --> <Border Grid.Row="2" Background="$bgPanel" BorderBrush="$border" BorderThickness="0,1,0,0" Padding="14,6"> <Grid> <TextBlock x:Name="txtStatus" Text="Ready" Foreground="$fgSecondary" FontSize="11.5"/> <StackPanel Orientation="Horizontal" HorizontalAlignment="Right"> <TextBlock x:Name="txtCacheStatus" Text="" Foreground="$fgSecondary" FontSize="11.5" Margin="0,0,16,0"/> <TextBlock x:Name="txtIsAdmin" Foreground="$fgSecondary" FontSize="11.5"/> </StackPanel> </Grid> </Border> </Grid> </Window> "@ # Build Window $reader = New-Object System.Xml.XmlNodeReader $xaml $window = [Windows.Markup.XamlReader]::Load($reader) $ui = @{} $xaml.SelectNodes('//*[@*[contains(translate(name(),"X","x"),"x:Name")]]') | ForEach-Object { $ui[$_.Name] = $window.FindName($_.Name) } # State $script:currentView = 'installed' $script:installedCache = @() $script:updatesCache = @() $script:searchResults = @() $script:selectedModule = $null # Admin detection $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if ($isAdmin) { $ui.txtIsAdmin.Text = "Administrator" } else { $ui.txtIsAdmin.Text = "Standard User" $ui.txtIsAdmin.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString($orange) } # Install scope: admin = AllUsers, standard = CurrentUser $script:installScope = if ($isAdmin) { 'AllUsers' } else { 'CurrentUser' } # Check if -AcceptLicense is supported (PowerShellGet 1.6+) $script:supportsAcceptLicense = $false try { $installCmd = Get-Command Install-Module -ErrorAction SilentlyContinue if ($installCmd.Parameters.ContainsKey('AcceptLicense')) { $script:supportsAcceptLicense = $true } } catch {} # -- Helpers -- function Set-Status { param([string]$msg) $ui.txtStatus.Text = $msg [System.Windows.Forms.Application]::DoEvents() } function Show-Loading { param([string]$msg = "Loading...") $ui.txtLoading.Text = $msg $ui.pnlLoading.Visibility = 'Visible' $ui.pnlEmpty.Visibility = 'Collapsed' [System.Windows.Forms.Application]::DoEvents() } function Hide-Loading { $ui.pnlLoading.Visibility = 'Collapsed' [System.Windows.Forms.Application]::DoEvents() } function Format-Downloads { param([long]$n) if ($n -ge 1000000) { return "{0:N1}M" -f ($n / 1000000) } elseif ($n -ge 1000) { return "{0:N1}K" -f ($n / 1000) } else { return $n.ToString() } } function Format-CacheAge { param([datetime]$cachedAt) $span = (Get-Date) - $cachedAt if ($span.TotalMinutes -lt 1) { return "just now" } if ($span.TotalMinutes -lt 60) { return [math]::Floor($span.TotalMinutes).ToString() + "m ago" } if ($span.TotalHours -lt 24) { return [math]::Floor($span.TotalHours).ToString() + "h ago" } return [math]::Floor($span.TotalDays).ToString() + "d ago" } function Update-CacheAgeDisplay { $lines = @() if (Test-Path $script:cacheInstalled) { try { $data = Get-Content $script:cacheInstalled -Raw | ConvertFrom-Json $age = Format-CacheAge -cachedAt ([datetime]$data.CachedAt) $lines += "Installed: " + $age } catch {} } if (Test-Path $script:cacheUpdates) { try { $data = Get-Content $script:cacheUpdates -Raw | ConvertFrom-Json $age = Format-CacheAge -cachedAt ([datetime]$data.CachedAt) $lines += "Updates: " + $age } catch {} } if ($lines.Count -gt 0) { $ui.txtCacheAge.Text = "Cache:`n" + ($lines -join "`n") $ui.txtCacheStatus.Text = "Cached" } else { $ui.txtCacheAge.Text = "Cache: none" $ui.txtCacheStatus.Text = "No cache" } } # Detect module scope from install path function Get-ModuleScope { param([string]$installedLocation) if ([string]::IsNullOrWhiteSpace($installedLocation)) { return "Unknown" } $userProfile = $env:USERPROFILE if ($installedLocation.StartsWith($userProfile, [System.StringComparison]::OrdinalIgnoreCase)) { return "CurrentUser" } return "AllUsers" } function Set-NavActive { param([string]$name) $brushTransparent = [System.Windows.Media.BrushConverter]::new().ConvertFromString('Transparent') $brushFg = [System.Windows.Media.BrushConverter]::new().ConvertFromString($fgPrimary) $brushAccent = [System.Windows.Media.BrushConverter]::new().ConvertFromString($accent) $brushBright = [System.Windows.Media.BrushConverter]::new().ConvertFromString($fgBright) foreach ($btn in @('navInstalled','navUpdates','navSearch')) { $ui[$btn].Background = $brushTransparent $ui[$btn].Foreground = $brushFg $ui[$btn].FontWeight = 'Normal' } $map = @{ 'installed' = 'navInstalled'; 'updates' = 'navUpdates'; 'search' = 'navSearch' } $active = $map[$name] if ($active -and $ui.ContainsKey($active)) { $ui[$active].Background = $brushAccent $ui[$active].Foreground = $brushBright $ui[$active].FontWeight = 'SemiBold' } } # -- JSON Cache Functions -- function Save-CacheInstalled { param([array]$modules) try { $export = @() foreach ($m in $modules) { $export += @{ Name = [string]$m.Name Version = [string]$m.Version Author = [string]$m.Author Description = [string]$m._Description PublishedDate = [string]$m._PublishedDate License = [string]$m._License ProjectUri = [string]$m._ProjectUri Tags = [string]$m._Tags Dependencies = [string]$m._Dependencies Downloads = [long]$m._Downloads Status = [string]$m._Status LatestVersion = [string]$m._LatestVersion Scope = [string]$m._Scope InstalledLocation = [string]$m._InstalledLocation } } $wrapper = @{ CachedAt = (Get-Date).ToString("o") Modules = $export } $wrapper | ConvertTo-Json -Depth 4 | Set-Content -Path $script:cacheInstalled -Encoding UTF8 -Force } catch {} } function Save-CacheUpdates { param([array]$modules) try { $export = @() foreach ($m in $modules) { $export += @{ Name = [string]$m.Name Version = [string]$m.Version Author = [string]$m.Author Description = [string]$m._Description PublishedDate = [string]$m._PublishedDate License = [string]$m._License ProjectUri = [string]$m._ProjectUri Tags = [string]$m._Tags Dependencies = [string]$m._Dependencies Downloads = [long]$m._Downloads Status = [string]$m._Status LatestVersion = [string]$m._LatestVersion Scope = [string]$m._Scope InstalledLocation = [string]$m._InstalledLocation } } $wrapper = @{ CachedAt = (Get-Date).ToString("o") Modules = $export } $wrapper | ConvertTo-Json -Depth 4 | Set-Content -Path $script:cacheUpdates -Encoding UTF8 -Force } catch {} } function Load-CacheFile { param([string]$path) if (-not (Test-Path $path)) { return $null } try { $raw = Get-Content $path -Raw | ConvertFrom-Json $modules = @() foreach ($entry in $raw.Modules) { $obj = New-Object PSObject $obj | Add-Member -NotePropertyName 'Name' -NotePropertyValue $entry.Name $obj | Add-Member -NotePropertyName 'Version' -NotePropertyValue $entry.Version $obj | Add-Member -NotePropertyName 'Author' -NotePropertyValue $entry.Author $obj | Add-Member -NotePropertyName 'Description' -NotePropertyValue $entry.Description $obj | Add-Member -NotePropertyName '_Status' -NotePropertyValue $entry.Status $obj | Add-Member -NotePropertyName '_Description' -NotePropertyValue $entry.Description $obj | Add-Member -NotePropertyName '_Downloads' -NotePropertyValue ([long]$entry.Downloads) $obj | Add-Member -NotePropertyName '_PublishedDate' -NotePropertyValue $entry.PublishedDate $obj | Add-Member -NotePropertyName '_License' -NotePropertyValue $entry.License $obj | Add-Member -NotePropertyName '_ProjectUri' -NotePropertyValue $entry.ProjectUri $obj | Add-Member -NotePropertyName '_Tags' -NotePropertyValue $entry.Tags $obj | Add-Member -NotePropertyName '_Dependencies' -NotePropertyValue $entry.Dependencies $obj | Add-Member -NotePropertyName '_LatestVersion' -NotePropertyValue $entry.LatestVersion $obj | Add-Member -NotePropertyName '_Scope' -NotePropertyValue $entry.Scope $obj | Add-Member -NotePropertyName '_InstalledLocation' -NotePropertyValue $entry.InstalledLocation $modules += $obj } return @{ CachedAt = [datetime]$raw.CachedAt Modules = $modules } } catch { return $null } } function Clear-AllCache { if (Test-Path $script:cacheInstalled) { Remove-Item $script:cacheInstalled -Force } if (Test-Path $script:cacheUpdates) { Remove-Item $script:cacheUpdates -Force } Update-CacheAgeDisplay } # -- Build module card -- function New-ModuleCard { param($mod) $brushBright = [System.Windows.Media.BrushConverter]::new().ConvertFromString($fgBright) $brushSec = [System.Windows.Media.BrushConverter]::new().ConvertFromString($fgSecondary) $brushAccent = [System.Windows.Media.BrushConverter]::new().ConvertFromString($accent) $brushGreen = [System.Windows.Media.BrushConverter]::new().ConvertFromString($green) $brushOrange = [System.Windows.Media.BrushConverter]::new().ConvertFromString($orange) $brushTransp = [System.Windows.Media.BrushConverter]::new().ConvertFromString('Transparent') $brushPurple = [System.Windows.Media.BrushConverter]::new().ConvertFromString($purple) $grid = New-Object System.Windows.Controls.Grid $grid.Tag = $mod $col1 = New-Object System.Windows.Controls.ColumnDefinition $col1.Width = [System.Windows.GridLength]::new(1, 'Star') $col2 = New-Object System.Windows.Controls.ColumnDefinition $col2.Width = [System.Windows.GridLength]::Auto $grid.ColumnDefinitions.Add($col1) $grid.ColumnDefinitions.Add($col2) # Left side $left = New-Object System.Windows.Controls.StackPanel [System.Windows.Controls.Grid]::SetColumn($left, 0) $nameRow = New-Object System.Windows.Controls.StackPanel $nameRow.Orientation = 'Horizontal' $txtName = New-Object System.Windows.Controls.TextBlock $txtName.Text = $mod.Name $txtName.FontWeight = 'SemiBold' $txtName.FontSize = 14 $txtName.Foreground = $brushBright $nameRow.Children.Add($txtName) | Out-Null $txtVer = New-Object System.Windows.Controls.TextBlock $txtVer.Text = " v" + $mod.Version $txtVer.Foreground = $brushSec $txtVer.FontSize = 12 $txtVer.VerticalAlignment = 'Center' $nameRow.Children.Add($txtVer) | Out-Null # Scope badge (for installed modules) if ($mod._Scope -and $mod._Scope -ne 'NotInstalled') { $scopeBadge = New-Object System.Windows.Controls.Border $scopeBadge.CornerRadius = [System.Windows.CornerRadius]::new(4) $scopeBadge.Padding = [System.Windows.Thickness]::new(5, 1, 5, 1) $scopeBadge.Margin = [System.Windows.Thickness]::new(6, 0, 0, 0) $scopeBadge.VerticalAlignment = 'Center' $scopeBadge.Background = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#1AB388FF') $scopeTxt = New-Object System.Windows.Controls.TextBlock $scopeTxt.FontSize = 10 $scopeTxt.Foreground = $brushPurple $scopeTxt.FontWeight = 'SemiBold' if ($mod._Scope -eq 'AllUsers') { $scopeTxt.Text = 'AllUsers' } else { $scopeTxt.Text = 'User' } $scopeBadge.Child = $scopeTxt $nameRow.Children.Add($scopeBadge) | Out-Null } # Status badge if ($mod._Status) { $badge = New-Object System.Windows.Controls.Border $badge.CornerRadius = [System.Windows.CornerRadius]::new(4) $badge.Padding = [System.Windows.Thickness]::new(6, 1, 6, 1) $badge.Margin = [System.Windows.Thickness]::new(6, 0, 0, 0) $badge.VerticalAlignment = 'Center' $badgeTxt = New-Object System.Windows.Controls.TextBlock $badgeTxt.FontSize = 10.5 $badgeTxt.FontWeight = 'SemiBold' if ($mod._Status -eq 'Installed') { $badge.Background = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#1A16C60C') $badgeTxt.Foreground = $brushGreen $badgeTxt.Text = 'Installed' } elseif ($mod._Status -eq 'Update') { $badge.Background = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#1AF9A825') $badgeTxt.Foreground = $brushOrange $badgeTxt.Text = "Update -> " + $mod._LatestVersion } elseif ($mod._Status -eq 'NotInstalled') { $badge.Background = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#1A0078D4') $badgeTxt.Foreground = $brushAccent $badgeTxt.Text = 'Available' } $badge.Child = $badgeTxt $nameRow.Children.Add($badge) | Out-Null } $left.Children.Add($nameRow) | Out-Null # Description $txtDesc = New-Object System.Windows.Controls.TextBlock $descText = "" if ($mod.Description) { $descText = $mod.Description } elseif ($mod._Description) { $descText = $mod._Description } if ($descText.Length -gt 140) { $descText = $descText.Substring(0,137) + "..." } $txtDesc.Text = $descText $txtDesc.Foreground = $brushSec $txtDesc.FontSize = 12 $txtDesc.Margin = [System.Windows.Thickness]::new(0, 4, 20, 0) $txtDesc.TextWrapping = 'Wrap' $left.Children.Add($txtDesc) | Out-Null # Author + downloads $metaRow = New-Object System.Windows.Controls.StackPanel $metaRow.Orientation = 'Horizontal' $metaRow.Margin = [System.Windows.Thickness]::new(0, 4, 0, 0) if ($mod.Author) { $txtAuth = New-Object System.Windows.Controls.TextBlock $txtAuth.Text = "by " + $mod.Author $txtAuth.Foreground = $brushSec $txtAuth.FontSize = 11 $txtAuth.Margin = [System.Windows.Thickness]::new(0, 0, 12, 0) $metaRow.Children.Add($txtAuth) | Out-Null } if ($mod._Downloads -gt 0) { $txtDl = New-Object System.Windows.Controls.TextBlock $txtDl.Text = "Downloads: " + (Format-Downloads $mod._Downloads) $txtDl.Foreground = $brushSec $txtDl.FontSize = 11 $metaRow.Children.Add($txtDl) | Out-Null } $left.Children.Add($metaRow) | Out-Null $grid.Children.Add($left) | Out-Null # Right side: quick action $right = New-Object System.Windows.Controls.StackPanel $right.VerticalAlignment = 'Center' [System.Windows.Controls.Grid]::SetColumn($right, 1) $btnQuick = New-Object System.Windows.Controls.Button $btnQuick.Padding = [System.Windows.Thickness]::new(10, 5, 10, 5) $btnQuick.FontSize = 11.5 $btnQuick.Cursor = [System.Windows.Input.Cursors]::Hand $btnQuick.Tag = $mod if ($mod._Status -eq 'Update') { $btnQuick.Content = 'Update' $btnQuick.Background = $brushGreen $btnQuick.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('White') $btnQuick.Add_Click({ $m = $this.Tag Invoke-ModuleAction -Action 'Update' -Module $m }) } elseif ($mod._Status -eq 'NotInstalled') { $btnQuick.Content = 'Install' $btnQuick.Background = $brushAccent $btnQuick.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('White') $btnQuick.Add_Click({ $m = $this.Tag Invoke-ModuleAction -Action 'Install' -Module $m }) } else { $btnQuick.Content = 'Details >' $btnQuick.Background = $brushTransp $btnQuick.Foreground = $brushAccent $btnQuick.Add_Click({ $m = $this.Tag Show-Detail -mod $m }) } $right.Children.Add($btnQuick) | Out-Null $grid.Children.Add($right) | Out-Null return $grid } # Populate list function Update-ModuleList { param([array]$modules, [string]$emptyMsg = "No modules found") $ui.lstModules.Items.Clear() $ui.pnlBatchBar.Visibility = 'Collapsed' if ($modules.Count -eq 0) { $ui.pnlEmpty.Visibility = 'Visible' $ui.txtEmpty.Text = $emptyMsg $ui.txtCount.Text = "0 modules" } else { $ui.pnlEmpty.Visibility = 'Collapsed' $suffix = "" if ($modules.Count -ne 1) { $suffix = "s" } $ui.txtCount.Text = "$($modules.Count) module$suffix" foreach ($mod in $modules) { $card = New-ModuleCard -mod $mod $ui.lstModules.Items.Add($card) | Out-Null } } [System.Windows.Forms.Application]::DoEvents() } # Detail panel function Show-Detail { param($mod) $script:selectedModule = $mod $ui.detailColumn.Width = [System.Windows.GridLength]::new(320) $ui.txtDetailName.Text = $mod.Name $ui.txtDetailVersion.Text = "Version: " + $mod.Version if ($mod.Author) { $ui.txtDetailAuthor.Text = "Author: " + $mod.Author } else { $ui.txtDetailAuthor.Text = "" } # Scope display if ($mod._Scope -eq 'AllUsers') { $ui.txtDetailScope.Text = "Scope: AllUsers (system-wide)" } elseif ($mod._Scope -eq 'CurrentUser') { $ui.txtDetailScope.Text = "Scope: CurrentUser" } else { $ui.txtDetailScope.Text = "" } $desc = "" if ($mod.Description) { $desc = $mod.Description } elseif ($mod._Description) { $desc = $mod._Description } else { $desc = "No description available." } $ui.txtDetailDesc.Text = $desc if ($mod._PublishedDate) { $ui.txtDetailDate.Text = $mod._PublishedDate } else { $ui.txtDetailDate.Text = "-" } if ($mod._Downloads -gt 0) { $ui.txtDetailDownloads.Text = Format-Downloads $mod._Downloads } else { $ui.txtDetailDownloads.Text = "-" } if ($mod._License) { $ui.txtDetailLicense.Text = $mod._License } else { $ui.txtDetailLicense.Text = "-" } if ($mod._ProjectUri) { $ui.txtDetailUrl.Text = $mod._ProjectUri } else { $ui.txtDetailUrl.Text = "-" } if ($mod._Tags) { $ui.txtDetailTags.Text = $mod._Tags } else { $ui.txtDetailTags.Text = "-" } if ($mod._Dependencies) { $ui.txtDetailDeps.Text = $mod._Dependencies } else { $ui.txtDetailDeps.Text = "None" } if ($mod._Status -eq 'NotInstalled') { $ui.btnDetailInstall.Visibility = 'Visible' } else { $ui.btnDetailInstall.Visibility = 'Collapsed' } if ($mod._Status -eq 'Update') { $ui.btnDetailUpdate.Visibility = 'Visible' } else { $ui.btnDetailUpdate.Visibility = 'Collapsed' } if ($mod._Status -eq 'Installed' -or $mod._Status -eq 'Update') { $ui.btnDetailUninstall.Visibility = 'Visible' } else { $ui.btnDetailUninstall.Visibility = 'Collapsed' } if ($mod._InstalledLocation -and $mod._InstalledLocation -ne '') { $ui.btnOpenLocation.Visibility = 'Visible' } else { $ui.btnOpenLocation.Visibility = 'Collapsed' } } function Hide-Detail { $script:selectedModule = $null $ui.detailColumn.Width = [System.Windows.GridLength]::new(0) } # Enriches a live module object with custom properties (including scope detection) function Add-ModuleProps { param($m, [string]$Status, [long]$Downloads = 0, [string]$LatestVersion = "") $pubDate = "" try { $pubDate = $m.PublishedDate.ToString('yyyy-MM-dd') } catch {} $tags = "" try { $tags = ($m.Tags -join ', ') } catch {} $deps = "" try { $deps = ($m.Dependencies.Name -join ', ') } catch {} # Detect scope from install path $detectedScope = "Unknown" $installPath = "" try { if ($m.InstalledLocation) { $installPath = $m.InstalledLocation $detectedScope = Get-ModuleScope -installedLocation $installPath } } catch {} $m | Add-Member -NotePropertyName '_Status' -NotePropertyValue $Status -Force -PassThru | Add-Member -NotePropertyName '_Description' -NotePropertyValue $m.Description -Force -PassThru | Add-Member -NotePropertyName '_Downloads' -NotePropertyValue $Downloads -Force -PassThru | Add-Member -NotePropertyName '_PublishedDate' -NotePropertyValue $pubDate -Force -PassThru | Add-Member -NotePropertyName '_License' -NotePropertyValue $m.LicenseUri -Force -PassThru | Add-Member -NotePropertyName '_ProjectUri' -NotePropertyValue $m.ProjectUri -Force -PassThru | Add-Member -NotePropertyName '_Tags' -NotePropertyValue $tags -Force -PassThru | Add-Member -NotePropertyName '_Dependencies' -NotePropertyValue $deps -Force -PassThru | Add-Member -NotePropertyName '_LatestVersion' -NotePropertyValue $LatestVersion -Force -PassThru | Add-Member -NotePropertyName '_Scope' -NotePropertyValue $detectedScope -Force -PassThru | Add-Member -NotePropertyName '_InstalledLocation' -NotePropertyValue $installPath -Force -PassThru } # -- Data Loaders (with cache support) -- function Load-InstalledModules { param([switch]$Force) $script:currentView = 'installed' Set-NavActive -name 'installed' $ui.txtViewTitle.Text = 'Installed Modules' Hide-Detail # Try cache first (unless forced) if (-not $Force) { $cached = Load-CacheFile -path $script:cacheInstalled if ($cached) { $script:installedCache = @($cached.Modules) $age = Format-CacheAge -cachedAt $cached.CachedAt Update-ModuleList -modules $script:installedCache -emptyMsg "No modules installed via PowerShellGet." Set-Status -msg ("Loaded " + $script:installedCache.Count.ToString() + " module(s) from cache (" + $age + ")") Update-CacheAgeDisplay return } } # Live scan Show-Loading -msg "Scanning installed modules..." Set-Status -msg "Loading installed modules (live scan)..." try { $mods = Get-InstalledModule -ErrorAction SilentlyContinue | Sort-Object Name | ForEach-Object { Add-ModuleProps -m $_ -Status 'Installed' } $script:installedCache = @($mods) Update-ModuleList -modules $script:installedCache -emptyMsg "No modules installed via PowerShellGet." Set-Status -msg ("Found " + $script:installedCache.Count.ToString() + " installed module(s). Cache saved.") Save-CacheInstalled -modules $script:installedCache Update-CacheAgeDisplay } catch { Set-Status -msg ("Error: " + $_.Exception.Message) } finally { Hide-Loading } } function Load-Updates { param([switch]$Force) $script:currentView = 'updates' Set-NavActive -name 'updates' $ui.txtViewTitle.Text = 'Available Updates' Hide-Detail # Try cache first (unless forced) if (-not $Force) { $cached = Load-CacheFile -path $script:cacheUpdates if ($cached) { $script:updatesCache = @($cached.Modules) $age = Format-CacheAge -cachedAt $cached.CachedAt Update-ModuleList -modules $script:updatesCache -emptyMsg "All modules are up to date!" if ($script:updatesCache.Count -gt 0) { $ui.badgeUpdates.Visibility = 'Visible' $ui.txtBadge.Text = $script:updatesCache.Count.ToString() $ui.btnUpdateAll.Visibility = 'Visible' } else { $ui.badgeUpdates.Visibility = 'Collapsed' $ui.btnUpdateAll.Visibility = 'Collapsed' } Set-Status -msg ("Loaded " + $script:updatesCache.Count.ToString() + " update(s) from cache (" + $age + ")") Update-CacheAgeDisplay return } } # Live scan Show-Loading -msg "Checking for updates... (this may take a moment)" Set-Status -msg "Checking for module updates (live scan)..." try { if ($script:installedCache.Count -eq 0) { $script:installedCache = @(Get-InstalledModule -ErrorAction SilentlyContinue | Sort-Object Name | ForEach-Object { Add-ModuleProps -m $_ -Status 'Installed' }) Save-CacheInstalled -modules $script:installedCache } $updates = @() $i = 0 foreach ($mod in $script:installedCache) { $i++ $ui.txtLoading.Text = "Checking $i of $($script:installedCache.Count): " + $mod.Name [System.Windows.Forms.Application]::DoEvents() try { $online = Find-Module -Name $mod.Name -ErrorAction SilentlyContinue | Select-Object -First 1 if ($online) { $localVer = [version]$mod.Version $remoteVer = [version]$online.Version if ($remoteVer -gt $localVer) { $mod._Status = 'Update' $mod._LatestVersion = $online.Version.ToString() try { $mod._Downloads = [long]$online.AdditionalMetadata.downloadCount } catch {} $updates += $mod } } } catch {} } $script:updatesCache = $updates Update-ModuleList -modules $script:updatesCache -emptyMsg "All modules are up to date!" if ($updates.Count -gt 0) { $ui.badgeUpdates.Visibility = 'Visible' $ui.txtBadge.Text = $updates.Count.ToString() $ui.btnUpdateAll.Visibility = 'Visible' } else { $ui.badgeUpdates.Visibility = 'Collapsed' $ui.btnUpdateAll.Visibility = 'Collapsed' } Set-Status -msg ("Found " + $updates.Count.ToString() + " update(s). Cache saved.") Save-CacheUpdates -modules $script:updatesCache Update-CacheAgeDisplay } catch { Set-Status -msg ("Error checking updates: " + $_.Exception.Message) } finally { Hide-Loading } } function Invoke-GallerySearch { param([string]$query) if ([string]::IsNullOrWhiteSpace($query)) { return } $script:currentView = 'search' Set-NavActive -name 'search' $ui.txtViewTitle.Text = "Search: " + $query Hide-Detail Show-Loading -msg "Searching PSGallery..." Set-Status -msg "Searching..." try { $installedNames = @{} $installedScopes = @{} foreach ($m in $script:installedCache) { $installedNames[$m.Name] = $m.Version $installedScopes[$m.Name] = $m._Scope } $results = Find-Module -Name "*$query*" -Repository PSGallery -ErrorAction SilentlyContinue | Select-Object -First 50 | ForEach-Object { $isInstalled = $installedNames.ContainsKey($_.Name) $status = 'NotInstalled' $modScope = '' if ($isInstalled) { $localVer = $installedNames[$_.Name] $modScope = $installedScopes[$_.Name] if ([version]$_.Version -gt [version]$localVer) { $status = 'Update' } else { $status = 'Installed' } } $dlCount = 0 try { $dlCount = [long]$_.AdditionalMetadata.downloadCount } catch {} $obj = Add-ModuleProps -m $_ -Status $status -Downloads $dlCount -LatestVersion $_.Version.ToString() # Override scope: use cached scope for installed, empty for new if ($modScope) { $obj._Scope = $modScope } else { $obj._Scope = '' } $obj } $script:searchResults = @($results) Update-ModuleList -modules $script:searchResults -emptyMsg "No modules matching that query." Set-Status -msg ("Found " + $script:searchResults.Count.ToString() + " result(s).") } catch { Set-Status -msg ("Search error: " + $_.Exception.Message) } finally { Hide-Loading } } # Module actions (scope-aware) function Invoke-ModuleAction { param([string]$Action, $Module) $name = $Module.Name $modScope = $Module._Scope # For Install: auto-determine scope based on admin status if ($Action -eq 'Install') { $targetScope = $script:installScope $confirmMsg = "$Action module '$name' [$targetScope scope]?" $confirm = [System.Windows.MessageBox]::Show($confirmMsg, "Confirm $Action", 'YesNo', 'Question') if ($confirm -ne 'Yes') { return } } else { # Update / Uninstall: check if module is AllUsers and user is not admin if ($modScope -eq 'AllUsers' -and -not $isAdmin) { [System.Windows.MessageBox]::Show( "The module '$name' is installed in AllUsers scope (system-wide).`n`nTo $Action this module, please run PS Module Manager as Administrator.", "Administrator Required", 'OK', 'Warning') return } $scopeLabel = if ($modScope) { $modScope } else { "detected" } $confirmMsg = "$Action module '$name' [$scopeLabel scope]?" $confirm = [System.Windows.MessageBox]::Show($confirmMsg, "Confirm $Action", 'YesNo', 'Question') if ($confirm -ne 'Yes') { return } } Show-Loading -msg "${Action}ing ${name}..." Set-Status -msg "${Action}ing ${name}..." try { if ($Action -eq 'Install') { $installParams = @{ Name = $name; Scope = $script:installScope; Force = $true; AllowClobber = $true; ErrorAction = 'Stop' } if ($script:supportsAcceptLicense) { $installParams['AcceptLicense'] = $true } Install-Module @installParams Set-Status -msg "Successfully installed $name." [System.Windows.MessageBox]::Show("Module '$name' installed successfully!", "Success", 'OK', 'Information') } elseif ($Action -eq 'Update') { $updateParams = @{ Name = $name; Force = $true; ErrorAction = 'Stop' } if ($script:supportsAcceptLicense) { $updateParams['AcceptLicense'] = $true } Update-Module @updateParams Set-Status -msg "Successfully updated $name." [System.Windows.MessageBox]::Show("Module '$name' updated successfully!", "Success", 'OK', 'Information') } elseif ($Action -eq 'Uninstall') { Uninstall-Module -Name $name -Force -AllVersions -ErrorAction Stop Set-Status -msg "Successfully uninstalled $name." [System.Windows.MessageBox]::Show("Module '$name' uninstalled.", "Done", 'OK', 'Information') Hide-Detail } # Invalidate caches and force refresh Clear-AllCache $script:installedCache = @() $script:updatesCache = @() if ($script:currentView -eq 'installed') { Load-InstalledModules -Force } elseif ($script:currentView -eq 'updates') { Load-Updates -Force } elseif ($script:currentView -eq 'search') { Invoke-GallerySearch -query $ui.txtSearch.Text } } catch { Set-Status -msg ("Error: " + $_.Exception.Message) [System.Windows.MessageBox]::Show($_.Exception.Message, "Action Failed", 'OK', 'Error') } finally { Hide-Loading } } # -- Event wiring -- # Navigation $ui.navInstalled.Add_Click({ Load-InstalledModules }) $ui.navUpdates.Add_Click({ Load-Updates }) $ui.navSearch.Add_Click({ $script:currentView = 'search' Set-NavActive -name 'search' $ui.txtViewTitle.Text = 'Gallery Search' $ui.txtSearch.Focus() if ($script:searchResults.Count -gt 0) { Update-ModuleList -modules $script:searchResults } else { $ui.lstModules.Items.Clear() $ui.pnlBatchBar.Visibility = 'Collapsed' $ui.pnlEmpty.Visibility = 'Visible' $ui.txtEmpty.Text = "Type a keyword above and click Search Gallery" $ui.txtCount.Text = '' } }) # Search $ui.btnSearch.Add_Click({ Invoke-GallerySearch -query $ui.txtSearch.Text }) $ui.txtSearch.Add_KeyDown({ if ($_.Key -eq 'Return') { Invoke-GallerySearch -query $ui.txtSearch.Text } }) # Refresh (force live scan) $ui.btnRefresh.Add_Click({ $script:installedCache = @() $script:updatesCache = @() if ($script:currentView -eq 'installed') { Load-InstalledModules -Force } elseif ($script:currentView -eq 'updates') { Load-Updates -Force } elseif ($script:currentView -eq 'search' -and $ui.txtSearch.Text) { Invoke-GallerySearch -query $ui.txtSearch.Text } else { Load-InstalledModules -Force } }) # Clear cache $ui.btnClearCache.Add_Click({ Clear-AllCache $script:installedCache = @() $script:updatesCache = @() Set-Status -msg "Cache cleared. Next load will do a live scan." }) # Update all (scope-aware) $ui.btnUpdateAll.Add_Click({ if ($script:updatesCache.Count -eq 0) { return } # Check if any AllUsers modules exist and user is not admin if (-not $isAdmin) { $allUsersMods = @($script:updatesCache | Where-Object { $_._Scope -eq 'AllUsers' }) if ($allUsersMods.Count -gt 0) { $userMods = @($script:updatesCache | Where-Object { $_._Scope -ne 'AllUsers' }) if ($userMods.Count -eq 0) { [System.Windows.MessageBox]::Show( "All " + $allUsersMods.Count.ToString() + " module(s) with updates are installed in AllUsers scope.`n`nPlease run PS Module Manager as Administrator to update them.", "Administrator Required", 'OK', 'Warning') return } else { $answer = [System.Windows.MessageBox]::Show( $allUsersMods.Count.ToString() + " module(s) are AllUsers scope and will be skipped (requires Admin).`n`nUpdate the remaining " + $userMods.Count.ToString() + " CurrentUser module(s)?", "Partial Update", 'YesNo', 'Question') if ($answer -ne 'Yes') { return } $modsToUpdate = $userMods } } else { $msg = "Update all " + $script:updatesCache.Count.ToString() + " module(s)?" $confirm = [System.Windows.MessageBox]::Show($msg, "Confirm Bulk Update", 'YesNo', 'Question') if ($confirm -ne 'Yes') { return } $modsToUpdate = $script:updatesCache } } else { $msg = "Update all " + $script:updatesCache.Count.ToString() + " module(s)?" $confirm = [System.Windows.MessageBox]::Show($msg, "Confirm Bulk Update", 'YesNo', 'Question') if ($confirm -ne 'Yes') { return } $modsToUpdate = $script:updatesCache } $i = 0 $ok = 0 $fail = 0 foreach ($mod in $modsToUpdate) { $i++ Show-Loading -msg ("Updating $i of " + $modsToUpdate.Count.ToString() + ": " + $mod.Name) try { $updateParams = @{ Name = $mod.Name; Force = $true; ErrorAction = 'Stop' } if ($script:supportsAcceptLicense) { $updateParams['AcceptLicense'] = $true } Update-Module @updateParams $ok++ } catch { $fail++ } } Hide-Loading Clear-AllCache $script:installedCache = @() $script:updatesCache = @() Set-Status -msg "Bulk update complete: $ok succeeded, $fail failed." [System.Windows.MessageBox]::Show("Updated: $ok`nFailed: $fail", "Bulk Update Complete", 'OK', 'Information') Load-Updates -Force }) # Selection changes -> update batch action bar $ui.lstModules.Add_SelectionChanged({ $selected = @() foreach ($item in $ui.lstModules.SelectedItems) { if ($item -and $item.Tag) { $selected += $item.Tag } } if ($selected.Count -gt 0) { $ui.pnlBatchBar.Visibility = 'Visible' $suffix = "" if ($selected.Count -ne 1) { $suffix = "s" } $ui.txtSelected.Text = $selected.Count.ToString() + " selected" # Show Update button if any selected have updates $hasUpdates = $false foreach ($m in $selected) { if ($m._Status -eq 'Update') { $hasUpdates = $true; break } } if ($hasUpdates) { $ui.btnBatchUpdate.Visibility = 'Visible' } else { $ui.btnBatchUpdate.Visibility = 'Collapsed' } # Show Uninstall button if any selected are installed $hasInstalled = $false foreach ($m in $selected) { if ($m._Status -eq 'Installed' -or $m._Status -eq 'Update') { $hasInstalled = $true; break } } if ($hasInstalled) { $ui.btnBatchUninstall.Visibility = 'Visible' } else { $ui.btnBatchUninstall.Visibility = 'Collapsed' } } else { $ui.pnlBatchBar.Visibility = 'Collapsed' } }) # Clear selection $ui.btnClearSelection.Add_Click({ $ui.lstModules.UnselectAll() $ui.pnlBatchBar.Visibility = 'Collapsed' }) # Batch Update $ui.btnBatchUpdate.Add_Click({ $selected = @() foreach ($item in $ui.lstModules.SelectedItems) { if ($item -and $item.Tag -and $item.Tag._Status -eq 'Update') { $selected += $item.Tag } } if ($selected.Count -eq 0) { return } # Check AllUsers modules when not admin $toUpdate = @() $skipped = @() foreach ($m in $selected) { if ($m._Scope -eq 'AllUsers' -and -not $isAdmin) { $skipped += $m } else { $toUpdate += $m } } if ($skipped.Count -gt 0 -and $toUpdate.Count -eq 0) { [System.Windows.MessageBox]::Show( "All " + $skipped.Count.ToString() + " selected module(s) are AllUsers scope.`n`nPlease run PS Module Manager as Administrator to update them.", "Administrator Required", 'OK', 'Warning') return } elseif ($skipped.Count -gt 0) { $answer = [System.Windows.MessageBox]::Show( $skipped.Count.ToString() + " AllUsers module(s) will be skipped (requires Admin).`n`nUpdate the remaining " + $toUpdate.Count.ToString() + " module(s)?", "Partial Update", 'YesNo', 'Question') if ($answer -ne 'Yes') { return } } else { $confirm = [System.Windows.MessageBox]::Show( "Update " + $toUpdate.Count.ToString() + " selected module(s)?", "Confirm Batch Update", 'YesNo', 'Question') if ($confirm -ne 'Yes') { return } } $i = 0; $ok = 0; $fail = 0 foreach ($mod in $toUpdate) { $i++ Show-Loading -msg ("Updating $i of " + $toUpdate.Count.ToString() + ": " + $mod.Name) try { $updateParams = @{ Name = $mod.Name; Force = $true; ErrorAction = 'Stop' } if ($script:supportsAcceptLicense) { $updateParams['AcceptLicense'] = $true } Update-Module @updateParams $ok++ } catch { $fail++ } } Hide-Loading Clear-AllCache $script:installedCache = @() $script:updatesCache = @() $resultMsg = "Updated: $ok" if ($fail -gt 0) { $resultMsg += "`nFailed: $fail" } if ($skipped.Count -gt 0) { $resultMsg += "`nSkipped (AllUsers): " + $skipped.Count.ToString() } [System.Windows.MessageBox]::Show($resultMsg, "Batch Update Complete", 'OK', 'Information') if ($script:currentView -eq 'installed') { Load-InstalledModules -Force } elseif ($script:currentView -eq 'updates') { Load-Updates -Force } elseif ($script:currentView -eq 'search') { Invoke-GallerySearch -query $ui.txtSearch.Text } }) # Batch Uninstall $ui.btnBatchUninstall.Add_Click({ $selected = @() foreach ($item in $ui.lstModules.SelectedItems) { if ($item -and $item.Tag -and ($item.Tag._Status -eq 'Installed' -or $item.Tag._Status -eq 'Update')) { $selected += $item.Tag } } if ($selected.Count -eq 0) { return } # Check AllUsers modules when not admin $toRemove = @() $skipped = @() foreach ($m in $selected) { if ($m._Scope -eq 'AllUsers' -and -not $isAdmin) { $skipped += $m } else { $toRemove += $m } } if ($skipped.Count -gt 0 -and $toRemove.Count -eq 0) { [System.Windows.MessageBox]::Show( "All " + $skipped.Count.ToString() + " selected module(s) are AllUsers scope.`n`nPlease run PS Module Manager as Administrator to uninstall them.", "Administrator Required", 'OK', 'Warning') return } elseif ($skipped.Count -gt 0) { $answer = [System.Windows.MessageBox]::Show( $skipped.Count.ToString() + " AllUsers module(s) will be skipped (requires Admin).`n`nUninstall the remaining " + $toRemove.Count.ToString() + " module(s)?", "Partial Uninstall", 'YesNo', 'Question') if ($answer -ne 'Yes') { return } } else { $nameList = "" foreach ($m in $toRemove) { $nameList += "`n - " + $m.Name } $confirm = [System.Windows.MessageBox]::Show( "Uninstall " + $toRemove.Count.ToString() + " module(s)?" + $nameList, "Confirm Batch Uninstall", 'YesNo', 'Warning') if ($confirm -ne 'Yes') { return } } $i = 0; $ok = 0; $fail = 0 foreach ($mod in $toRemove) { $i++ Show-Loading -msg ("Uninstalling $i of " + $toRemove.Count.ToString() + ": " + $mod.Name) try { Uninstall-Module -Name $mod.Name -Force -AllVersions -ErrorAction Stop $ok++ } catch { $fail++ } } Hide-Loading Hide-Detail Clear-AllCache $script:installedCache = @() $script:updatesCache = @() $resultMsg = "Uninstalled: $ok" if ($fail -gt 0) { $resultMsg += "`nFailed: $fail" } if ($skipped.Count -gt 0) { $resultMsg += "`nSkipped (AllUsers): " + $skipped.Count.ToString() } [System.Windows.MessageBox]::Show($resultMsg, "Batch Uninstall Complete", 'OK', 'Information') if ($script:currentView -eq 'installed') { Load-InstalledModules -Force } elseif ($script:currentView -eq 'updates') { Load-Updates -Force } elseif ($script:currentView -eq 'search') { Invoke-GallerySearch -query $ui.txtSearch.Text } }) # Detail actions $ui.btnCloseDetail.Add_Click({ Hide-Detail }) $ui.btnDetailInstall.Add_Click({ if ($script:selectedModule) { Invoke-ModuleAction -Action 'Install' -Module $script:selectedModule } }) $ui.btnDetailUpdate.Add_Click({ if ($script:selectedModule) { Invoke-ModuleAction -Action 'Update' -Module $script:selectedModule } }) $ui.btnDetailUninstall.Add_Click({ if ($script:selectedModule) { Invoke-ModuleAction -Action 'Uninstall' -Module $script:selectedModule } }) $ui.btnOpenGallery.Add_Click({ if ($script:selectedModule) { Start-Process ("https://www.powershellgallery.com/packages/" + $script:selectedModule.Name) } }) $ui.btnOpenLocation.Add_Click({ if ($script:selectedModule -and $script:selectedModule._InstalledLocation) { $path = $script:selectedModule._InstalledLocation if (Test-Path $path) { Start-Process explorer.exe -ArgumentList $path } else { [System.Windows.MessageBox]::Show("Path not found: $path", "Location Not Found", 'OK', 'Warning') } } }) # Clickable project URL $ui.txtDetailUrl.Add_MouseLeftButtonDown({ $url = $ui.txtDetailUrl.Text if ($url -and $url -ne '-') { Start-Process $url } }) # Initial load $window.Add_ContentRendered({ Update-CacheAgeDisplay Load-InstalledModules }) # Show window $window.ShowDialog() | Out-Null } Export-ModuleMember -Function Start-PSGalleryManager New-Alias -Name psgm -Value Start-PSGalleryManager -Force Export-ModuleMember -Alias psgm |