From fe4733d9039a1362643655de52285b6856537f96 Mon Sep 17 00:00:00 2001 From: Ludwig Lehnert Date: Mon, 29 Jun 2026 10:52:57 +0000 Subject: [PATCH] switched to GPO-based setup --- PolicyDefinitions/Softwarekatalog.admx | 51 +++ PolicyDefinitions/de-DE/Softwarekatalog.adml | 21 + PolicyDefinitions/en-US/Softwarekatalog.adml | 21 + setup.ps1 | 411 +++++++++++++++---- 4 files changed, 433 insertions(+), 71 deletions(-) create mode 100644 PolicyDefinitions/Softwarekatalog.admx create mode 100644 PolicyDefinitions/de-DE/Softwarekatalog.adml create mode 100644 PolicyDefinitions/en-US/Softwarekatalog.adml diff --git a/PolicyDefinitions/Softwarekatalog.admx b/PolicyDefinitions/Softwarekatalog.admx new file mode 100644 index 0000000..13f27d8 --- /dev/null +++ b/PolicyDefinitions/Softwarekatalog.admx @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PolicyDefinitions/de-DE/Softwarekatalog.adml b/PolicyDefinitions/de-DE/Softwarekatalog.adml new file mode 100644 index 0000000..18d9ddf --- /dev/null +++ b/PolicyDefinitions/de-DE/Softwarekatalog.adml @@ -0,0 +1,21 @@ + + + Softwarekatalog + Richtlinien fuer den Softwarekatalog. + + + Softwarekatalog + Softwarekatalog konfigurieren + Aktiviert und konfiguriert den Softwarekatalog. Katalog-Eintraege werden unter HKLM\Software\Policies\STL\Softwarekatalog\Catalog gespeichert. Der Wertname ist der Alias, der Wert hat das Format PackageId|DisplayName|Description. Pflichtsoftware wird unter HKLM\Software\Policies\STL\Softwarekatalog\Required gespeichert. Der Wertname ist der Alias, der Wert ist 1. Der Client liest maximal 500 Eintraege pro Liste. + + + + Benutzer duerfen Software installieren + Benutzer duerfen Software deinstallieren + Pflichtsoftware automatisch installieren + Freigegebene Software. Name = Alias, Wert = PackageId|DisplayName|Description. + Pflichtsoftware. Name = Alias aus Katalog, Wert = 1. + + + + diff --git a/PolicyDefinitions/en-US/Softwarekatalog.adml b/PolicyDefinitions/en-US/Softwarekatalog.adml new file mode 100644 index 0000000..0dbe3b4 --- /dev/null +++ b/PolicyDefinitions/en-US/Softwarekatalog.adml @@ -0,0 +1,21 @@ + + + Software Catalog + Policies for Software Catalog. + + + Software Catalog + Configure Software Catalog + Enables and configures Software Catalog. Catalog entries are stored below HKLM\Software\Policies\STL\Softwarekatalog\Catalog. Value name is alias, value format is PackageId|DisplayName|Description. Required software is stored below HKLM\Software\Policies\STL\Softwarekatalog\Required. Value name is alias, value is 1. Client reads up to 500 entries per list. + + + + Allow users to install software + Allow users to uninstall software + Automatically install required software + Allowed software. Name = alias, value = PackageId|DisplayName|Description. + Required software. Name = catalog alias, value = 1. + + + + diff --git a/setup.ps1 b/setup.ps1 index bf9f1ab..edcfddf 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -3,7 +3,7 @@ SelfServiceWinget.ps1 Use as: - Computer Startup GPO script (default mode = Install) -- Scheduled task target (Mode = Daemon / UpgradeAll) +- Scheduled task target (Mode = Daemon / UpgradeAll / ApplyPolicy) Behavior: User -> GUI / request helper -> named pipe -> daemon task (SYSTEM) -> winget install --scope machine @@ -20,7 +20,7 @@ C:\ProgramData\__Softwarekatalog\ [CmdletBinding()] param( - [ValidateSet('Install','Daemon','UpgradeAll')] + [ValidateSet('Install','Daemon','UpgradeAll','ApplyPolicy')] [string]$Mode = 'Install' ) @@ -39,6 +39,10 @@ $CatalogLauncherPath = Join-Path $BaseDir 'Launch-Software-Catalog.vbs' $InstalledPackagesPath = Join-Path $BaseDir 'Installed-Packages.json' $LogDir = Join-Path $BaseDir 'Logs' $LogFile = Join-Path $LogDir 'service.log' +$PolicyRoot = 'HKLM:\Software\Policies\STL\Softwarekatalog' +$PolicyCatalogRoot = Join-Path $PolicyRoot 'Catalog' +$PolicyRequiredRoot = Join-Path $PolicyRoot 'Required' +$PolicyMaxEntries = 500 $LegacyQueueTaskName = 'Softwarekatalog - Winget Queue' $DaemonTaskName = 'Softwarekatalog - Daemon' $UpgradeTaskName = 'Softwarekatalog - Winget Upgrade' @@ -52,44 +56,6 @@ $CommonProgramsDir = [Environment]::GetFolderPath('CommonPrograms') $DesktopShortcutPath = Join-Path $CommonDesktopDir "$CatalogDisplayName.lnk" $StartMenuShortcutPath = Join-Path $CommonProgramsDir "$CatalogDisplayName.lnk" -# App catalog configuration (list-based) -$AllowedApps = @( - [ordered]@{ Alias = 'vscode'; Title = 'Visual Studio Code'; Description = 'Leistungsfähiger Quellcode-Editor mit Erweiterungs-Ökosystem'; PackageId = 'Microsoft.VisualStudioCode' } - [ordered]@{ Alias = 'notepadpp'; Title = 'Notepad++'; Description = 'Schneller Text- und Codeeditor mit Syntax-Highlighting'; PackageId = 'Notepad++.Notepad++' } - [ordered]@{ Alias = 'sevenzip'; Title = '7-Zip'; Description = 'Werkzeug zum Komprimieren und Entpacken von Archivdateien'; PackageId = '7zip.7zip' } - [ordered]@{ Alias = 'adobereader'; Title = 'Adobe Acrobat Reader'; Description = 'Betrachter zum Öffnen, Lesen und Drucken von PDF-Dokumenten'; PackageId = 'Adobe.Acrobat.Reader.64-bit' } - [ordered]@{ Alias = 'vlc'; Title = 'VLC media player'; Description = 'Universeller Open-Source-Player für Audio- und Videoformate'; PackageId = 'VideoLAN.VLC' } - [ordered]@{ Alias = 'git'; Title = 'Git'; Description = 'Verteiltes Versionskontrollsystem für Softwareprojekte'; PackageId = 'Git.Git' } - [ordered]@{ Alias = 'python314'; Title = 'Python 3.14'; Description = 'Interpreter und Laufzeitumgebung für die Programmiersprache Python'; PackageId = 'Python.Python.3.14' } - [ordered]@{ Alias = 'bitwarden'; Title = 'Bitwarden'; Description = 'Open-Source Passwortmanager mit verschlüsseltem Tresor'; PackageId = 'Bitwarden.Bitwarden' } - [ordered]@{ Alias = 'keepass'; Title = 'KeePass'; Description = 'Lokaler Passwortmanager mit verschlüsselter Datenbank'; PackageId = 'DominikReichl.KeePass' } - [ordered]@{ Alias = 'keepassxc'; Title = 'KeePassXC'; Description = 'Moderner Open-Source Passwortmanager mit KeePass-Kompatibilität'; PackageId = 'KeePassXCTeam.KeePassXC' } - [ordered]@{ Alias = 'gimp'; Title = 'GIMP'; Description = 'Open-Source Bildbearbeitungsprogramm für Retusche und Grafikdesign'; PackageId = 'GIMP.GIMP.3' } - [ordered]@{ Alias = 'inkscape'; Title = 'Inkscape'; Description = 'Editor für Vektorgrafiken im SVG-Format'; PackageId = 'Inkscape.Inkscape' } - [ordered]@{ Alias = 'onlyoffice'; Title = 'ONLYOFFICE Desktop Editors'; Description = 'Desktop-Office-Suite für Dokumente, Tabellen und Präsentationen'; PackageId = 'ONLYOFFICE.DesktopEditors' } - [ordered]@{ Alias = 'libreoffice'; Title = 'LibreOffice'; Description = 'Umfangreiche Open-Source-Office-Suite als Microsoft-Office-Alternative'; PackageId = 'TheDocumentFoundation.LibreOffice' } - [ordered]@{ Alias = 'triliumnext'; Title = 'Trilium Notes'; Description = 'Hierarchisches Notizsystem für persönliche Wissensdatenbanken'; PackageId = 'TriliumNext.Notes' } - [ordered]@{ Alias = 'logseq'; Title = 'Logseq'; Description = 'Markdown-basierte Wissens- und Notizverwaltung mit Graphstruktur'; PackageId = 'Logseq.Logseq' } - [ordered]@{ Alias = 'superproductivity'; Title = 'Super Productivity'; Description = 'Aufgaben- und Zeitmanagement mit Fokus auf Entwickler-Workflows'; PackageId = 'JohannesMillan.superProductivity' } - [ordered]@{ Alias = 'typst'; Title = 'Typst'; Description = 'Modernes Satzsystem zum Erstellen technisch formatierter Dokumente'; PackageId = 'Typst.Typst' } - [ordered]@{ Alias = 'thunderbirdde'; Title = 'Mozilla Thunderbird (DE)'; Description = 'E-Mail-Client mit Kalender, Verschlüsselung und Erweiterungen'; PackageId = 'Mozilla.Thunderbird.de' } - [ordered]@{ Alias = 'virtualbox'; Title = 'Oracle VM VirtualBox'; Description = 'Virtualisierungssoftware zum Ausführen mehrerer Betriebssysteme'; PackageId = 'Oracle.VirtualBox' } - [ordered]@{ Alias = 'powershell7'; Title = 'PowerShell 7'; Description = 'Moderne plattformübergreifende PowerShell für Automatisierung und Skripting'; PackageId = 'Microsoft.PowerShell' } - [ordered]@{ Alias = 'windowsterminal'; Title = 'Windows Terminal'; Description = 'Moderner Terminal für PowerShell, CMD, WSL und SSH'; PackageId = 'Microsoft.WindowsTerminal' } - [ordered]@{ Alias = 'nodejs'; Title = 'Node.js LTS'; Description = 'JavaScript-Laufzeitumgebung für Tools und Anwendungen'; PackageId = 'OpenJS.NodeJS.LTS' } - [ordered]@{ Alias = 'everything'; Title = 'Everything'; Description = 'Extrem schnelle Dateisuche für Windows'; PackageId = 'voidtools.Everything' } - [ordered]@{ Alias = 'rufus'; Title = 'Rufus'; Description = 'Tool zum Erstellen bootfähiger USB-Sticks'; PackageId = 'Rufus.Rufus' } - [ordered]@{ Alias = 'filezilla'; Title = 'FileZilla Client'; Description = 'FTP-, FTPS- und SFTP-Client für Dateiübertragungen'; PackageId = 'FileZilla.FileZilla.Client' } - [ordered]@{ Alias = 'obs'; Title = 'OBS Studio'; Description = 'Open-Source Software für Bildschirmaufnahme und Streaming'; PackageId = 'OBSProject.OBSStudio' } - [ordered]@{ Alias = 'powertoys'; Title = 'Microsoft PowerToys'; Description = 'Sammlung nützlicher Produktivitätswerkzeuge für Windows'; PackageId = 'Microsoft.PowerToys' } - [ordered]@{ Alias = 'wireshark'; Title = 'Wireshark'; Description = 'Netzwerkprotokoll-Analyzer zur Analyse von Netzwerkverkehr'; PackageId = 'WiresharkFoundation.Wireshark' } - [ordered]@{ Alias = 'bruno'; Title = 'Bruno'; Description = 'Leichtgewichtiger API-Client zum Testen von REST- und HTTP-APIs'; PackageId = 'usebruno.bruno' } - [ordered]@{ Alias = 'anki'; Title = 'Anki'; Description = 'Karteikartenprogramm für effektives Lernen mit Spaced-Repetition'; PackageId = 'Anki.Anki' } - [ordered]@{ Alias = 'torbrowser'; Title = 'Tor Browser'; Description = 'Datenschutzorientierter Webbrowser für anonymes Surfen über das Tor-Netzwerk'; PackageId = 'TorProject.TorBrowser' } - [ordered]@{ Alias = 'citrixworkspace'; Title = 'Citrix Workspace'; Description = 'Client für den Zugriff auf virtuelle Desktops und Anwendungen über Citrix-Infrastruktur'; PackageId = 'Citrix.Workspace' } - [ordered]@{ Alias = 'msoffice'; Title = 'Microsoft 365 Apps for enterprise'; Description = 'Microsoft Office-Desktopanwendungen wie Word, Excel, PowerPoint und Outlook aus Microsoft 365'; PackageId = 'Microsoft.Office' } -) - # ---------------------------- # Utility functions # ---------------------------- @@ -180,13 +146,146 @@ function Set-FileAclAdminsOnly { Set-Acl -LiteralPath $Path -AclObject $acl } +function Get-PolicyDword { + param( + [Parameter(Mandatory)][string]$Name, + [int]$Default = 0 + ) + + try { + if (-not (Test-Path -LiteralPath $PolicyRoot)) { + return $Default + } + + $props = Get-ItemProperty -LiteralPath $PolicyRoot -ErrorAction Stop + $property = $props.PSObject.Properties[$Name] + if (-not $property) { + return $Default + } + + return [int]$property.Value + } + catch { + Write-Log "Failed to read policy value '$Name': $($_.Exception.Message)" 'WARN' + return $Default + } +} + +function Test-SoftwareCatalogPolicyEnabled { + (Get-PolicyDword -Name 'Enabled' -Default 0) -eq 1 +} + +function Test-UserInstallsAllowed { + (Get-PolicyDword -Name 'AllowUserInstalls' -Default 1) -eq 1 +} + +function Test-UserUninstallAllowed { + (Get-PolicyDword -Name 'AllowUserUninstall' -Default 1) -eq 1 +} + +function Test-RequiredEnforcementEnabled { + (Get-PolicyDword -Name 'EnforceRequired' -Default 1) -eq 1 +} + +function Get-PolicyStringValues { + param([Parameter(Mandatory)][string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { + return @() + } + + try { + $item = Get-Item -LiteralPath $Path -ErrorAction Stop + $names = @($item.GetValueNames()) | + Where-Object { $_ -and $_ -notmatch '^\*' } | + Sort-Object | + Select-Object -First $PolicyMaxEntries + + foreach ($name in $names) { + $value = [string]$item.GetValue($name, '') + if (-not [string]::IsNullOrWhiteSpace($value)) { + [pscustomobject]@{ + Name = [string]$name + Value = $value.Trim() + } + } + } + } + catch { + Write-Log "Failed to read policy list '$Path': $($_.Exception.Message)" 'WARN' + return @() + } +} + function Get-AppCatalogObjects { - foreach ($app in $AllowedApps) { + if (-not (Test-SoftwareCatalogPolicyEnabled)) { + return @() + } + + $seenAliases = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + $rows = Get-PolicyStringValues -Path $PolicyCatalogRoot + foreach ($row in $rows) { + $alias = ([string]$row.Name).Trim().ToLowerInvariant() + if ([string]::IsNullOrWhiteSpace($alias)) { + continue + } + + if (-not $seenAliases.Add($alias)) { + Write-Log "Duplicate catalog alias '$alias' in policy; ignoring duplicate." 'WARN' + continue + } + + $parts = ([string]$row.Value) -split '\|', 3 + if (@($parts).Count -lt 2) { + Write-Log "Malformed catalog row '$alias'. Expected: PackageId|DisplayName|Description" 'WARN' + continue + } + + $packageId = ([string]$parts[0]).Trim() + $displayName = ([string]$parts[1]).Trim() + $description = '' + if (@($parts).Count -ge 3) { + $description = ([string]$parts[2]).Trim() + } + + if ([string]::IsNullOrWhiteSpace($packageId)) { + Write-Log "Catalog row '$alias' has empty package ID; ignoring." 'WARN' + continue + } + + if ([string]::IsNullOrWhiteSpace($displayName)) { + $displayName = $alias + } + [pscustomobject]@{ - Alias = [string]$app.Alias - DisplayName = [string]$app.Title - Description = [string]$app.Description - PackageId = [string]$app.PackageId + Alias = $alias + DisplayName = $displayName + Description = $description + PackageId = $packageId + } + } +} + +function Get-RequiredAliases { + if (-not (Test-SoftwareCatalogPolicyEnabled)) { + return @() + } + + $seenAliases = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + $rows = Get-PolicyStringValues -Path $PolicyRequiredRoot + foreach ($row in $rows) { + $value = ([string]$row.Value).Trim() + if ($value -eq '0' -or $value -eq 'false') { + continue + } + + $alias = ([string]$row.Name).Trim().ToLowerInvariant() + if ([string]::IsNullOrWhiteSpace($alias)) { + continue + } + + if ($seenAliases.Add($alias)) { + $alias } } } @@ -194,7 +293,7 @@ function Get-AppCatalogObjects { function Get-PackageIdByAlias { param([Parameter(Mandatory)][string]$Alias) - foreach ($app in $AllowedApps) { + foreach ($app in Get-AppCatalogObjects) { if ([string]::Equals([string]$app.Alias, $Alias, [System.StringComparison]::OrdinalIgnoreCase)) { return [string]$app.PackageId } @@ -203,6 +302,18 @@ function Get-PackageIdByAlias { return $null } +function Get-AppCatalogObjectByAlias { + param([Parameter(Mandatory)][string]$Alias) + + foreach ($app in Get-AppCatalogObjects) { + if ([string]::Equals([string]$app.Alias, $Alias, [System.StringComparison]::OrdinalIgnoreCase)) { + return $app + } + } + + return $null +} + function Write-TextFile { param( [Parameter(Mandatory)][string]$Path, @@ -341,7 +452,6 @@ function Ensure-WingetAvailable { } function New-UserRequestTool { - $appsJson = (Get-AppCatalogObjects | ConvertTo-Json -Depth 4) $pipeLiteral = $PipeName.Replace("'", "''") $content = @" @@ -357,12 +467,6 @@ param( Set-StrictMode -Version Latest `$ErrorActionPreference = 'Stop' -`$AppsJson = @' -$appsJson -'@ - -`$Apps = `$AppsJson | ConvertFrom-Json -`$AllowedAliases = @(`$Apps | ForEach-Object { `$_.Alias }) `$PipeName = '$pipeLiteral' function Invoke-CatalogDaemon { @@ -415,8 +519,9 @@ if (`$Uninstall) { } if (`$List) { + `$response = Invoke-CatalogDaemon -Command 'GetCatalog' Write-Host 'Freigegebene Anwendungen:' - `$Apps | Sort-Object DisplayName | ForEach-Object { + @(`$response.Catalog) | Sort-Object DisplayName | ForEach-Object { Write-Host (" - {0} [{1}] : {2}" -f `$_.DisplayName, `$_.Alias, `$_.Description) } exit 0 @@ -427,10 +532,6 @@ if (-not `$AppAlias) { } `$alias = `$AppAlias.Trim().ToLowerInvariant() -if (`$AllowedAliases -notcontains `$alias) { - throw "App-Alias '`$AppAlias' ist nicht freigegeben. Mit -List sehen Sie gültige Aliase." -} - `$response = Invoke-CatalogDaemon -Command `$Action -Alias `$alias if (`$Action -eq 'Uninstall') { @@ -450,7 +551,6 @@ if (`$response.Message) { } function New-CatalogGui { - $appsJson = (Get-AppCatalogObjects | ConvertTo-Json -Depth 4) $pipeLiteral = $PipeName.Replace("'", "''") $titleLiteral = $CatalogDisplayName.Replace("'", "''") $logLiteral = $LogFile.Replace("'", "''") @@ -510,15 +610,12 @@ public static extern bool DestroyIcon(IntPtr hIcon); Set-StrictMode -Version Latest `$ErrorActionPreference = 'Stop' -`$AppsJson = @' -$appsJson -'@ - -`$Apps = `$AppsJson | ConvertFrom-Json `$PipeName = '$pipeLiteral' `$CatalogTitle = '$titleLiteral' `$LogFile = '$logLiteral' `$InstalledPackagesPath = '$installedLiteral' +`$AllowUserInstalls = `$true +`$AllowUserUninstall = `$true function Set-FormIcon { param([Parameter(Mandatory=`$true)]`$Form) @@ -622,6 +719,13 @@ function New-AppRequest { return Invoke-CatalogDaemon -Command `$Action -Alias (`$Alias.Trim().ToLowerInvariant()) } +function Get-CatalogApps { + `$response = Invoke-CatalogDaemon -Command 'GetCatalog' + `$script:AllowUserInstalls = [bool]`$response.AllowUserInstalls + `$script:AllowUserUninstall = [bool]`$response.AllowUserUninstall + return @(`$response.Catalog) +} + function Get-InstalledPackageState { `$state = [ordered]@{ GeneratedAt = `$null @@ -786,8 +890,8 @@ function Update-InstallButtonState { `$selectedItem = `$list.SelectedItems[0] `$state = `$selectedItem.SubItems[2].Text - `$btnInstall.Enabled = (`$state -eq 'Installierbar') - `$btnUninstall.Enabled = (`$state -eq 'Installiert') + `$btnInstall.Enabled = (`$script:AllowUserInstalls -and `$state -eq 'Installierbar') + `$btnUninstall.Enabled = (`$script:AllowUserUninstall -and `$state -eq 'Installiert') } function Load-AppList { @@ -800,6 +904,7 @@ function Load-AppList { `$list.Items.Clear() + `$apps = Get-CatalogApps `$installedState = Get-InstalledPackageState `$installedSet = `$installedState.PackageSet @@ -809,7 +914,7 @@ function Load-AppList { `$installedNames = New-Object 'System.Collections.Generic.List[string]' - foreach (`$app in (`$Apps | Sort-Object DisplayName)) { + foreach (`$app in (`$apps | Sort-Object DisplayName)) { `$state = Get-AppStatus -App `$app -InstalledSet `$installedSet `$item = New-Object System.Windows.Forms.ListViewItem(`$app.DisplayName) @@ -1419,6 +1524,99 @@ function Update-InstalledPackagesSnapshot { } } +function Get-InstalledPackageIdSet { + $set = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + + if (-not (Test-Path -LiteralPath $InstalledPackagesPath)) { + return $set + } + + try { + $raw = Get-Content -LiteralPath $InstalledPackagesPath -Raw -Encoding UTF8 + if ([string]::IsNullOrWhiteSpace($raw)) { + return $set + } + + $snapshot = $raw | ConvertFrom-Json + foreach ($id in @($snapshot.InstalledPackageIds)) { + if (-not [string]::IsNullOrWhiteSpace([string]$id)) { + [void]$set.Add(([string]$id).Trim()) + } + } + } + catch { + Write-Log "Failed to read installed package snapshot: $($_.Exception.Message)" 'WARN' + } + + return $set +} + +function Invoke-RequiredSoftwarePolicy { + param([Parameter(Mandatory)][string]$WingetPath) + + if (-not (Test-SoftwareCatalogPolicyEnabled)) { + Write-Log 'Required software policy skipped because Softwarekatalog policy is disabled or not configured.' 'WARN' + return [pscustomobject]@{ Installed = 0; Failed = 0; Skipped = 0 } + } + + if (-not (Test-RequiredEnforcementEnabled)) { + Write-Log 'Required software enforcement is disabled by policy.' + return [pscustomobject]@{ Installed = 0; Failed = 0; Skipped = 0 } + } + + $requiredAliases = @(Get-RequiredAliases) + if ($requiredAliases.Count -lt 1) { + Write-Log 'No required software configured by policy.' + return [pscustomobject]@{ Installed = 0; Failed = 0; Skipped = 0 } + } + + $installedCount = 0 + $failedCount = 0 + $skippedCount = 0 + + $result = Invoke-WithWingetLock -ScriptBlock { + $snapshotUpdated = Update-InstalledPackagesSnapshot -WingetPath $WingetPath + if (-not $snapshotUpdated) { + throw 'Could not refresh installed package snapshot before required software enforcement.' + } + + $installedSet = Get-InstalledPackageIdSet + foreach ($alias in $requiredAliases) { + $app = Get-AppCatalogObjectByAlias -Alias $alias + if (-not $app) { + Write-Log "Required alias '$alias' is not present in catalog policy; skipping." 'WARN' + $skippedCount++ + continue + } + + if ($installedSet.Contains([string]$app.PackageId)) { + $skippedCount++ + continue + } + + Write-Log "Installing required software alias '$($app.Alias)' package '$($app.PackageId)'." + $exitCode = Invoke-WingetInstall -WingetPath $WingetPath -PackageId ([string]$app.PackageId) + if ($exitCode -eq 0) { + $installedCount++ + [void]$installedSet.Add([string]$app.PackageId) + } + else { + $failedCount++ + Write-Log "Required install failed for alias '$($app.Alias)' package '$($app.PackageId)' with code $exitCode." 'ERROR' + } + } + + $null = Update-InstalledPackagesSnapshot -WingetPath $WingetPath + [pscustomobject]@{ + Installed = $installedCount + Failed = $failedCount + Skipped = $skippedCount + } + } + + return @($result)[-1] +} + function New-CatalogPipeSecurity { $pipeSecurity = New-Object System.IO.Pipes.PipeSecurity @@ -1523,7 +1721,52 @@ function Invoke-CatalogDaemonCommand { $command = $command.Trim().ToLowerInvariant() switch ($command) { + 'getcatalog' { + $catalog = @(Get-AppCatalogObjects | Sort-Object DisplayName) + $requiredAliases = @(Get-RequiredAliases) + return [pscustomobject]@{ + Success = $true + Command = $command + RequestedBy = $ClientIdentity.Name + RequestedBySid = $ClientIdentity.Sid + Catalog = $catalog + RequiredAliases = $requiredAliases + AllowUserInstalls = (Test-UserInstallsAllowed) + AllowUserUninstall = (Test-UserUninstallAllowed) + EnforceRequired = (Test-RequiredEnforcementEnabled) + Message = 'Katalog geladen.' + } + } + + 'applypolicy' { + Write-Log "Authenticated policy apply requested by user='$($ClientIdentity.Name)' sid='$($ClientIdentity.Sid)'" + $wingetPath = Ensure-WingetAvailable + if (-not $wingetPath) { + throw 'winget is unavailable.' + } + + $result = Invoke-RequiredSoftwarePolicy -WingetPath $wingetPath + return [pscustomobject]@{ + Success = ($result.Failed -eq 0) + Command = $command + RequestedBy = $ClientIdentity.Name + RequestedBySid = $ClientIdentity.Sid + Installed = $result.Installed + Failed = $result.Failed + Skipped = $result.Skipped + Message = "Policy angewendet. Installiert=$($result.Installed), Fehler=$($result.Failed), Übersprungen=$($result.Skipped)." + } + } + { $_ -in @('install','uninstall') } { + if ($command -eq 'install' -and -not (Test-UserInstallsAllowed)) { + throw 'Benutzerinstallationen sind per Richtlinie deaktiviert.' + } + + if ($command -eq 'uninstall' -and -not (Test-UserUninstallAllowed)) { + throw 'Benutzerdeinstallationen sind per Richtlinie deaktiviert.' + } + $appAliasProperty = $Request.PSObject.Properties['AppAlias'] if (-not $appAliasProperty -or [string]::IsNullOrWhiteSpace([string]$appAliasProperty.Value)) { throw 'Missing AppAlias.' @@ -1619,13 +1862,11 @@ function Start-CatalogDaemon { try { $wingetPath = Ensure-WingetAvailable if ($wingetPath) { - Invoke-WithWingetLock -ScriptBlock { - $null = Update-InstalledPackagesSnapshot -WingetPath $wingetPath - } + $null = Invoke-RequiredSoftwarePolicy -WingetPath $wingetPath } } catch { - Write-Log "Initial installed package snapshot failed: $($_.Exception.Message)" 'WARN' + Write-Log "Initial required software enforcement failed: $($_.Exception.Message)" 'WARN' } while ($true) { @@ -1700,6 +1941,33 @@ function Invoke-UpgradeAllMode { else { Write-Log "2-hour winget upgrade failed with code $($result.ExitCode). SnapshotUpdated=$($result.SnapshotUpdated)." 'WARN' } + + try { + $requiredResult = Invoke-RequiredSoftwarePolicy -WingetPath $wingetPath + Write-Log "Required policy after upgrade completed. Installed=$($requiredResult.Installed), Failed=$($requiredResult.Failed), Skipped=$($requiredResult.Skipped)." + } + catch { + Write-Log "Required policy after upgrade failed: $($_.Exception.Message)" 'ERROR' + } +} + +function Invoke-ApplyPolicyMode { + Ensure-Directory -Path $BaseDir + Ensure-Directory -Path $LogDir + + $wingetPath = Ensure-WingetAvailable + if (-not $wingetPath) { + Write-Log 'Aborting policy apply because winget is unavailable.' 'ERROR' + return + } + + $result = Invoke-RequiredSoftwarePolicy -WingetPath $wingetPath + if ($result.Failed -eq 0) { + Write-Log "Required policy apply completed. Installed=$($result.Installed), Failed=$($result.Failed), Skipped=$($result.Skipped)." + } + else { + Write-Log "Required policy apply completed with failures. Installed=$($result.Installed), Failed=$($result.Failed), Skipped=$($result.Skipped)." 'WARN' + } } function Install-Service { @@ -1741,6 +2009,7 @@ try { 'Install' { Install-Service } 'Daemon' { Start-CatalogDaemon } 'UpgradeAll' { Invoke-UpgradeAllMode } + 'ApplyPolicy' { Invoke-ApplyPolicyMode } default { throw "Unsupported mode '$Mode'." } } }