<# SelfServiceWinget.ps1 Use as: - Computer Startup GPO script (default mode = Install) - Scheduled task target (Mode = ProcessRequests / UpgradeAll / ApplyPolicy / ProcessQueue / ProcessRequest) Behavior: User -> GUI / request helper -> request drop dir -> SYSTEM request worker -> winget install --scope machine Created paths: C:\ProgramData\__Softwarekatalog\ SelfServiceWinget.ps1 Request-App.ps1 Software-Catalog.ps1 Launch-Software-Catalog.vbs Installed-Packages.json Requests\ Processed\ Failed\ Logs\service.log #> [CmdletBinding()] param( [ValidateSet('Install','UpgradeAll','ApplyPolicy','ProcessQueue','ProcessRequest','ProcessRequests')] [string]$Mode = 'Install', [ValidateSet('Install','Uninstall')] [string]$RequestAction = 'Install', [string]$AppAlias, [string]$RequestedBy, [string]$RequestedBySid ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # ---------------------------- # Configuration # ---------------------------- $CatalogDisplayName = 'Softwarekatalog' $BaseDir = Join-Path $env:ProgramData '__Softwarekatalog' $LocalScriptPath = Join-Path $BaseDir 'SelfServiceWinget.ps1' $RequestToolPath = Join-Path $BaseDir 'Request-App.ps1' $CatalogGuiPath = Join-Path $BaseDir 'Software-Catalog.ps1' $CatalogLauncherPath = Join-Path $BaseDir 'Launch-Software-Catalog.vbs' $InstalledPackagesPath = Join-Path $BaseDir 'Installed-Packages.json' $QueueStatePath = Join-Path $BaseDir 'Queue-State.json' $RequestsDir = Join-Path $BaseDir 'Requests' $ProcessedRequestsDir = Join-Path $BaseDir 'Processed' $FailedRequestsDir = Join-Path $BaseDir 'Failed' $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' $RequestTaskName = 'Softwarekatalog - Requests' $UpgradeTaskName = 'Softwarekatalog - Winget Upgrade' $WingetMutexName = 'Global\Softwarekatalog-Winget' $QueueStateMutexName = 'Global\Softwarekatalog-QueueState' $PowerShellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' $WScriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe' $AppInstallerFamilyName = 'Microsoft.DesktopAppInstaller_8wekyb3d8bbwe' $CommonDesktopDir = [Environment]::GetFolderPath('CommonDesktopDirectory') $CommonProgramsDir = [Environment]::GetFolderPath('CommonPrograms') $DesktopShortcutPath = Join-Path $CommonDesktopDir "$CatalogDisplayName.lnk" $StartMenuShortcutPath = Join-Path $CommonProgramsDir "$CatalogDisplayName.lnk" # ---------------------------- # Utility functions # ---------------------------- function Ensure-Directory { param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path -LiteralPath $Path)) { New-Item -ItemType Directory -Path $Path -Force | Out-Null } } function Write-Log { param( [Parameter(Mandatory)][string]$Message, [ValidateSet('INFO','WARN','ERROR')] [string]$Level = 'INFO' ) Ensure-Directory -Path $LogDir $line = '{0} [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message Add-Content -LiteralPath $LogFile -Value $line -Encoding UTF8 } function Set-DirectoryAcl { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][bool]$UsersCanModify ) $inheritance = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' $propagation = [System.Security.AccessControl.PropagationFlags]::None $acl = New-Object System.Security.AccessControl.DirectorySecurity $acl.SetAccessRuleProtection($true, $false) $sidSystem = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18') $sidAdmins = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544') $sidUsers = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-545') $ruleSystem = New-Object System.Security.AccessControl.FileSystemAccessRule( $sidSystem, 'FullControl', $inheritance, $propagation, 'Allow' ) $ruleAdmins = New-Object System.Security.AccessControl.FileSystemAccessRule( $sidAdmins, 'FullControl', $inheritance, $propagation, 'Allow' ) if ($UsersCanModify) { $ruleUsers = New-Object System.Security.AccessControl.FileSystemAccessRule( $sidUsers, 'Modify', $inheritance, $propagation, 'Allow' ) } else { $ruleUsers = New-Object System.Security.AccessControl.FileSystemAccessRule( $sidUsers, 'ReadAndExecute, Synchronize', $inheritance, $propagation, 'Allow' ) } $null = $acl.AddAccessRule($ruleSystem) $null = $acl.AddAccessRule($ruleAdmins) $null = $acl.AddAccessRule($ruleUsers) Set-Acl -LiteralPath $Path -AclObject $acl } function Set-RequestDirectoryAcl { param([Parameter(Mandatory)][string]$Path) $inheritance = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' $inheritOnly = [System.Security.AccessControl.PropagationFlags]::InheritOnly $noInheritance = [System.Security.AccessControl.InheritanceFlags]::None $noPropagation = [System.Security.AccessControl.PropagationFlags]::None $acl = New-Object System.Security.AccessControl.DirectorySecurity $acl.SetAccessRuleProtection($true, $false) $sidSystem = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18') $sidAdmins = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544') $sidUsers = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-545') $sidCreatorOwner = New-Object System.Security.Principal.SecurityIdentifier('S-1-3-0') $ruleSystem = New-Object System.Security.AccessControl.FileSystemAccessRule( $sidSystem, 'FullControl', $inheritance, $noPropagation, 'Allow' ) $ruleAdmins = New-Object System.Security.AccessControl.FileSystemAccessRule( $sidAdmins, 'FullControl', $inheritance, $noPropagation, 'Allow' ) $ruleUsers = New-Object System.Security.AccessControl.FileSystemAccessRule( $sidUsers, 'ReadAndExecute, Write, Synchronize', $noInheritance, $noPropagation, 'Allow' ) $ruleCreatorOwner = New-Object System.Security.AccessControl.FileSystemAccessRule( $sidCreatorOwner, 'Modify', $inheritance, $inheritOnly, 'Allow' ) $null = $acl.AddAccessRule($ruleSystem) $null = $acl.AddAccessRule($ruleAdmins) $null = $acl.AddAccessRule($ruleUsers) $null = $acl.AddAccessRule($ruleCreatorOwner) Set-Acl -LiteralPath $Path -AclObject $acl } function Set-FileAclAdminsOnly { param([Parameter(Mandatory)][string]$Path) $acl = New-Object System.Security.AccessControl.FileSecurity $acl.SetAccessRuleProtection($true, $false) $sidSystem = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18') $sidAdmins = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544') $sidUsers = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-545') $ruleSystem = New-Object System.Security.AccessControl.FileSystemAccessRule( $sidSystem, 'FullControl', 'Allow' ) $ruleAdmins = New-Object System.Security.AccessControl.FileSystemAccessRule( $sidAdmins, 'FullControl', 'Allow' ) $ruleUsers = New-Object System.Security.AccessControl.FileSystemAccessRule( $sidUsers, 'ReadAndExecute, Synchronize', 'Allow' ) $null = $acl.AddAccessRule($ruleSystem) $null = $acl.AddAccessRule($ruleAdmins) $null = $acl.AddAccessRule($ruleUsers) 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 { 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 } } } function Get-PackageIdByAlias { param([Parameter(Mandatory)][string]$Alias) foreach ($app in Get-AppCatalogObjects) { if ([string]::Equals([string]$app.Alias, $Alias, [System.StringComparison]::OrdinalIgnoreCase)) { return [string]$app.PackageId } } 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, [Parameter(Mandatory)][string]$Content, [ValidateSet('Utf8Bom','Unicode')] [string]$Encoding = 'Utf8Bom' ) $parent = Split-Path -Path $Path -Parent if ($parent) { Ensure-Directory -Path $parent } switch ($Encoding) { 'Unicode' { $textEncoding = New-Object System.Text.UnicodeEncoding($false, $true) } default { $textEncoding = New-Object System.Text.UTF8Encoding($true) } } [System.IO.File]::WriteAllText($Path, $Content, $textEncoding) } function New-QueueStateObject { [pscustomobject][ordered]@{ Version = 0 UpdatedAt = (Get-Date).ToString('o') Current = $null Queue = @() } } function Read-QueueState { if (-not (Test-Path -LiteralPath $QueueStatePath)) { return New-QueueStateObject } try { $raw = Get-Content -LiteralPath $QueueStatePath -Raw -Encoding UTF8 if ([string]::IsNullOrWhiteSpace($raw)) { return New-QueueStateObject } $state = $raw | ConvertFrom-Json if (-not $state.PSObject.Properties['Version']) { $state | Add-Member -NotePropertyName Version -NotePropertyValue 0 } if (-not $state.PSObject.Properties['UpdatedAt']) { $state | Add-Member -NotePropertyName UpdatedAt -NotePropertyValue (Get-Date).ToString('o') } if (-not $state.PSObject.Properties['Current']) { $state | Add-Member -NotePropertyName Current -NotePropertyValue $null } if (-not $state.PSObject.Properties['Queue']) { $state | Add-Member -NotePropertyName Queue -NotePropertyValue @() } elseif (-not $state.Queue) { $state.Queue = @() } return $state } catch { Write-Log "Failed to read queue state: $($_.Exception.Message)" 'WARN' return New-QueueStateObject } } function Write-QueueState { param([Parameter(Mandatory)]$State) Ensure-Directory -Path $BaseDir $tempPath = Join-Path $BaseDir ("Queue-State-{0}.json.tmp" -f [Guid]::NewGuid().ToString('N')) try { $State | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $tempPath -Encoding UTF8 Move-Item -LiteralPath $tempPath -Destination $QueueStatePath -Force } finally { if (Test-Path -LiteralPath $tempPath) { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue } } } function Invoke-WithQueueStateLock { param([Parameter(Mandatory)][scriptblock]$ScriptBlock) $mutex = New-Object System.Threading.Mutex($false, $QueueStateMutexName) $hasLock = $false try { $hasLock = $mutex.WaitOne([TimeSpan]::FromSeconds(30)) if (-not $hasLock) { throw "Timed out waiting for queue state lock '$QueueStateMutexName'." } & $ScriptBlock } finally { if ($hasLock) { $mutex.ReleaseMutex() } $mutex.Dispose() } } function Update-QueueState { param([Parameter(Mandatory)][scriptblock]$ScriptBlock) Invoke-WithQueueStateLock -ScriptBlock { $state = Read-QueueState & $ScriptBlock $state $state.Version = [int64]$state.Version + 1 $state.UpdatedAt = (Get-Date).ToString('o') Write-QueueState -State $state return $state } } function Get-QueueStateSnapshot { Invoke-WithQueueStateLock -ScriptBlock { Read-QueueState } } function New-QueueItem { param( [Parameter(Mandatory)][ValidateSet('Install','Uninstall','UpgradeAll')][string]$Action, [string]$Alias, [string]$PackageId, [string]$DisplayName, [ValidateSet('User','Required','System')][string]$Source = 'User', [bool]$Required = $false, [string]$RequestedBy, [string]$RequestedBySid ) [pscustomobject][ordered]@{ Id = [Guid]::NewGuid().ToString('N') Action = $Action Alias = $Alias PackageId = $PackageId DisplayName = $DisplayName Source = $Source Required = [bool]$Required RequestedBy = $RequestedBy RequestedBySid = $RequestedBySid State = 'Queued' Stage = 'Queued' StageText = 'In Warteschlange' ProgressPercent = $null IsIndeterminate = $false CreatedAt = (Get-Date).ToString('o') StartedAt = $null FinishedAt = $null ExitCode = $null Message = $null } } function Get-ProgressPercentFromLine { param([string]$Line) if ([string]::IsNullOrWhiteSpace($Line)) { return $null } $match = [regex]::Match($Line, '(? Column) { a = itemX.SubItems[Column].Text; } if (itemY != null && itemY.SubItems.Count > Column) { b = itemY.SubItems[Column].Text; } int result = StringComparer.CurrentCultureIgnoreCase.Compare(a, b); return Order == SortOrder.Descending ? -result : result; } } '@ -ReferencedAssemblies @('System.dll','System.Windows.Forms.dll') } if (-not ('ShellIconExtractor' -as [type])) { Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; public static class ShellIconExtractor { [DllImport("shell32.dll", CharSet = CharSet.Unicode)] public static extern uint ExtractIconEx(string szFileName, int nIconIndex, IntPtr[] phiconLarge, IntPtr[] phiconSmall, uint nIcons); [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool DestroyIcon(IntPtr hIcon); } '@ } Set-StrictMode -Version Latest `$ErrorActionPreference = 'Stop' `$CatalogTitle = '$titleLiteral' `$LogFile = '$logLiteral' `$InstalledPackagesPath = '$installedLiteral' `$QueueStatePath = '$queueStateLiteral' `$RequestsDir = '$requestsLiteral' `$PolicyRoot = '$policyRootLiteral' `$PolicyCatalogRoot = '$policyCatalogLiteral' `$PolicyRequiredRoot = '$policyRequiredLiteral' `$PolicyMaxEntries = $policyMaxEntriesLiteral `$AllowUserInstalls = `$true `$AllowUserUninstall = `$true `$RequiredAliasSet = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) `$LastStateVersion = -1 `$LastQueue = @() `$LastCurrent = `$null `$LastInstalledIds = @() `$PendingRequests = @{} function Set-FormIcon { param([Parameter(Mandatory=`$true)]`$Form) `$large = `$null `$small = `$null try { `$iconFile = Join-Path `$env:SystemRoot 'System32\shell32.dll' `$large = New-Object System.IntPtr[] 1 `$small = New-Object System.IntPtr[] 1 `$count = [ShellIconExtractor]::ExtractIconEx(`$iconFile, 80, `$large, `$small, 1) if (`$count -gt 0) { `$useHandle = [IntPtr]::Zero if (`$small[0] -ne [IntPtr]::Zero) { `$useHandle = `$small[0] } elseif (`$large[0] -ne [IntPtr]::Zero) { `$useHandle = `$large[0] } if (`$useHandle -ne [IntPtr]::Zero) { `$icon = [System.Drawing.Icon]::FromHandle(`$useHandle) `$Form.Icon = [System.Drawing.Icon]`$icon.Clone() } } } catch { } finally { try { if (`$small -and `$small[0] -ne [IntPtr]::Zero) { [void][ShellIconExtractor]::DestroyIcon(`$small[0]) } } catch { } try { if (`$large -and `$large[0] -ne [IntPtr]::Zero) { [void][ShellIconExtractor]::DestroyIcon(`$large[0]) } } catch { } } } function Invoke-CatalogLocal { param( [Parameter(Mandatory=`$true)][string]`$Command, [string]`$Alias ) `$commandName = `$Command.Trim().ToLowerInvariant() switch (`$commandName) { { `$_ -in @('getcatalog','getstate') } { `$queueState = Get-QueueStateSnapshot return [pscustomobject]@{ Success = `$true Command = `$commandName Catalog = @(Get-AppCatalogObjects | Sort-Object DisplayName) RequiredAliases = @(Get-RequiredAliases) Queue = @(`$queueState.Queue) Current = `$queueState.Current Version = `$queueState.Version InstalledPackageIds = @(Get-InstalledPackageIds) AllowUserInstalls = (Test-UserInstallsAllowed) AllowUserUninstall = (Test-UserUninstallAllowed) EnforceRequired = (Test-RequiredEnforcementEnabled) Message = 'Katalog geladen.' } } { `$_ -in @('install','uninstall','refreshstatus') } { if (`$commandName -eq 'install') { `$requestCommand = 'Install' } elseif (`$commandName -eq 'uninstall') { `$requestCommand = 'Uninstall' } else { `$requestCommand = 'RefreshStatus' } New-CatalogRequestFile -Command `$requestCommand -Alias `$Alias return [pscustomobject]@{ Success = `$true Command = `$commandName AppAlias = `$Alias Queued = `$true Message = 'Anfrage gespeichert.' } } default { throw "Unsupported command '`$Command'." } } } function Get-PolicyDword { param([Parameter(Mandatory=`$true)][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 { 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=`$true)][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 { return @() } } function Get-AppCatalogObjects { if (-not (Test-SoftwareCatalogPolicyEnabled)) { return @() } foreach (`$row in Get-PolicyStringValues -Path `$PolicyCatalogRoot) { `$alias = ([string]`$row.Name).Trim().ToLowerInvariant() if ([string]::IsNullOrWhiteSpace(`$alias)) { continue } `$parts = ([string]`$row.Value) -split '\|', 3 if (@(`$parts).Count -lt 2) { 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)) { continue } if ([string]::IsNullOrWhiteSpace(`$displayName)) { `$displayName = `$alias } [pscustomobject]@{ Alias = `$alias DisplayName = `$displayName Description = `$description PackageId = `$packageId } } } function Get-RequiredAliases { if (-not (Test-SoftwareCatalogPolicyEnabled)) { return @() } foreach (`$row in Get-PolicyStringValues -Path `$PolicyRequiredRoot) { `$value = ([string]`$row.Value).Trim() if (`$value -eq '0' -or `$value -eq 'false') { continue } `$alias = ([string]`$row.Name).Trim().ToLowerInvariant() if (-not [string]::IsNullOrWhiteSpace(`$alias)) { `$alias } } } function Get-QueueStateSnapshot { `$state = [pscustomobject][ordered]@{ Version = 0; UpdatedAt = `$null; Current = `$null; Queue = @() } if (-not (Test-Path -LiteralPath `$QueueStatePath)) { return `$state } try { `$raw = Get-Content -LiteralPath `$QueueStatePath -Raw -Encoding UTF8 if ([string]::IsNullOrWhiteSpace(`$raw)) { return `$state } `$loaded = `$raw | ConvertFrom-Json if (`$loaded.PSObject.Properties['Version']) { `$state.Version = [int64]`$loaded.Version } if (`$loaded.PSObject.Properties['UpdatedAt']) { `$state.UpdatedAt = `$loaded.UpdatedAt } if (`$loaded.PSObject.Properties['Current']) { `$state.Current = `$loaded.Current } if (`$loaded.PSObject.Properties['Queue'] -and `$loaded.Queue) { `$state.Queue = @(`$loaded.Queue) } } catch { } return `$state } function Get-InstalledPackageIds { if (-not (Test-Path -LiteralPath `$InstalledPackagesPath)) { return @() } try { `$raw = Get-Content -LiteralPath `$InstalledPackagesPath -Raw -Encoding UTF8 if ([string]::IsNullOrWhiteSpace(`$raw)) { return @() } `$snapshot = `$raw | ConvertFrom-Json return @(`$snapshot.InstalledPackageIds) } catch { return @() } } function New-CatalogRequestFile { param( [Parameter(Mandatory=`$true)][ValidateSet('Install','Uninstall','RefreshStatus')][string]`$Command, [string]`$Alias ) if (-not (Test-Path -LiteralPath `$RequestsDir)) { throw "Anfrageverzeichnis '`$RequestsDir' fehlt. Bitte Setup erneut ausführen." } `$id = [Guid]::NewGuid().ToString('N') `$payload = [ordered]@{ Id = `$id Command = `$Command AppAlias = `$Alias CreatedAt = (Get-Date).ToString('o') } `$tempPath = Join-Path `$RequestsDir ("`$id.tmp") `$finalPath = Join-Path `$RequestsDir ("`$id.json") `$payload | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath `$tempPath -Encoding UTF8 Move-Item -LiteralPath `$tempPath -Destination `$finalPath -Force } function New-AppRequest { param( [Parameter(Mandatory=`$true)][string]`$Alias, [Parameter(Mandatory=`$true)][ValidateSet('Install','Uninstall')][string]`$Action ) return Invoke-CatalogLocal -Command `$Action -Alias (`$Alias.Trim().ToLowerInvariant()) } function Get-CatalogApps { `$response = Invoke-CatalogLocal -Command 'GetState' `$script:AllowUserInstalls = [bool]`$response.AllowUserInstalls `$script:AllowUserUninstall = [bool]`$response.AllowUserUninstall `$script:RequiredAliasSet = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) foreach (`$alias in @(`$response.RequiredAliases)) { if (-not [string]::IsNullOrWhiteSpace([string]`$alias)) { [void]`$script:RequiredAliasSet.Add([string]`$alias) } } `$script:LastStateVersion = [int64]`$response.Version if (`$response.Queue) { `$script:LastQueue = @(`$response.Queue) } else { `$script:LastQueue = @() } `$script:LastCurrent = `$response.Current if (`$response.InstalledPackageIds) { `$script:LastInstalledIds = @(`$response.InstalledPackageIds) } else { `$script:LastInstalledIds = @() } return @(`$response.Catalog) } function Get-InstalledPackageState { `$state = [ordered]@{ GeneratedAt = `$null PackageSet = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) } if (-not (Test-Path -LiteralPath `$InstalledPackagesPath)) { return [pscustomobject]`$state } try { `$raw = Get-Content -LiteralPath `$InstalledPackagesPath -Raw -Encoding UTF8 if ([string]::IsNullOrWhiteSpace(`$raw)) { return [pscustomobject]`$state } `$snapshot = `$raw | ConvertFrom-Json if (`$snapshot.GeneratedAt) { `$state.GeneratedAt = [string]`$snapshot.GeneratedAt } foreach (`$id in @(`$snapshot.InstalledPackageIds)) { if (`$id) { [void]`$state.PackageSet.Add(([string]`$id).Trim()) } } } catch { } return [pscustomobject]`$state } function Get-AppStatus { param( [Parameter(Mandatory=`$true)]`$App, [Parameter(Mandatory=`$true)]`$InstalledSet, [Parameter(Mandatory=`$true)]`$QueueItems ) foreach (`$queued in @(`$QueueItems)) { if (-not `$queued) { continue } if (`$queued.State -in @('Queued','Running') -and [string]::Equals([string]`$queued.Alias, [string]`$App.Alias, [System.StringComparison]::OrdinalIgnoreCase)) { [void]`$script:PendingRequests.Remove([string]`$App.Alias) if (`$queued.State -eq 'Running') { if (`$queued.Action -eq 'Uninstall') { return 'Wird deinstalliert' } return 'Wird installiert' } if (`$queued.Action -eq 'Uninstall') { return 'Deinstallation in Warteschlange' } return 'In Warteschlange' } } `$pending = `$script:PendingRequests[[string]`$App.Alias] if (`$pending) { if (`$pending.CreatedAt -lt (Get-Date).AddMinutes(-5)) { [void]`$script:PendingRequests.Remove([string]`$App.Alias) } elseif (`$pending.Action -eq 'Uninstall') { return 'Deinstallation in Warteschlange' } else { return 'In Warteschlange' } } if (`$InstalledSet.Contains(`$App.PackageId)) { return 'Installiert' } if (`$script:RequiredAliasSet.Contains([string]`$App.Alias)) { return 'In Warteschlange' } return 'Installierbar' } function Test-RequestQueuedInState { param( [Parameter(Mandatory=`$true)][string]`$Alias, [Parameter(Mandatory=`$true)][string]`$Action ) if (-not (Test-Path -LiteralPath `$QueueStatePath)) { return `$false } try { `$raw = Get-Content -LiteralPath `$QueueStatePath -Raw -Encoding UTF8 if ([string]::IsNullOrWhiteSpace(`$raw)) { return `$false } `$queueState = `$raw | ConvertFrom-Json foreach (`$item in @(`$queueState.Queue)) { if (-not `$item) { continue } if (`$item.CreatedAt) { try { `$createdAt = [DateTime]::Parse([string]`$item.CreatedAt) if (`$createdAt -lt (Get-Date).AddMinutes(-10)) { continue } } catch { } } if (`$item.State -notin @('Queued','Running','Succeeded')) { continue } if (-not [string]::Equals([string]`$item.Alias, `$Alias, [System.StringComparison]::OrdinalIgnoreCase)) { continue } if (-not [string]::Equals([string]`$item.Action, `$Action, [System.StringComparison]::OrdinalIgnoreCase)) { continue } return `$true } } catch { } return `$false } `$form = New-Object System.Windows.Forms.Form `$form.Text = `$CatalogTitle `$form.StartPosition = 'CenterScreen' `$form.Size = New-Object System.Drawing.Size(980, 660) `$form.MinimumSize = New-Object System.Drawing.Size(960, 640) `$form.TopMost = `$false `$form.ShowIcon = `$true `$form.Font = New-Object System.Drawing.Font('Segoe UI', 9) `$form.BackColor = [System.Drawing.ColorTranslator]::FromHtml('#F3F3F3') Set-FormIcon -Form `$form `$headerPanel = New-Object System.Windows.Forms.Panel `$headerPanel.Location = New-Object System.Drawing.Point(12, 12) `$headerPanel.Size = New-Object System.Drawing.Size(940, 52) `$headerPanel.Anchor = 'Top, Left, Right' `$headerPanel.BackColor = [System.Drawing.SystemColors]::Window `$headerPanel.BorderStyle = 'FixedSingle' `$headerAccent = New-Object System.Windows.Forms.Panel `$headerAccent.Location = New-Object System.Drawing.Point(0, 0) `$headerAccent.Size = New-Object System.Drawing.Size(6, 50) `$headerAccent.Anchor = 'Top, Left, Bottom' `$headerAccent.BackColor = [System.Drawing.ColorTranslator]::FromHtml('#0F6CBD') `$headerTitle = New-Object System.Windows.Forms.Label `$headerTitle.Location = New-Object System.Drawing.Point(18, 12) `$headerTitle.Size = New-Object System.Drawing.Size(900, 24) `$headerTitle.Font = New-Object System.Drawing.Font('Segoe UI Semibold', 13) `$headerTitle.Text = `$CatalogTitle `$headerPanel.Controls.Add(`$headerAccent) `$headerPanel.Controls.Add(`$headerTitle) `$groupApps = New-Object System.Windows.Forms.Panel `$groupApps.Location = New-Object System.Drawing.Point(12, 72) `$groupApps.Size = New-Object System.Drawing.Size(940, 384) `$groupApps.Anchor = 'Top, Left, Right, Bottom' `$groupApps.BackColor = [System.Drawing.SystemColors]::Window `$groupApps.BorderStyle = 'FixedSingle' `$listCaption = New-Object System.Windows.Forms.Label `$listCaption.Location = New-Object System.Drawing.Point(12, 10) `$listCaption.Size = New-Object System.Drawing.Size(912, 20) `$listCaption.Font = New-Object System.Drawing.Font('Segoe UI Semibold', 9.5) `$listCaption.Text = 'Freigegebene Anwendungen' `$list = New-Object System.Windows.Forms.ListView `$list.Location = New-Object System.Drawing.Point(12, 34) `$list.Size = New-Object System.Drawing.Size(916, 338) `$list.Anchor = 'Top, Left, Right, Bottom' `$list.View = 'Details' `$list.FullRowSelect = `$true `$list.MultiSelect = `$false `$list.GridLines = `$false `$list.HideSelection = `$false [void]`$list.Columns.Add('Anwendung', 260) [void]`$list.Columns.Add('Beschreibung', 450) [void]`$list.Columns.Add('Status', 150) [void]`$list.Columns.Add('Alias', 110) [void]`$list.Columns.Add('Paket-ID', 100) `$list.BorderStyle = 'None' `$list.HeaderStyle = 'Clickable' `$columnSorter = New-Object ListViewColumnSorter `$list.ListViewItemSorter = `$columnSorter function Sort-ListByColumn { param([Parameter(Mandatory=`$true)][int]`$ColumnIndex) if (`$columnSorter.Column -eq `$ColumnIndex) { if (`$columnSorter.Order -eq [System.Windows.Forms.SortOrder]::Ascending) { `$columnSorter.Order = [System.Windows.Forms.SortOrder]::Descending } else { `$columnSorter.Order = [System.Windows.Forms.SortOrder]::Ascending } } else { `$columnSorter.Column = `$ColumnIndex `$columnSorter.Order = [System.Windows.Forms.SortOrder]::Ascending } `$list.Sort() } `$groupApps.Controls.Add(`$listCaption) `$groupApps.Controls.Add(`$list) `$infoPanel = New-Object System.Windows.Forms.Panel `$infoPanel.Location = New-Object System.Drawing.Point(12, 466) `$infoPanel.Size = New-Object System.Drawing.Size(940, 54) `$infoPanel.Anchor = 'Left, Right, Bottom' `$infoPanel.BorderStyle = 'FixedSingle' `$infoPanel.BackColor = [System.Drawing.SystemColors]::Window `$snapshotLabel = New-Object System.Windows.Forms.Label `$snapshotLabel.Location = New-Object System.Drawing.Point(10, 8) `$snapshotLabel.Size = New-Object System.Drawing.Size(914, 18) `$snapshotLabel.Text = 'Installationsdaten: kein Datenstand vorhanden' `$installedLabel = New-Object System.Windows.Forms.Label `$installedLabel.Location = New-Object System.Drawing.Point(10, 30) `$installedLabel.Size = New-Object System.Drawing.Size(914, 18) `$installedLabel.Text = 'Bereits installiert: keine' `$infoPanel.Controls.Add(`$snapshotLabel) `$infoPanel.Controls.Add(`$installedLabel) function Update-InstallButtonState { if (`$list.SelectedItems.Count -lt 1) { `$btnInstall.Enabled = `$false `$btnUninstall.Enabled = `$false return } `$selectedItem = `$list.SelectedItems[0] `$state = `$selectedItem.SubItems[2].Text `$isRequired = `$script:RequiredAliasSet.Contains([string]`$selectedItem.SubItems[3].Text) `$btnInstall.Enabled = (`$script:AllowUserInstalls -and `$state -eq 'Installierbar') `$btnUninstall.Enabled = (`$script:AllowUserUninstall -and -not `$isRequired -and `$state -eq 'Installiert') } function Update-ProgressArea { `$current = `$script:LastCurrent `$queuedCount = @(`$script:LastQueue | Where-Object { `$_ -and `$_.State -eq 'Queued' }).Count if (`$current) { `$label = [string]`$current.StageText if ([string]::IsNullOrWhiteSpace(`$label)) { `$label = 'Aktion läuft...' } `$status.Text = "{0} (Warteschlange: {1})" -f `$label, `$queuedCount `$progressBar.Visible = `$true if (`$current.IsIndeterminate -or `$null -eq `$current.ProgressPercent) { `$progressBar.Style = 'Marquee' } else { `$progressBar.Style = 'Continuous' `$value = [int]`$current.ProgressPercent if (`$value -lt 0) { `$value = 0 } if (`$value -gt 100) { `$value = 100 } `$progressBar.Value = `$value } } elseif (`$queuedCount -gt 0) { `$status.Text = "Warteschlange: {0}" -f `$queuedCount `$progressBar.Visible = `$true `$progressBar.Style = 'Continuous' `$progressBar.Value = 0 } else { `$status.Text = 'Bereit.' `$progressBar.Visible = `$false `$progressBar.Style = 'Continuous' `$progressBar.Value = 0 } } function Load-AppList { `$selectedAlias = `$null `$list.BeginUpdate() try { if (`$list.SelectedItems.Count -gt 0) { `$selectedAlias = [string]`$list.SelectedItems[0].SubItems[3].Text } `$list.Items.Clear() `$apps = Get-CatalogApps `$installedState = Get-InstalledPackageState `$installedSet = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) foreach (`$id in @(`$script:LastInstalledIds)) { if (-not [string]::IsNullOrWhiteSpace([string]`$id)) { [void]`$installedSet.Add([string]`$id) } } if (-not `$installedSet) { `$installedSet = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) } `$installedNames = New-Object 'System.Collections.Generic.List[string]' foreach (`$app in (`$apps | Sort-Object DisplayName)) { `$state = Get-AppStatus -App `$app -InstalledSet `$installedSet -QueueItems `$script:LastQueue `$item = New-Object System.Windows.Forms.ListViewItem(`$app.DisplayName) [void]`$item.SubItems.Add(`$app.Description) [void]`$item.SubItems.Add(`$state) [void]`$item.SubItems.Add(`$app.Alias) [void]`$item.SubItems.Add(`$app.PackageId) `$item.Tag = `$app if (`$state -eq 'Installiert') { `$item.ForeColor = [System.Drawing.Color]::DarkGreen [void]`$installedNames.Add(`$app.DisplayName) } elseif (`$state -like '*Warteschlange') { `$item.ForeColor = [System.Drawing.Color]::DarkGoldenrod } elseif (`$state -like 'Wird *') { `$item.ForeColor = [System.Drawing.Color]::RoyalBlue } [void]`$list.Items.Add(`$item) } foreach (`$col in `$list.Columns) { if (`$col.Text -eq 'Paket-ID') { `$col.Width = 0 } } `$list.Sort() if (`$installedState.GeneratedAt) { try { `$timestamp = [DateTime]::Parse(`$installedState.GeneratedAt).ToLocalTime() `$snapshotLabel.Text = 'Installationsdaten vom: ' + `$timestamp.ToString('dd.MM.yyyy HH:mm:ss') } catch { `$snapshotLabel.Text = 'Installationsdaten vom: ' + `$installedState.GeneratedAt } } else { `$snapshotLabel.Text = 'Installationsdaten: kein Datenstand vorhanden' } if (`$installedNames.Count -gt 0) { `$installedLabel.Text = 'Bereits installiert: ' + (`$installedNames -join ', ') } else { `$installedLabel.Text = 'Bereits installiert: keine freigegebene Software erkannt' } Update-ProgressArea } finally { `$list.EndUpdate() if (-not [string]::IsNullOrWhiteSpace(`$selectedAlias)) { foreach (`$item in `$list.Items) { if (`$item.SubItems.Count -gt 3 -and [string]::Equals(`$item.SubItems[3].Text, `$selectedAlias, [System.StringComparison]::OrdinalIgnoreCase)) { `$item.Selected = `$true `$item.EnsureVisible() break } } } Update-InstallButtonState } } `$status = New-Object System.Windows.Forms.Label `$status.Location = New-Object System.Drawing.Point(12, 526) `$status.Size = New-Object System.Drawing.Size(700, 20) `$status.Anchor = 'Left, Right, Bottom' `$status.ForeColor = [System.Drawing.Color]::DimGray `$status.Text = 'Bereit.' `$progressBar = New-Object System.Windows.Forms.ProgressBar `$progressBar.Location = New-Object System.Drawing.Point(12, 548) `$progressBar.Size = New-Object System.Drawing.Size(700, 12) `$progressBar.Anchor = 'Left, Right, Bottom' `$progressBar.Style = 'Continuous' `$progressBar.Visible = `$false `$btnInstall = New-Object System.Windows.Forms.Button `$btnInstall.Location = New-Object System.Drawing.Point(12, 568) `$btnInstall.Size = New-Object System.Drawing.Size(170, 30) `$btnInstall.Anchor = 'Left, Bottom' `$btnInstall.Text = 'Installieren' `$btnInstall.Enabled = `$false `$btnUninstall = New-Object System.Windows.Forms.Button `$btnUninstall.Location = New-Object System.Drawing.Point(190, 568) `$btnUninstall.Size = New-Object System.Drawing.Size(140, 30) `$btnUninstall.Anchor = 'Left, Bottom' `$btnUninstall.Text = 'Deinstallation' `$btnUninstall.Enabled = `$false `$btnRefresh = New-Object System.Windows.Forms.Button `$btnRefresh.Location = New-Object System.Drawing.Point(338, 568) `$btnRefresh.Size = New-Object System.Drawing.Size(110, 30) `$btnRefresh.Anchor = 'Left, Bottom' `$btnRefresh.Text = 'Aktualisieren' `$btnOpenLog = New-Object System.Windows.Forms.Button `$btnOpenLog.Location = New-Object System.Drawing.Point(456, 568) `$btnOpenLog.Size = New-Object System.Drawing.Size(130, 30) `$btnOpenLog.Anchor = 'Left, Bottom' `$btnOpenLog.Text = 'Protokoll öffnen' `$btnClose = New-Object System.Windows.Forms.Button `$btnClose.Location = New-Object System.Drawing.Point(832, 568) `$btnClose.Size = New-Object System.Drawing.Size(120, 30) `$btnClose.Anchor = 'Right, Bottom' `$btnClose.Text = 'Schließen' `$script:StateWatcher = `$null `$script:SnapshotWatcher = `$null `$stateRefreshTimer = New-Object System.Windows.Forms.Timer `$stateRefreshTimer.Interval = 500 `$script:StateRefreshPending = `$false `$script:RequestWorkerBusy = `$false function Start-AppRequestAsync { param( [Parameter(Mandatory=`$true)]`$Selected, [Parameter(Mandatory=`$true)][ValidateSet('Install','Uninstall')][string]`$Action ) if (`$script:RequestWorkerBusy) { `$status.Text = 'Eine Anfrage wird bereits verarbeitet.' return } `$script:RequestWorkerBusy = `$true `$btnInstall.Enabled = `$false `$btnUninstall.Enabled = `$false `$btnRefresh.Enabled = `$false try { `$response = New-AppRequest -Alias ([string]`$Selected.Alias) -Action `$Action `$script:PendingRequests[[string]`$Selected.Alias] = [pscustomobject]@{ Action = `$Action; CreatedAt = (Get-Date) } if (`$Action -eq 'Uninstall') { `$verb = 'Deinstallation' } else { `$verb = 'Installation' } if (`$response.Message) { `$status.Text = [string]`$response.Message } else { `$status.Text = "`$verb angefordert: {0}" -f `$Selected.DisplayName } [System.Windows.Forms.MessageBox]::Show( ("`$verb angefordert:`r`n`r`n{0}`r`nAlias: {1}" -f `$Selected.DisplayName, `$Selected.Alias), `$CatalogTitle, [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Information ) | Out-Null `$script:StateRefreshPending = `$true } catch { throw } finally { `$script:RequestWorkerBusy = `$false `$btnRefresh.Enabled = `$true Update-InstallButtonState } } function Start-RefreshStatusAsync { if (`$script:RequestWorkerBusy) { `$status.Text = 'Eine Anfrage wird bereits verarbeitet.' return } `$script:RequestWorkerBusy = `$true `$btnInstall.Enabled = `$false `$btnUninstall.Enabled = `$false `$btnRefresh.Enabled = `$false `$status.Text = 'Installationsdaten-Aktualisierung wird angefordert...' try { `$response = Invoke-CatalogLocal -Command 'RefreshStatus' if (`$response.Message) { `$status.Text = [string]`$response.Message } else { `$status.Text = 'Installationsdaten-Aktualisierung angefordert.' } `$script:StateRefreshPending = `$true } catch { throw } finally { `$script:RequestWorkerBusy = `$false `$btnRefresh.Enabled = `$true Update-InstallButtonState } } `$btnInstall.Add_Click({ try { if (`$list.SelectedItems.Count -lt 1) { [System.Windows.Forms.MessageBox]::Show( 'Bitte zuerst eine Anwendung auswählen.', `$CatalogTitle, [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Information ) | Out-Null return } `$selectedItem = `$list.SelectedItems[0] `$selected = `$selectedItem.Tag `$currentState = `$selectedItem.SubItems[2].Text if (`$currentState -eq 'Installiert') { [System.Windows.Forms.MessageBox]::Show( ("{0} ist bereits installiert." -f `$selected.DisplayName), `$CatalogTitle, [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Information ) | Out-Null return } `$status.Text = "Installation wird eingereiht: {0}" -f `$selected.DisplayName `$selectedItem.SubItems[2].Text = 'In Warteschlange' `$selectedItem.ForeColor = [System.Drawing.Color]::DarkGoldenrod Update-InstallButtonState Start-AppRequestAsync -Selected `$selected -Action 'Install' } catch { `$status.Text = "Fehler: " + `$_.Exception.Message [System.Windows.Forms.MessageBox]::Show( `$_.Exception.Message, `$CatalogTitle, [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Error ) | Out-Null } }) `$btnUninstall.Add_Click({ try { if (`$list.SelectedItems.Count -lt 1) { [System.Windows.Forms.MessageBox]::Show( 'Bitte zuerst eine Anwendung auswählen.', `$CatalogTitle, [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Information ) | Out-Null return } `$selectedItem = `$list.SelectedItems[0] `$selected = `$selectedItem.Tag `$currentState = `$selectedItem.SubItems[2].Text if (`$currentState -ne 'Installiert') { [System.Windows.Forms.MessageBox]::Show( ("{0} ist derzeit nicht als installiert markiert." -f `$selected.DisplayName), `$CatalogTitle, [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Information ) | Out-Null return } `$status.Text = "Deinstallation wird eingereiht: {0}" -f `$selected.DisplayName `$selectedItem.SubItems[2].Text = 'Deinstallation in Warteschlange' `$selectedItem.ForeColor = [System.Drawing.Color]::DarkGoldenrod Update-InstallButtonState Start-AppRequestAsync -Selected `$selected -Action 'Uninstall' } catch { `$status.Text = "Fehler: " + `$_.Exception.Message [System.Windows.Forms.MessageBox]::Show( `$_.Exception.Message, `$CatalogTitle, [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Error ) | Out-Null } }) `$btnRefresh.Add_Click({ try { Start-RefreshStatusAsync } catch { `$status.Text = "Fehler: " + `$_.Exception.Message [System.Windows.Forms.MessageBox]::Show( `$_.Exception.Message, `$CatalogTitle, [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Error ) | Out-Null } }) `$stateRefreshTimer.Add_Tick({ if (-not `$script:StateRefreshPending) { return } if (`$script:RequestWorkerBusy) { return } `$script:StateRefreshPending = `$false try { Load-AppList } catch { `$status.Text = "Fehler bei automatischer Aktualisierung: " + `$_.Exception.Message } }) `$btnOpenLog.Add_Click({ try { if ([string]::IsNullOrWhiteSpace(`$LogFile)) { throw 'Der Pfad zur Protokolldatei ist leer.' } if (Test-Path -LiteralPath `$LogFile) { Start-Process -FilePath 'notepad.exe' -ArgumentList @(`$LogFile) } else { [System.Windows.Forms.MessageBox]::Show( 'Die Protokolldatei wurde noch nicht erstellt.', `$CatalogTitle, [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Information ) | Out-Null } } catch { [System.Windows.Forms.MessageBox]::Show( `$_.Exception.Message, `$CatalogTitle, [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Error ) | Out-Null } }) `$btnClose.Add_Click({ `$form.Close() }) `$form.Add_Shown({ try { `$stateDir = Split-Path -Path `$QueueStatePath -Parent `$stateFile = Split-Path -Path `$QueueStatePath -Leaf if (Test-Path -LiteralPath `$stateDir) { `$script:StateWatcher = New-Object System.IO.FileSystemWatcher(`$stateDir, `$stateFile) `$script:StateWatcher.NotifyFilter = [System.IO.NotifyFilters]'LastWrite, FileName, Size' `$script:StateWatcher.Add_Changed({ `$script:StateRefreshPending = `$true }) `$script:StateWatcher.Add_Created({ `$script:StateRefreshPending = `$true }) `$script:StateWatcher.Add_Renamed({ `$script:StateRefreshPending = `$true }) `$script:StateWatcher.EnableRaisingEvents = `$true } } catch { `$status.Text = "Statusueberwachung konnte nicht gestartet werden: " + `$_.Exception.Message } try { `$snapshotDir = Split-Path -Path `$InstalledPackagesPath -Parent `$snapshotFile = Split-Path -Path `$InstalledPackagesPath -Leaf if (Test-Path -LiteralPath `$snapshotDir) { `$script:SnapshotWatcher = New-Object System.IO.FileSystemWatcher(`$snapshotDir, `$snapshotFile) `$script:SnapshotWatcher.NotifyFilter = [System.IO.NotifyFilters]'LastWrite, FileName, Size' `$script:SnapshotWatcher.Add_Changed({ `$script:StateRefreshPending = `$true }) `$script:SnapshotWatcher.Add_Created({ `$script:StateRefreshPending = `$true }) `$script:SnapshotWatcher.Add_Renamed({ `$script:StateRefreshPending = `$true }) `$script:SnapshotWatcher.EnableRaisingEvents = `$true } } catch { `$status.Text = "Snapshotueberwachung konnte nicht gestartet werden: " + `$_.Exception.Message } `$stateRefreshTimer.Start() }) `$form.Add_FormClosing({ `$stateRefreshTimer.Stop() `$stateRefreshTimer.Dispose() if (`$null -ne `$script:StateWatcher) { `$script:StateWatcher.Dispose() } if (`$null -ne `$script:SnapshotWatcher) { `$script:SnapshotWatcher.Dispose() } }) `$list.Add_SelectedIndexChanged({ Update-InstallButtonState }) `$list.Add_ColumnClick({ param(`$sender, `$eventArgs) Sort-ListByColumn -ColumnIndex `$eventArgs.Column }) `$list.Add_DoubleClick({ if (`$list.SelectedItems.Count -gt 0) { if (`$btnInstall.Enabled) { `$btnInstall.PerformClick() } } }) `$form.Controls.Add(`$headerPanel) `$form.Controls.Add(`$groupApps) `$form.Controls.Add(`$infoPanel) `$form.Controls.Add(`$status) `$form.Controls.Add(`$progressBar) `$form.Controls.Add(`$btnInstall) `$form.Controls.Add(`$btnUninstall) `$form.Controls.Add(`$btnRefresh) `$form.Controls.Add(`$btnOpenLog) `$form.Controls.Add(`$btnClose) `$form.AcceptButton = `$btnInstall `$form.CancelButton = `$btnClose try { Load-AppList } catch { `$status.Text = "Fehler beim Laden: " + `$_.Exception.Message [System.Windows.Forms.MessageBox]::Show( "Der Softwarekatalog konnte nicht vollständig geladen werden.`r`n`r`n" + `$_.Exception.Message, `$CatalogTitle, [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Error ) | Out-Null } [void]`$form.ShowDialog() "@ Write-TextFile -Path $CatalogGuiPath -Content $content -Encoding Utf8Bom Set-FileAclAdminsOnly -Path $CatalogGuiPath } function New-CatalogLauncher { $psQuoted = $PowerShellExe.Replace('"', '""') $guiQuoted = $CatalogGuiPath.Replace('"', '""') $vbs = @" Set shell = CreateObject("WScript.Shell") cmdLine = Chr(34) & "$psQuoted" & Chr(34) & " -NoProfile -ExecutionPolicy Bypass -STA -WindowStyle Hidden -File " & Chr(34) & "$guiQuoted" & Chr(34) shell.Run cmdLine, 0, False "@ Write-TextFile -Path $CatalogLauncherPath -Content $vbs -Encoding Unicode Set-FileAclAdminsOnly -Path $CatalogLauncherPath } function New-Shortcut { param( [Parameter(Mandatory)][string]$ShortcutPath ) $parent = Split-Path -Path $ShortcutPath -Parent Ensure-Directory -Path $parent $shell = New-Object -ComObject WScript.Shell $shortcut = $shell.CreateShortcut($ShortcutPath) $shortcut.TargetPath = $WScriptExe $shortcut.Arguments = "`"$CatalogLauncherPath`"" $shortcut.WorkingDirectory = $BaseDir $shortcut.WindowStyle = 1 $shortcut.Description = $CatalogDisplayName $shortcut.IconLocation = "$env:SystemRoot\System32\shell32.dll,80" $shortcut.Save() } function Unregister-LegacyQueueTask { try { $task = Get-ScheduledTask -TaskName $LegacyQueueTaskName -ErrorAction SilentlyContinue if ($task) { Write-Log "Removing legacy scheduled task '$LegacyQueueTaskName'." Unregister-ScheduledTask -TaskName $LegacyQueueTaskName -Confirm:$false } } catch { Write-Log "Could not remove legacy scheduled task '$LegacyQueueTaskName': $($_.Exception.Message)" 'WARN' } } function Register-OrUpdateTasks { Unregister-LegacyQueueTask try { $existingDaemon = Get-ScheduledTask -TaskName $DaemonTaskName -ErrorAction SilentlyContinue if ($existingDaemon) { Write-Log "Removing obsolete scheduled task '$DaemonTaskName'." Stop-ScheduledTask -TaskName $DaemonTaskName -ErrorAction SilentlyContinue Unregister-ScheduledTask -TaskName $DaemonTaskName -Confirm:$false } } catch { Write-Log "Could not remove daemon task '$DaemonTaskName': $($_.Exception.Message)" 'WARN' } $principal = New-ScheduledTaskPrincipal ` -UserId 'SYSTEM' ` -LogonType ServiceAccount ` -RunLevel Highest Write-Log "Registering/updating scheduled task '$RequestTaskName'." $requestArgs = "-NoProfile -ExecutionPolicy Bypass -File `"$LocalScriptPath`" -Mode ProcessRequests" $requestAction = New-ScheduledTaskAction -Execute $PowerShellExe -Argument $requestArgs $requestStartupTrigger = New-ScheduledTaskTrigger -AtStartup $requestPollTrigger = New-ScheduledTaskTrigger ` -Once ` -At ((Get-Date).AddMinutes(1)) ` -RepetitionInterval (New-TimeSpan -Minutes 1) ` -RepetitionDuration (New-TimeSpan -Days 3650) $requestSettings = New-ScheduledTaskSettingsSet ` -AllowStartIfOnBatteries ` -DontStopIfGoingOnBatteries ` -StartWhenAvailable ` -MultipleInstances IgnoreNew ` -ExecutionTimeLimit (New-TimeSpan -Minutes 10) $requestTask = New-ScheduledTask ` -Action $requestAction ` -Trigger @($requestStartupTrigger, $requestPollTrigger) ` -Principal $principal ` -Settings $requestSettings Register-ScheduledTask -TaskName $RequestTaskName -InputObject $requestTask -Force | Out-Null Write-Log "Registering/updating scheduled task '$UpgradeTaskName'." $upgradeArgs = "-NoProfile -ExecutionPolicy Bypass -File `"$LocalScriptPath`" -Mode UpgradeAll" $upgradeAction = New-ScheduledTaskAction -Execute $PowerShellExe -Argument $upgradeArgs $upgradeTrigger = New-ScheduledTaskTrigger ` -Once ` -At ((Get-Date).AddMinutes(2)) ` -RepetitionInterval (New-TimeSpan -Hours 2) ` -RepetitionDuration (New-TimeSpan -Days 3650) $upgradeSettings = New-ScheduledTaskSettingsSet ` -AllowStartIfOnBatteries ` -DontStopIfGoingOnBatteries ` -StartWhenAvailable ` -MultipleInstances IgnoreNew ` -ExecutionTimeLimit (New-TimeSpan -Hours 2) $upgradeTask = New-ScheduledTask ` -Action $upgradeAction ` -Trigger $upgradeTrigger ` -Principal $principal ` -Settings $upgradeSettings Register-ScheduledTask -TaskName $UpgradeTaskName -InputObject $upgradeTask -Force | Out-Null try { Start-ScheduledTask -TaskName $RequestTaskName } catch { Write-Log "Could not start request task '$RequestTaskName': $($_.Exception.Message)" 'WARN' } } function Invoke-WingetInstall { param( [Parameter(Mandatory)][string]$WingetPath, [Parameter(Mandatory)][string]$PackageId, [string]$QueueItemId ) $arguments = @( 'install' '--id', $PackageId '--exact' '--scope', 'machine' '--silent' '--accept-package-agreements' '--accept-source-agreements' '--disable-interactivity' ) Write-Log "Executing: `"$WingetPath`" $($arguments -join ' ')" if ($QueueItemId) { Update-QueueItemProgress -Id $QueueItemId -Stage 'Install' -StageText 'Installation läuft...' -IsIndeterminate:$true } $outputLines = New-Object 'System.Collections.Generic.List[string]' & $WingetPath @arguments 2>&1 | ForEach-Object { $line = [string]$_ [void]$outputLines.Add($line) if ($line.Trim()) { Write-Log "winget: $line" if ($QueueItemId) { $percent = Get-ProgressPercentFromLine -Line $line if ($null -ne $percent) { Update-QueueItemProgress -Id $QueueItemId -Stage 'Download' -StageText ("Download läuft... {0}%" -f $percent) -ProgressPercent $percent -IsIndeterminate:$false } } } } $exitCode = $LASTEXITCODE return $exitCode } function Invoke-WingetUninstall { param( [Parameter(Mandatory)][string]$WingetPath, [Parameter(Mandatory)][string]$PackageId, [string]$QueueItemId ) $arguments = @( 'uninstall' '--id', $PackageId '--exact' '--scope', 'machine' '--silent' '--accept-source-agreements' '--disable-interactivity' ) Write-Log "Executing: `"$WingetPath`" $($arguments -join ' ')" if ($QueueItemId) { Update-QueueItemProgress -Id $QueueItemId -Stage 'Uninstall' -StageText 'Deinstallation läuft...' -IsIndeterminate:$true } $outputLines = New-Object 'System.Collections.Generic.List[string]' & $WingetPath @arguments 2>&1 | ForEach-Object { $line = [string]$_ [void]$outputLines.Add($line) if ($line.Trim()) { Write-Log "winget: $line" if ($QueueItemId) { $percent = Get-ProgressPercentFromLine -Line $line if ($null -ne $percent) { Update-QueueItemProgress -Id $QueueItemId -Stage 'Uninstall' -StageText ("Deinstallation läuft... {0}%" -f $percent) -ProgressPercent $percent -IsIndeterminate:$false } } } } $exitCode = $LASTEXITCODE return $exitCode } function Invoke-WingetUpgradeAll { param( [Parameter(Mandatory)][string]$WingetPath, [string]$QueueItemId ) $arguments = @( 'upgrade' '--all' '--scope', 'machine' '--silent' '--accept-package-agreements' '--accept-source-agreements' '--disable-interactivity' ) Write-Log "Executing 2-hour upgrade: `"$WingetPath`" $($arguments -join ' ')" if ($QueueItemId) { Update-QueueItemProgress -Id $QueueItemId -Stage 'Upgrade' -StageText 'Updates laufen...' -IsIndeterminate:$true } $outputLines = New-Object 'System.Collections.Generic.List[string]' & $WingetPath @arguments 2>&1 | ForEach-Object { $line = [string]$_ [void]$outputLines.Add($line) if ($line.Trim()) { Write-Log "winget upgrade: $line" if ($QueueItemId) { $percent = Get-ProgressPercentFromLine -Line $line if ($null -ne $percent) { Update-QueueItemProgress -Id $QueueItemId -Stage 'Upgrade' -StageText ("Updates laufen... {0}%" -f $percent) -ProgressPercent $percent -IsIndeterminate:$false } } } } $exitCode = $LASTEXITCODE return $exitCode } function Update-InstalledPackagesSnapshot { param( [Parameter(Mandatory)][string]$WingetPath, [string]$QueueItemId ) Ensure-Directory -Path $BaseDir $tempRoot = if ($env:TEMP) { $env:TEMP } elseif ($env:TMP) { $env:TMP } else { $BaseDir } $tempFile = Join-Path $tempRoot ("selfservice-winget-export-{0}.json" -f [Guid]::NewGuid().ToString('N')) $snapshotTempFile = Join-Path $BaseDir ("Installed-Packages-{0}.json.tmp" -f [Guid]::NewGuid().ToString('N')) $arguments = @( 'export' '--output', $tempFile '--accept-source-agreements' '--disable-interactivity' ) Write-Log 'Refreshing installed package snapshot.' if ($QueueItemId) { Update-QueueItemProgress -Id $QueueItemId -Stage 'Snapshot' -StageText 'Installationsdaten werden aktualisiert...' -IsIndeterminate:$true } $output = & $WingetPath @arguments 2>&1 | Out-String $exitCode = $LASTEXITCODE if ($output) { foreach ($line in ($output -split "`r?`n")) { if ($line.Trim()) { Write-Log "winget export: $line" } } } if ($exitCode -ne 0) { Write-Log "winget export failed with code $exitCode; snapshot was not updated." 'WARN' if (Test-Path -LiteralPath $tempFile) { Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue } return $false } if (-not (Test-Path -LiteralPath $tempFile)) { Write-Log 'winget export did not produce an output file; snapshot was not updated.' 'WARN' return $false } try { $raw = Get-Content -LiteralPath $tempFile -Raw -Encoding UTF8 $export = $raw | ConvertFrom-Json $packageIds = @() foreach ($source in @($export.Sources)) { foreach ($pkg in @($source.Packages)) { if ($pkg.PackageIdentifier) { $packageIds += [string]$pkg.PackageIdentifier } elseif ($pkg.PackageId) { $packageIds += [string]$pkg.PackageId } } } $packageIds = $packageIds | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() } | Sort-Object -Unique $snapshot = [ordered]@{ GeneratedAt = (Get-Date).ToString('o') InstalledPackageIds = @($packageIds) } $snapshot | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $snapshotTempFile -Encoding UTF8 Move-Item -LiteralPath $snapshotTempFile -Destination $InstalledPackagesPath -Force Write-Log "Installed package snapshot written to '$InstalledPackagesPath' (count=$(@($packageIds).Count))." return $true } catch { Write-Log "Failed to write installed package snapshot: $($_.Exception.Message)" 'WARN' return $false } finally { if (Test-Path -LiteralPath $tempFile) { Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue } if (Test-Path -LiteralPath $snapshotTempFile) { Remove-Item -LiteralPath $snapshotTempFile -Force -ErrorAction SilentlyContinue } } } 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([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 } } if ($WingetPath) { try { Invoke-WithWingetLock -TimeoutSeconds 2 -ScriptBlock { $null = Update-InstalledPackagesSnapshot -WingetPath $WingetPath } | Out-Null } catch { Write-Log "Could not refresh snapshot before required queueing: $($_.Exception.Message)" 'WARN' } } $queued = Ensure-RequiredSoftwareQueued if ($queued -gt 0) { Start-QueueWorker } return [pscustomobject]@{ Installed = 0; Failed = 0; Skipped = 0; Queued = $queued } } function Reset-RunningQueueItems { $null = Update-QueueState -ScriptBlock { param($state) foreach ($item in @($state.Queue)) { if (-not $item) { continue } if ($item.State -eq 'Running') { $item.State = 'Queued' $item.Stage = 'Queued' $item.StageText = 'Nach Neustart erneut eingereiht' $item.ProgressPercent = $null $item.IsIndeterminate = $false $item.StartedAt = $null } } $state.Current = $null } } function Get-NextQueuedItem { $script:NextQueueItem = $null $null = Update-QueueState -ScriptBlock { param($state) foreach ($item in (@($state.Queue) | Sort-Object CreatedAt)) { if (-not $item) { continue } if ($item.State -eq 'Queued') { $item.State = 'Running' $item.Stage = 'Starting' $item.StageText = 'Vorbereitung...' $item.ProgressPercent = $null $item.IsIndeterminate = $true $item.StartedAt = (Get-Date).ToString('o') $state.Current = [pscustomobject][ordered]@{ Id = $item.Id Action = $item.Action Alias = $item.Alias DisplayName = $item.DisplayName Stage = $item.Stage StageText = $item.StageText ProgressPercent = $item.ProgressPercent IsIndeterminate = $item.IsIndeterminate } $script:NextQueueItem = $item break } } } $item = $script:NextQueueItem $script:NextQueueItem = $null return $item } function Complete-QueueItem { param( [Parameter(Mandatory)][string]$Id, [Parameter(Mandatory)][ValidateSet('Succeeded','Failed')][string]$State, [int]$ExitCode = 0, [string]$Message ) $null = Update-QueueState -ScriptBlock { param($queueState) foreach ($item in @($queueState.Queue)) { if (-not $item) { continue } if ($item.Id -eq $Id) { $item.State = $State $item.Stage = $State $item.StageText = $Message $item.ProgressPercent = if ($State -eq 'Succeeded') { 100 } else { $null } $item.IsIndeterminate = $false $item.FinishedAt = (Get-Date).ToString('o') $item.ExitCode = $ExitCode $item.Message = $Message break } } $queueState.Current = $null } } function Invoke-QueuedItem { param( [Parameter(Mandatory)]$Item, [Parameter(Mandatory)][string]$WingetPath ) if ($Item.Action -eq 'UpgradeAll') { $exitCode = Invoke-WingetUpgradeAll -WingetPath $WingetPath -QueueItemId $Item.Id $null = Update-InstalledPackagesSnapshot -WingetPath $WingetPath -QueueItemId $Item.Id if ($exitCode -eq 0) { Complete-QueueItem -Id $Item.Id -State Succeeded -ExitCode $exitCode -Message 'Updates abgeschlossen.' } else { Complete-QueueItem -Id $Item.Id -State Failed -ExitCode $exitCode -Message "Updates fehlgeschlagen (Code $exitCode)." } return } $app = Get-AppCatalogObjectByAlias -Alias ([string]$Item.Alias) if (-not $app) { Complete-QueueItem -Id $Item.Id -State Failed -ExitCode 1 -Message 'Alias ist nicht mehr freigegeben.' return } $requiredSet = Get-RequiredAliasSet if ($Item.Action -eq 'Uninstall' -and $requiredSet.Contains([string]$Item.Alias)) { Complete-QueueItem -Id $Item.Id -State Failed -ExitCode 1 -Message 'Pflichtsoftware kann nicht deinstalliert werden.' return } $installedSet = Get-InstalledPackageIdSet if ($Item.Action -eq 'Install' -and $installedSet.Contains([string]$app.PackageId)) { Complete-QueueItem -Id $Item.Id -State Succeeded -ExitCode 0 -Message 'Bereits installiert.' return } if ($Item.Action -eq 'Install') { $exitCode = Invoke-WingetInstall -WingetPath $WingetPath -PackageId ([string]$app.PackageId) -QueueItemId $Item.Id } else { $exitCode = Invoke-WingetUninstall -WingetPath $WingetPath -PackageId ([string]$app.PackageId) -QueueItemId $Item.Id } $null = Update-InstalledPackagesSnapshot -WingetPath $WingetPath -QueueItemId $Item.Id if ($exitCode -eq 0) { Complete-QueueItem -Id $Item.Id -State Succeeded -ExitCode $exitCode -Message 'Abgeschlossen.' } else { Complete-QueueItem -Id $Item.Id -State Failed -ExitCode $exitCode -Message "Fehlgeschlagen (Code $exitCode)." } } function Invoke-ProcessQueueMode { Ensure-Directory -Path $BaseDir Ensure-Directory -Path $LogDir $wingetPath = Ensure-WingetAvailable if (-not $wingetPath) { Write-Log 'Aborting queue processing because winget is unavailable.' 'ERROR' return } Invoke-WithWingetLock -ScriptBlock { Reset-RunningQueueItems $null = Update-InstalledPackagesSnapshot -WingetPath $wingetPath $null = Ensure-RequiredSoftwareQueued while ($true) { $item = Get-NextQueuedItem if (-not $item) { break } Write-Log "Processing queued item id='$($item.Id)' action='$($item.Action)' alias='$($item.Alias)' source='$($item.Source)' requestedBy='$($item.RequestedBy)'" try { Invoke-QueuedItem -Item $item -WingetPath $wingetPath } catch { Write-Log "Queued item '$($item.Id)' failed: $($_.Exception.Message)" 'ERROR' Complete-QueueItem -Id $item.Id -State Failed -ExitCode 1 -Message $_.Exception.Message } } $null = Update-QueueState -ScriptBlock { param($state) $state.Current = $null } } } function Invoke-UpgradeAllMode { Ensure-Directory -Path $BaseDir Ensure-Directory -Path $LogDir $item = New-QueueItem -Action UpgradeAll -DisplayName 'winget upgrade --all' -Source System -RequestedBy 'System' -RequestedBySid 'System' $result = Add-QueueItem -Item $item if ($result.Added) { Write-Log 'Queued 2-hour winget upgrade.' } else { Write-Log '2-hour winget upgrade is already queued or running.' } Start-QueueWorker } function Invoke-ApplyPolicyMode { Ensure-Directory -Path $BaseDir Ensure-Directory -Path $LogDir $wingetPath = Ensure-WingetAvailable $result = Invoke-RequiredSoftwarePolicy -WingetPath $wingetPath Write-Log "Required policy apply queued items=$($result.Queued)." } function Invoke-ProcessRequestMode { Ensure-Directory -Path $BaseDir Ensure-Directory -Path $LogDir if ([string]::IsNullOrWhiteSpace($AppAlias)) { throw 'AppAlias is required for ProcessRequest mode.' } $alias = $AppAlias.Trim().ToLowerInvariant() $app = Get-AppCatalogObjectByAlias -Alias $alias if (-not $app) { Write-Log "Worker rejected alias '$alias' because it is no longer in catalog policy. RequestedBy='$RequestedBy' Sid='$RequestedBySid'" 'WARN' return } if ($RequestAction -eq 'Install' -and -not (Test-UserInstallsAllowed)) { Write-Log "Worker rejected install for alias '$alias' because user installs are disabled. RequestedBy='$RequestedBy' Sid='$RequestedBySid'" 'WARN' return } if ($RequestAction -eq 'Uninstall' -and -not (Test-UserUninstallAllowed)) { Write-Log "Worker rejected uninstall for alias '$alias' because user uninstalls are disabled. RequestedBy='$RequestedBy' Sid='$RequestedBySid'" 'WARN' return } $result = Add-SoftwareRequestToQueue -Action $RequestAction -Alias $alias -RequestedBy $RequestedBy -RequestedBySid $RequestedBySid -Source User Write-Log "Queued worker request action=$RequestAction alias='$alias' added=$($result.Added) RequestedBy='$RequestedBy' Sid='$RequestedBySid'" Start-QueueWorker } function Get-RequestFileOwnerInfo { param([Parameter(Mandatory)][string]$Path) $ownerName = 'Unknown' $ownerSid = $null try { $acl = Get-Acl -LiteralPath $Path -ErrorAction Stop if ($acl.Owner) { $ownerName = [string]$acl.Owner try { $account = New-Object System.Security.Principal.NTAccount($ownerName) $ownerSid = $account.Translate([System.Security.Principal.SecurityIdentifier]).Value } catch { } } } catch { Write-Log "Could not read request file owner '$Path': $($_.Exception.Message)" 'WARN' } [pscustomobject]@{ Name = $ownerName Sid = $ownerSid } } function Move-RequestFile { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$DestinationDir, [string]$ErrorMessage ) Ensure-Directory -Path $DestinationDir $leaf = Split-Path -Path $Path -Leaf $destination = Join-Path $DestinationDir $leaf if (Test-Path -LiteralPath $destination) { $destination = Join-Path $DestinationDir ("{0}-{1}.json" -f [IO.Path]::GetFileNameWithoutExtension($leaf), [Guid]::NewGuid().ToString('N')) } Move-Item -LiteralPath $Path -Destination $destination -Force Set-FileAclAdminsOnly -Path $destination if (-not [string]::IsNullOrWhiteSpace($ErrorMessage)) { Set-Content -LiteralPath ($destination + '.error.txt') -Value $ErrorMessage -Encoding UTF8 Set-FileAclAdminsOnly -Path ($destination + '.error.txt') } } function Invoke-ProcessRequestsMode { Ensure-Directory -Path $BaseDir Ensure-Directory -Path $LogDir Ensure-Directory -Path $RequestsDir Ensure-Directory -Path $ProcessedRequestsDir Ensure-Directory -Path $FailedRequestsDir $shouldStartQueue = $false $files = @(Get-ChildItem -LiteralPath $RequestsDir -Filter '*.json' -File -ErrorAction SilentlyContinue | Sort-Object CreationTimeUtc | Select-Object -First 200) foreach ($file in $files) { try { if (($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Request file is a reparse point.' } if ($file.Length -gt 32768) { throw "Request file is too large ($($file.Length) bytes)." } $raw = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8 if ([string]::IsNullOrWhiteSpace($raw)) { throw 'Request file is empty.' } $request = $raw | ConvertFrom-Json $commandValue = $null if ($request.PSObject.Properties['Command'] -and $request.Command) { $commandValue = [string]$request.Command } elseif ($request.PSObject.Properties['Action'] -and $request.Action) { $commandValue = [string]$request.Action } if ([string]::IsNullOrWhiteSpace($commandValue)) { throw 'Request is missing Command.' } $command = $commandValue.Trim().ToLowerInvariant() if ($command -in @('refreshstatus','getstatus')) { $owner = Get-RequestFileOwnerInfo -Path $file.FullName Write-Log "Accepted status refresh request file '$($file.Name)' owner='$($owner.Name)' sid='$($owner.Sid)'." Move-RequestFile -Path $file.FullName -DestinationDir $ProcessedRequestsDir $shouldStartQueue = $true continue } if ($command -eq 'install') { $action = 'Install' if (-not (Test-UserInstallsAllowed)) { throw 'Benutzerinstallationen sind per Richtlinie deaktiviert.' } } elseif ($command -eq 'uninstall') { $action = 'Uninstall' if (-not (Test-UserUninstallAllowed)) { throw 'Benutzerdeinstallationen sind per Richtlinie deaktiviert.' } } else { throw "Unsupported request command '$commandValue'." } $aliasValue = $null if ($request.PSObject.Properties['AppAlias'] -and $request.AppAlias) { $aliasValue = [string]$request.AppAlias } elseif ($request.PSObject.Properties['Alias'] -and $request.Alias) { $aliasValue = [string]$request.Alias } if ([string]::IsNullOrWhiteSpace($aliasValue)) { throw 'Request is missing AppAlias.' } $alias = $aliasValue.Trim().ToLowerInvariant() $owner = Get-RequestFileOwnerInfo -Path $file.FullName $result = Add-SoftwareRequestToQueue -Action $action -Alias $alias -RequestedBy $owner.Name -RequestedBySid $owner.Sid -Source User Write-Log "Accepted request file '$($file.Name)' action=$action alias='$alias' added=$($result.Added) owner='$($owner.Name)' sid='$($owner.Sid)'." Move-RequestFile -Path $file.FullName -DestinationDir $ProcessedRequestsDir $shouldStartQueue = $true } catch { $message = $_.Exception.Message Write-Log "Rejected request file '$($file.Name)': $message" 'WARN' try { Move-RequestFile -Path $file.FullName -DestinationDir $FailedRequestsDir -ErrorMessage $message } catch { Write-Log "Could not move failed request file '$($file.FullName)': $($_.Exception.Message)" 'WARN' } } } $requiredQueued = Ensure-RequiredSoftwareQueued if ($requiredQueued -gt 0) { Write-Log "Queued required software from request worker count=$requiredQueued." $shouldStartQueue = $true } foreach ($queuedItem in @((Get-QueueStateSnapshot).Queue)) { if ($queuedItem -and $queuedItem.State -eq 'Queued') { $shouldStartQueue = $true break } } if ($shouldStartQueue) { if (Test-WingetLockAvailable) { Start-QueueWorker } else { Write-Log 'Queue worker not started because winget lock is already held.' } } } function Install-Service { Ensure-Directory -Path $BaseDir Ensure-Directory -Path $LogDir Ensure-Directory -Path $RequestsDir Ensure-Directory -Path $ProcessedRequestsDir Ensure-Directory -Path $FailedRequestsDir Set-DirectoryAcl -Path $BaseDir -UsersCanModify:$false Set-DirectoryAcl -Path $LogDir -UsersCanModify:$false Set-RequestDirectoryAcl -Path $RequestsDir Set-DirectoryAcl -Path $ProcessedRequestsDir -UsersCanModify:$false Set-DirectoryAcl -Path $FailedRequestsDir -UsersCanModify:$false if (-not $PSCommandPath) { throw 'PSCommandPath is empty; cannot determine the source script path.' } Copy-Item -LiteralPath $PSCommandPath -Destination $LocalScriptPath -Force Set-FileAclAdminsOnly -Path $LocalScriptPath New-UserRequestTool New-CatalogGui New-CatalogLauncher New-Shortcut -ShortcutPath $DesktopShortcutPath New-Shortcut -ShortcutPath $StartMenuShortcutPath Register-OrUpdateTasks Write-Log 'Install/update completed successfully.' } # ---------------------------- # Entry point # ---------------------------- try { switch ($Mode) { 'Install' { Install-Service } 'UpgradeAll' { Invoke-UpgradeAllMode } 'ApplyPolicy' { Invoke-ApplyPolicyMode } 'ProcessQueue' { Invoke-ProcessQueueMode } 'ProcessRequest' { Invoke-ProcessRequestMode } 'ProcessRequests' { Invoke-ProcessRequestsMode } default { throw "Unsupported mode '$Mode'." } } } catch { try { Write-Log "Fatal error in mode '$Mode': $($_.Exception.Message)" 'ERROR' } catch { } throw }