switched to GPO-based setup

This commit is contained in:
Ludwig Lehnert
2026-06-29 10:52:57 +00:00
parent 97a18f8777
commit fe4733d903
4 changed files with 433 additions and 71 deletions

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<policyDefinitions revision="1.0" schemaVersion="1.0">
<policyNamespaces>
<target prefix="stlSoftwarekatalog" namespace="STL.Policies.Softwarekatalog" />
<using prefix="windows" namespace="Microsoft.Policies.Windows" />
</policyNamespaces>
<resources minRequiredRevision="1.0" />
<categories>
<category name="Softwarekatalog" displayName="$(string.Softwarekatalog)" />
</categories>
<policies>
<policy name="Softwarekatalog_Settings" class="Machine" displayName="$(string.Softwarekatalog_Settings)" explainText="$(string.Softwarekatalog_Settings_Help)" key="Software\Policies\STL\Softwarekatalog" valueName="Enabled" presentation="$(presentation.Softwarekatalog_Settings)">
<parentCategory ref="Softwarekatalog" />
<supportedOn ref="windows:SUPPORTED_Windows7" />
<enabledValue>
<decimal value="1" />
</enabledValue>
<disabledValue>
<decimal value="0" />
</disabledValue>
<elements>
<boolean id="AllowUserInstalls" valueName="AllowUserInstalls">
<trueValue>
<decimal value="1" />
</trueValue>
<falseValue>
<decimal value="0" />
</falseValue>
</boolean>
<boolean id="AllowUserUninstall" valueName="AllowUserUninstall">
<trueValue>
<decimal value="1" />
</trueValue>
<falseValue>
<decimal value="0" />
</falseValue>
</boolean>
<boolean id="EnforceRequired" valueName="EnforceRequired">
<trueValue>
<decimal value="1" />
</trueValue>
<falseValue>
<decimal value="0" />
</falseValue>
</boolean>
<list id="Catalog" key="Software\Policies\STL\Softwarekatalog\Catalog" valuePrefix="Catalog" additive="false" explicitValue="true" />
<list id="Required" key="Software\Policies\STL\Softwarekatalog\Required" valuePrefix="Required" additive="false" explicitValue="true" />
</elements>
</policy>
</policies>
</policyDefinitions>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<policyDefinitionResources revision="1.0" schemaVersion="1.0">
<displayName>Softwarekatalog</displayName>
<description>Richtlinien fuer den Softwarekatalog.</description>
<resources>
<stringTable>
<string id="Softwarekatalog">Softwarekatalog</string>
<string id="Softwarekatalog_Settings">Softwarekatalog konfigurieren</string>
<string id="Softwarekatalog_Settings_Help">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.</string>
</stringTable>
<presentationTable>
<presentation id="Softwarekatalog_Settings">
<checkBox refId="AllowUserInstalls">Benutzer duerfen Software installieren</checkBox>
<checkBox refId="AllowUserUninstall">Benutzer duerfen Software deinstallieren</checkBox>
<checkBox refId="EnforceRequired">Pflichtsoftware automatisch installieren</checkBox>
<listBox refId="Catalog">Freigegebene Software. Name = Alias, Wert = PackageId|DisplayName|Description.</listBox>
<listBox refId="Required">Pflichtsoftware. Name = Alias aus Katalog, Wert = 1.</listBox>
</presentation>
</presentationTable>
</resources>
</policyDefinitionResources>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<policyDefinitionResources revision="1.0" schemaVersion="1.0">
<displayName>Software Catalog</displayName>
<description>Policies for Software Catalog.</description>
<resources>
<stringTable>
<string id="Softwarekatalog">Software Catalog</string>
<string id="Softwarekatalog_Settings">Configure Software Catalog</string>
<string id="Softwarekatalog_Settings_Help">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.</string>
</stringTable>
<presentationTable>
<presentation id="Softwarekatalog_Settings">
<checkBox refId="AllowUserInstalls">Allow users to install software</checkBox>
<checkBox refId="AllowUserUninstall">Allow users to uninstall software</checkBox>
<checkBox refId="EnforceRequired">Automatically install required software</checkBox>
<listBox refId="Catalog">Allowed software. Name = alias, value = PackageId|DisplayName|Description.</listBox>
<listBox refId="Required">Required software. Name = catalog alias, value = 1.</listBox>
</presentation>
</presentationTable>
</resources>
</policyDefinitionResources>

413
setup.ps1
View File

@@ -3,7 +3,7 @@ SelfServiceWinget.ps1
Use as: Use as:
- Computer Startup GPO script (default mode = Install) - Computer Startup GPO script (default mode = Install)
- Scheduled task target (Mode = Daemon / UpgradeAll) - Scheduled task target (Mode = Daemon / UpgradeAll / ApplyPolicy)
Behavior: Behavior:
User -> GUI / request helper -> named pipe -> daemon task (SYSTEM) -> winget install --scope machine User -> GUI / request helper -> named pipe -> daemon task (SYSTEM) -> winget install --scope machine
@@ -20,7 +20,7 @@ C:\ProgramData\__Softwarekatalog\
[CmdletBinding()] [CmdletBinding()]
param( param(
[ValidateSet('Install','Daemon','UpgradeAll')] [ValidateSet('Install','Daemon','UpgradeAll','ApplyPolicy')]
[string]$Mode = 'Install' [string]$Mode = 'Install'
) )
@@ -39,6 +39,10 @@ $CatalogLauncherPath = Join-Path $BaseDir 'Launch-Software-Catalog.vbs'
$InstalledPackagesPath = Join-Path $BaseDir 'Installed-Packages.json' $InstalledPackagesPath = Join-Path $BaseDir 'Installed-Packages.json'
$LogDir = Join-Path $BaseDir 'Logs' $LogDir = Join-Path $BaseDir 'Logs'
$LogFile = Join-Path $LogDir 'service.log' $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' $LegacyQueueTaskName = 'Softwarekatalog - Winget Queue'
$DaemonTaskName = 'Softwarekatalog - Daemon' $DaemonTaskName = 'Softwarekatalog - Daemon'
$UpgradeTaskName = 'Softwarekatalog - Winget Upgrade' $UpgradeTaskName = 'Softwarekatalog - Winget Upgrade'
@@ -52,44 +56,6 @@ $CommonProgramsDir = [Environment]::GetFolderPath('CommonPrograms')
$DesktopShortcutPath = Join-Path $CommonDesktopDir "$CatalogDisplayName.lnk" $DesktopShortcutPath = Join-Path $CommonDesktopDir "$CatalogDisplayName.lnk"
$StartMenuShortcutPath = Join-Path $CommonProgramsDir "$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 # Utility functions
# ---------------------------- # ----------------------------
@@ -180,13 +146,146 @@ function Set-FileAclAdminsOnly {
Set-Acl -LiteralPath $Path -AclObject $acl Set-Acl -LiteralPath $Path -AclObject $acl
} }
function Get-AppCatalogObjects { function Get-PolicyDword {
foreach ($app in $AllowedApps) { 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]@{ [pscustomobject]@{
Alias = [string]$app.Alias Name = [string]$name
DisplayName = [string]$app.Title Value = $value.Trim()
Description = [string]$app.Description }
PackageId = [string]$app.PackageId }
}
}
catch {
Write-Log "Failed to read policy list '$Path': $($_.Exception.Message)" 'WARN'
return @()
}
}
function Get-AppCatalogObjects {
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 = $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 { function Get-PackageIdByAlias {
param([Parameter(Mandatory)][string]$Alias) param([Parameter(Mandatory)][string]$Alias)
foreach ($app in $AllowedApps) { foreach ($app in Get-AppCatalogObjects) {
if ([string]::Equals([string]$app.Alias, $Alias, [System.StringComparison]::OrdinalIgnoreCase)) { if ([string]::Equals([string]$app.Alias, $Alias, [System.StringComparison]::OrdinalIgnoreCase)) {
return [string]$app.PackageId return [string]$app.PackageId
} }
@@ -203,6 +302,18 @@ function Get-PackageIdByAlias {
return $null 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 { function Write-TextFile {
param( param(
[Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Path,
@@ -341,7 +452,6 @@ function Ensure-WingetAvailable {
} }
function New-UserRequestTool { function New-UserRequestTool {
$appsJson = (Get-AppCatalogObjects | ConvertTo-Json -Depth 4)
$pipeLiteral = $PipeName.Replace("'", "''") $pipeLiteral = $PipeName.Replace("'", "''")
$content = @" $content = @"
@@ -357,12 +467,6 @@ param(
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
`$ErrorActionPreference = 'Stop' `$ErrorActionPreference = 'Stop'
`$AppsJson = @'
$appsJson
'@
`$Apps = `$AppsJson | ConvertFrom-Json
`$AllowedAliases = @(`$Apps | ForEach-Object { `$_.Alias })
`$PipeName = '$pipeLiteral' `$PipeName = '$pipeLiteral'
function Invoke-CatalogDaemon { function Invoke-CatalogDaemon {
@@ -415,8 +519,9 @@ if (`$Uninstall) {
} }
if (`$List) { if (`$List) {
`$response = Invoke-CatalogDaemon -Command 'GetCatalog'
Write-Host 'Freigegebene Anwendungen:' 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) Write-Host (" - {0} [{1}] : {2}" -f `$_.DisplayName, `$_.Alias, `$_.Description)
} }
exit 0 exit 0
@@ -427,10 +532,6 @@ if (-not `$AppAlias) {
} }
`$alias = `$AppAlias.Trim().ToLowerInvariant() `$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 `$response = Invoke-CatalogDaemon -Command `$Action -Alias `$alias
if (`$Action -eq 'Uninstall') { if (`$Action -eq 'Uninstall') {
@@ -450,7 +551,6 @@ if (`$response.Message) {
} }
function New-CatalogGui { function New-CatalogGui {
$appsJson = (Get-AppCatalogObjects | ConvertTo-Json -Depth 4)
$pipeLiteral = $PipeName.Replace("'", "''") $pipeLiteral = $PipeName.Replace("'", "''")
$titleLiteral = $CatalogDisplayName.Replace("'", "''") $titleLiteral = $CatalogDisplayName.Replace("'", "''")
$logLiteral = $LogFile.Replace("'", "''") $logLiteral = $LogFile.Replace("'", "''")
@@ -510,15 +610,12 @@ public static extern bool DestroyIcon(IntPtr hIcon);
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
`$ErrorActionPreference = 'Stop' `$ErrorActionPreference = 'Stop'
`$AppsJson = @'
$appsJson
'@
`$Apps = `$AppsJson | ConvertFrom-Json
`$PipeName = '$pipeLiteral' `$PipeName = '$pipeLiteral'
`$CatalogTitle = '$titleLiteral' `$CatalogTitle = '$titleLiteral'
`$LogFile = '$logLiteral' `$LogFile = '$logLiteral'
`$InstalledPackagesPath = '$installedLiteral' `$InstalledPackagesPath = '$installedLiteral'
`$AllowUserInstalls = `$true
`$AllowUserUninstall = `$true
function Set-FormIcon { function Set-FormIcon {
param([Parameter(Mandatory=`$true)]`$Form) param([Parameter(Mandatory=`$true)]`$Form)
@@ -622,6 +719,13 @@ function New-AppRequest {
return Invoke-CatalogDaemon -Command `$Action -Alias (`$Alias.Trim().ToLowerInvariant()) 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 { function Get-InstalledPackageState {
`$state = [ordered]@{ `$state = [ordered]@{
GeneratedAt = `$null GeneratedAt = `$null
@@ -786,8 +890,8 @@ function Update-InstallButtonState {
`$selectedItem = `$list.SelectedItems[0] `$selectedItem = `$list.SelectedItems[0]
`$state = `$selectedItem.SubItems[2].Text `$state = `$selectedItem.SubItems[2].Text
`$btnInstall.Enabled = (`$state -eq 'Installierbar') `$btnInstall.Enabled = (`$script:AllowUserInstalls -and `$state -eq 'Installierbar')
`$btnUninstall.Enabled = (`$state -eq 'Installiert') `$btnUninstall.Enabled = (`$script:AllowUserUninstall -and `$state -eq 'Installiert')
} }
function Load-AppList { function Load-AppList {
@@ -800,6 +904,7 @@ function Load-AppList {
`$list.Items.Clear() `$list.Items.Clear()
`$apps = Get-CatalogApps
`$installedState = Get-InstalledPackageState `$installedState = Get-InstalledPackageState
`$installedSet = `$installedState.PackageSet `$installedSet = `$installedState.PackageSet
@@ -809,7 +914,7 @@ function Load-AppList {
`$installedNames = New-Object 'System.Collections.Generic.List[string]' `$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 `$state = Get-AppStatus -App `$app -InstalledSet `$installedSet
`$item = New-Object System.Windows.Forms.ListViewItem(`$app.DisplayName) `$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 { function New-CatalogPipeSecurity {
$pipeSecurity = New-Object System.IO.Pipes.PipeSecurity $pipeSecurity = New-Object System.IO.Pipes.PipeSecurity
@@ -1523,7 +1721,52 @@ function Invoke-CatalogDaemonCommand {
$command = $command.Trim().ToLowerInvariant() $command = $command.Trim().ToLowerInvariant()
switch ($command) { 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') } { { $_ -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'] $appAliasProperty = $Request.PSObject.Properties['AppAlias']
if (-not $appAliasProperty -or [string]::IsNullOrWhiteSpace([string]$appAliasProperty.Value)) { if (-not $appAliasProperty -or [string]::IsNullOrWhiteSpace([string]$appAliasProperty.Value)) {
throw 'Missing AppAlias.' throw 'Missing AppAlias.'
@@ -1619,13 +1862,11 @@ function Start-CatalogDaemon {
try { try {
$wingetPath = Ensure-WingetAvailable $wingetPath = Ensure-WingetAvailable
if ($wingetPath) { if ($wingetPath) {
Invoke-WithWingetLock -ScriptBlock { $null = Invoke-RequiredSoftwarePolicy -WingetPath $wingetPath
$null = Update-InstalledPackagesSnapshot -WingetPath $wingetPath
}
} }
} }
catch { catch {
Write-Log "Initial installed package snapshot failed: $($_.Exception.Message)" 'WARN' Write-Log "Initial required software enforcement failed: $($_.Exception.Message)" 'WARN'
} }
while ($true) { while ($true) {
@@ -1700,6 +1941,33 @@ function Invoke-UpgradeAllMode {
else { else {
Write-Log "2-hour winget upgrade failed with code $($result.ExitCode). SnapshotUpdated=$($result.SnapshotUpdated)." 'WARN' 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 { function Install-Service {
@@ -1741,6 +2009,7 @@ try {
'Install' { Install-Service } 'Install' { Install-Service }
'Daemon' { Start-CatalogDaemon } 'Daemon' { Start-CatalogDaemon }
'UpgradeAll' { Invoke-UpgradeAllMode } 'UpgradeAll' { Invoke-UpgradeAllMode }
'ApplyPolicy' { Invoke-ApplyPolicyMode }
default { throw "Unsupported mode '$Mode'." } default { throw "Unsupported mode '$Mode'." }
} }
} }