4 Commits

Author SHA1 Message Date
Ludwig Lehnert
526c40403b fix gui timeout; ignore dist
All checks were successful
Release / build-release (push) Successful in 4s
2026-07-01 13:20:24 +00:00
Ludwig Lehnert
7fc0602818 fix gui runspace cleanup
All checks were successful
Release / build-release (push) Successful in 3s
2026-07-01 13:02:48 +00:00
Ludwig Lehnert
3039562e91 fix gui install runspace
All checks were successful
Release / build-release (push) Successful in 5s
2026-07-01 12:18:59 +00:00
Ludwig Lehnert
91208323ab interactive loading bars and more; (fix)
All checks were successful
Release / build-release (push) Successful in 4s
2026-06-29 15:30:39 +00:00
3 changed files with 451 additions and 84 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
dist/

35
dist/setup.ps1 vendored

File diff suppressed because one or more lines are too long

499
setup.ps1
View File

@@ -62,6 +62,7 @@ $CommonDesktopDir = [Environment]::GetFolderPath('CommonDesktopDirectory')
$CommonProgramsDir = [Environment]::GetFolderPath('CommonPrograms')
$DesktopShortcutPath = Join-Path $CommonDesktopDir "$CatalogDisplayName.lnk"
$StartMenuShortcutPath = Join-Path $CommonProgramsDir "$CatalogDisplayName.lnk"
$script:DaemonPostResponseWorkers = @()
# ----------------------------
# Utility functions
@@ -710,6 +711,28 @@ function Start-QueueWorker {
Start-SoftwarekatalogWorker -WorkerMode ProcessQueue
}
function Add-DaemonPostResponseWorker {
param([Parameter(Mandatory)][ValidateSet('ApplyPolicy','ProcessQueue')][string]$WorkerMode)
if (-not $script:DaemonPostResponseWorkers) {
$script:DaemonPostResponseWorkers = @()
}
$script:DaemonPostResponseWorkers = @($script:DaemonPostResponseWorkers + $WorkerMode)
}
function Invoke-DaemonPostResponseWorkers {
foreach ($workerMode in @($script:DaemonPostResponseWorkers | Select-Object -Unique)) {
try {
Start-SoftwarekatalogWorker -WorkerMode $workerMode
}
catch {
Write-Log "Could not start post-response worker '$workerMode': $($_.Exception.Message)" 'WARN'
}
}
$script:DaemonPostResponseWorkers = @()
}
function Get-WingetPath {
$candidates = @()
@@ -841,13 +864,17 @@ function Invoke-CatalogDaemon {
)
try {
`$client.Connect(60000)
`$client.Connect(10000)
`$reader = New-Object System.IO.StreamReader(`$client, `$encoding)
`$writer = New-Object System.IO.StreamWriter(`$client, `$encoding)
`$writer.AutoFlush = `$true
`$writer.WriteLine(`$json)
`$line = `$reader.ReadLine()
`$readTask = `$reader.ReadLineAsync()
if (-not `$readTask.Wait(15000)) {
throw 'Zeitüberschreitung beim Warten auf Antwort des Softwarekatalog-Dienstes.'
}
`$line = `$readTask.Result
if ([string]::IsNullOrWhiteSpace(`$line)) {
throw 'Der Softwarekatalog-Dienst hat keine Antwort gesendet.'
}
@@ -1047,13 +1074,17 @@ function Invoke-CatalogDaemon {
)
try {
`$client.Connect(60000)
`$client.Connect(10000)
`$reader = New-Object System.IO.StreamReader(`$client, `$encoding)
`$writer = New-Object System.IO.StreamWriter(`$client, `$encoding)
`$writer.AutoFlush = `$true
`$writer.WriteLine(`$json)
`$line = `$reader.ReadLine()
`$readTask = `$reader.ReadLineAsync()
if (-not `$readTask.Wait(15000)) {
throw 'Zeitüberschreitung beim Warten auf Antwort des Softwarekatalog-Dienstes.'
}
`$line = `$readTask.Result
if ([string]::IsNullOrWhiteSpace(`$line)) {
throw 'Der Softwarekatalog-Dienst hat keine Antwort gesendet.'
}
@@ -1163,6 +1194,45 @@ function Get-AppStatus {
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'
@@ -1462,6 +1532,331 @@ function Load-AppList {
`$stateRefreshTimer = New-Object System.Windows.Forms.Timer
`$stateRefreshTimer.Interval = 500
`$script:StateRefreshPending = `$false
`$script:RequestWorkerBusy = `$false
`$script:RunspaceJobs = @()
`$script:CatalogClosing = `$false
function Remove-CatalogRunspaceJob {
param(
[Parameter(Mandatory=`$true)]`$Job,
[switch]`$Stop
)
try {
if (`$null -ne `$Job.Timer) {
`$Job.Timer.Stop()
`$Job.Timer.Dispose()
}
}
catch {
}
try {
if (`$Stop -and `$null -ne `$Job.PowerShell -and `$null -ne `$Job.AsyncResult -and -not `$Job.AsyncResult.IsCompleted) {
`$null = `$Job.PowerShell.BeginStop(`$null, `$null)
}
}
catch {
try { `$Job.PowerShell.Stop() } catch { }
}
try {
if (`$null -ne `$Job.PowerShell) { `$Job.PowerShell.Dispose() }
}
catch {
}
try {
if (`$null -ne `$Job.Runspace) { `$Job.Runspace.Dispose() }
}
catch {
}
`$remainingJobs = @()
foreach (`$existingJob in @(`$script:RunspaceJobs)) {
if (-not [object]::ReferenceEquals(`$existingJob, `$Job)) {
`$remainingJobs += `$existingJob
}
}
`$script:RunspaceJobs = `$remainingJobs
}
function Start-CatalogDaemonRunspaceRequest {
param(
[Parameter(Mandatory=`$true)][string]`$Command,
[string]`$Alias,
`$Context,
[Parameter(Mandatory=`$true)][scriptblock]`$OnCompleted
)
`$runspace = [runspacefactory]::CreateRunspace()
`$powershell = [powershell]::Create()
`$timer = New-Object System.Windows.Forms.Timer
`$timer.Interval = 100
`$job = [pscustomobject]@{
PowerShell = `$powershell
Runspace = `$runspace
AsyncResult = `$null
Timer = `$timer
Context = `$Context
OnCompleted = `$OnCompleted
}
try {
`$runspace.ApartmentState = 'MTA'
`$runspace.ThreadOptions = 'ReuseThread'
`$runspace.Open()
`$runspace.SessionStateProxy.SetVariable('PipeName', `$PipeName)
`$runspace.SessionStateProxy.SetVariable('Command', `$Command)
`$runspace.SessionStateProxy.SetVariable('Alias', `$Alias)
`$powershell.Runspace = `$runspace
[void]`$powershell.AddScript({
Set-StrictMode -Version Latest
`$ErrorActionPreference = 'Stop'
`$payload = [ordered]@{
Command = `$Command
AppAlias = `$Alias
}
`$json = `$payload | ConvertTo-Json -Compress
`$encoding = New-Object System.Text.UTF8Encoding(`$false)
`$client = New-Object System.IO.Pipes.NamedPipeClientStream -ArgumentList @(
'.',
`$PipeName,
[System.IO.Pipes.PipeDirection]::InOut,
[System.IO.Pipes.PipeOptions]::None,
[System.Security.Principal.TokenImpersonationLevel]::Impersonation
)
try {
`$client.Connect(10000)
`$reader = New-Object System.IO.StreamReader(`$client, `$encoding)
`$writer = New-Object System.IO.StreamWriter(`$client, `$encoding)
`$writer.AutoFlush = `$true
`$writer.WriteLine(`$json)
`$readTask = `$reader.ReadLineAsync()
if (-not `$readTask.Wait(15000)) {
throw 'Zeitüberschreitung beim Warten auf Antwort des Softwarekatalog-Dienstes.'
}
`$line = `$readTask.Result
if ([string]::IsNullOrWhiteSpace(`$line)) {
throw 'Der Softwarekatalog-Dienst hat keine Antwort gesendet.'
}
`$response = `$line | ConvertFrom-Json
if (-not `$response.Success) {
throw ([string]`$response.Message)
}
return `$response
}
catch [System.TimeoutException] {
throw 'Der Softwarekatalog-Dienst ist nicht erreichbar oder startet noch. Bitte versuchen Sie es erneut.'
}
finally {
`$client.Dispose()
}
}.ToString())
`$job.AsyncResult = `$powershell.BeginInvoke()
}
catch {
`$timer.Dispose()
`$powershell.Dispose()
`$runspace.Dispose()
throw
}
`$script:RunspaceJobs = @(`$script:RunspaceJobs + `$job)
`$jobRef = `$job
`$timer.Add_Tick({
if (`$null -eq `$jobRef.AsyncResult -or -not `$jobRef.AsyncResult.IsCompleted) { return }
`$jobRef.Timer.Stop()
`$completion = [pscustomobject]@{
Context = `$jobRef.Context
Response = `$null
ErrorMessage = `$null
}
try {
`$result = @(`$jobRef.PowerShell.EndInvoke(`$jobRef.AsyncResult))
if (`$jobRef.PowerShell.Streams.Error.Count -gt 0) {
`$firstError = `$jobRef.PowerShell.Streams.Error[0]
if (`$null -ne `$firstError.Exception) {
`$completion.ErrorMessage = `$firstError.Exception.Message
}
else {
`$completion.ErrorMessage = [string]`$firstError
}
}
elseif (`$result.Count -gt 0) {
`$completion.Response = `$result[-1]
}
}
catch {
`$exception = `$_.Exception
while (`$exception.InnerException) {
`$exception = `$exception.InnerException
}
`$completion.ErrorMessage = `$exception.Message
}
try {
if (-not `$script:CatalogClosing) {
& `$jobRef.OnCompleted `$completion
}
}
catch {
try { `$status.Text = 'Fehler: ' + `$_.Exception.Message } catch { }
}
finally {
Remove-CatalogRunspaceJob -Job `$jobRef
}
}.GetNewClosure())
`$timer.Start()
}
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
`$context = [pscustomobject]@{
Action = `$Action
Alias = [string]`$Selected.Alias
DisplayName = [string]`$Selected.DisplayName
}
try {
Start-CatalogDaemonRunspaceRequest -Command `$Action -Alias `$context.Alias -Context `$context -OnCompleted ({
param(`$completion)
try {
`$script:RequestWorkerBusy = `$false
`$btnRefresh.Enabled = `$true
if (`$completion.Context.Action -eq 'Uninstall') {
`$verb = 'Deinstallation'
}
else {
`$verb = 'Installation'
}
if (`$completion.ErrorMessage) {
`$requestReachedQueue = (`$completion.ErrorMessage -like '*Zeitüberschreitung beim Warten auf Antwort*' -and (Test-RequestQueuedInState -Alias `$completion.Context.Alias -Action `$completion.Context.Action))
if (`$requestReachedQueue) {
`$status.Text = "`$verb eingereiht: {0}" -f `$completion.Context.DisplayName
`$script:StateRefreshPending = `$true
return
}
`$status.Text = 'Fehler: ' + `$completion.ErrorMessage
`$script:StateRefreshPending = `$true
[System.Windows.Forms.MessageBox]::Show(
`$completion.ErrorMessage,
`$CatalogTitle,
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Error
) | Out-Null
return
}
`$result = [pscustomobject]@{
Action = `$completion.Context.Action
Alias = `$completion.Context.Alias
DisplayName = `$completion.Context.DisplayName
Response = `$completion.Response
}
if (`$result.Response.Message) {
`$status.Text = [string]`$result.Response.Message
}
else {
`$status.Text = "`$verb eingereiht: {0}" -f `$result.DisplayName
}
[System.Windows.Forms.MessageBox]::Show(
("`$verb eingereiht:`r`n`r`n{0}`r`nAlias: {1}" -f `$result.DisplayName, `$result.Alias),
`$CatalogTitle,
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Information
) | Out-Null
`$script:StateRefreshPending = `$true
}
finally {
Update-InstallButtonState
}
}.GetNewClosure())
}
catch {
`$script:RequestWorkerBusy = `$false
`$btnRefresh.Enabled = `$true
Update-InstallButtonState
throw
}
}
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 {
Start-CatalogDaemonRunspaceRequest -Command 'RefreshStatus' -OnCompleted ({
param(`$completion)
try {
`$script:RequestWorkerBusy = `$false
`$btnRefresh.Enabled = `$true
if (`$completion.ErrorMessage) {
`$status.Text = 'Fehler: ' + `$completion.ErrorMessage
[System.Windows.Forms.MessageBox]::Show(
`$completion.ErrorMessage,
`$CatalogTitle,
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Error
) | Out-Null
return
}
`$response = `$completion.Response
if (`$response.Message) { `$status.Text = [string]`$response.Message }
`$script:StateRefreshPending = `$true
}
finally {
Update-InstallButtonState
}
}.GetNewClosure())
}
catch {
`$script:RequestWorkerBusy = `$false
`$btnRefresh.Enabled = `$true
Update-InstallButtonState
throw
}
}
`$btnInstall.Add_Click({
try {
@@ -1490,17 +1885,10 @@ function Load-AppList {
}
`$status.Text = "Installation wird eingereiht: {0}" -f `$selected.DisplayName
`$response = New-AppRequest -Alias `$selected.Alias -Action 'Install'
`$status.Text = if (`$response.Message) { `$response.Message } else { "Installation eingereiht: {0}" -f `$selected.DisplayName }
[System.Windows.Forms.MessageBox]::Show(
("Installation eingereiht:`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
Load-AppList
`$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
@@ -1540,17 +1928,10 @@ function Load-AppList {
}
`$status.Text = "Deinstallation wird eingereiht: {0}" -f `$selected.DisplayName
`$response = New-AppRequest -Alias `$selected.Alias -Action 'Uninstall'
`$status.Text = if (`$response.Message) { `$response.Message } else { "Deinstallation eingereiht: {0}" -f `$selected.DisplayName }
[System.Windows.Forms.MessageBox]::Show(
("Deinstallation eingereiht:`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
Load-AppList
`$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
@@ -1565,10 +1946,7 @@ function Load-AppList {
`$btnRefresh.Add_Click({
try {
`$status.Text = 'Installationsdaten werden aktualisiert...'
`$response = Invoke-CatalogDaemon -Command 'RefreshStatus'
Load-AppList
if (`$response.Message) { `$status.Text = `$response.Message }
Start-RefreshStatusAsync
}
catch {
`$status.Text = "Fehler: " + `$_.Exception.Message
@@ -1583,6 +1961,7 @@ function Load-AppList {
`$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 }
@@ -1653,10 +2032,15 @@ function Load-AppList {
`$stateRefreshTimer.Start()
})
`$form.Add_FormClosing({
`$script:CatalogClosing = `$true
`$stateRefreshTimer.Stop()
`$stateRefreshTimer.Dispose()
if (`$script:StateWatcher) { `$script:StateWatcher.Dispose() }
if (`$script:SnapshotWatcher) { `$script:SnapshotWatcher.Dispose() }
if (`$null -ne `$script:StateWatcher) { `$script:StateWatcher.Dispose() }
if (`$null -ne `$script:SnapshotWatcher) { `$script:SnapshotWatcher.Dispose() }
foreach (`$job in @(`$script:RunspaceJobs)) {
Remove-CatalogRunspaceJob -Job `$job -Stop
}
`$script:RunspaceJobs = @()
})
`$list.Add_SelectedIndexChanged({ Update-InstallButtonState })
@@ -2212,7 +2596,7 @@ function Invoke-CatalogDaemonCommand {
{ $_ -in @('getcatalog','getstate') } {
$requiredQueued = Ensure-RequiredSoftwareQueued
if ($requiredQueued -gt 0) {
Start-QueueWorker
Add-DaemonPostResponseWorker -WorkerMode ProcessQueue
}
$catalog = @(Get-AppCatalogObjects | Sort-Object DisplayName)
$requiredAliases = @(Get-RequiredAliases)
@@ -2239,7 +2623,7 @@ function Invoke-CatalogDaemonCommand {
'applypolicy' {
Write-Log "Authenticated policy apply requested by user='$($ClientIdentity.Name)' sid='$($ClientIdentity.Sid)'"
Start-SoftwarekatalogWorker -WorkerMode ApplyPolicy
Add-DaemonPostResponseWorker -WorkerMode ApplyPolicy
return [pscustomobject]@{
Success = $true
Command = $command
@@ -2278,10 +2662,20 @@ function Invoke-CatalogDaemonCommand {
Write-Log "Authenticated request: action=$actionText alias='$alias' package='$packageId' user='$($ClientIdentity.Name)' sid='$($ClientIdentity.Sid)'"
$queueResult = Add-SoftwareRequestToQueue -Action $command -Alias $alias -RequestedBy $ClientIdentity.Name -RequestedBySid $ClientIdentity.Sid -Source User
Start-QueueWorker
Add-DaemonPostResponseWorker -WorkerMode ProcessQueue
$verb = if ($command -eq 'uninstall') { 'Deinstallation' } else { 'Installation' }
$message = if ($queueResult.Added) { "$verb eingereiht: $alias" } else { "$verb ist bereits in der Warteschlange: $alias" }
if ($command -eq 'uninstall') {
$verb = 'Deinstallation'
}
else {
$verb = 'Installation'
}
if ($queueResult.Added) {
$message = "$verb eingereiht: $alias"
}
else {
$message = "$verb ist bereits in der Warteschlange: $alias"
}
return [pscustomobject]@{
Success = $true
Command = $command
@@ -2297,24 +2691,25 @@ function Invoke-CatalogDaemonCommand {
{ $_ -in @('refreshstatus','getstatus') } {
Write-Log "Authenticated status refresh requested by user='$($ClientIdentity.Name)' sid='$($ClientIdentity.Sid)'"
$wingetPath = Ensure-WingetAvailable
if (-not $wingetPath) {
throw 'winget is unavailable.'
$started = $false
if (Test-WingetLockAvailable) {
Add-DaemonPostResponseWorker -WorkerMode ProcessQueue
$started = $true
}
$result = Invoke-WithWingetLock -TimeoutSeconds 2 -ScriptBlock {
$snapshotUpdated = Update-InstalledPackagesSnapshot -WingetPath $wingetPath
[pscustomobject]@{ SnapshotUpdated = [bool]$snapshotUpdated }
if ($started) {
$message = 'Installationsdaten-Aktualisierung gestartet.'
}
else {
$message = 'Installationsdaten-Aktualisierung läuft bereits.'
}
$result = @($result)[-1]
return [pscustomobject]@{
Success = [bool]$result.SnapshotUpdated
Success = $true
Command = $command
RequestedBy = $ClientIdentity.Name
RequestedBySid = $ClientIdentity.Sid
SnapshotUpdated = [bool]$result.SnapshotUpdated
Message = 'Installationsdaten aktualisiert.'
SnapshotQueued = $started
Message = $message
}
}
@@ -2340,6 +2735,7 @@ function Start-CatalogDaemon {
while ($true) {
$pipe = $null
try {
$script:DaemonPostResponseWorkers = @()
$pipe = New-CatalogPipeServer
$pipe.WaitForConnection()
@@ -2351,7 +2747,11 @@ function Start-CatalogDaemon {
$response = $null
try {
$line = $reader.ReadLine()
$readTask = $reader.ReadLineAsync()
if (-not $readTask.Wait(30000)) {
throw 'Timed out waiting for named pipe request.'
}
$line = $readTask.Result
if ([string]::IsNullOrWhiteSpace($line)) {
throw 'Empty request.'
}
@@ -2370,6 +2770,7 @@ function Start-CatalogDaemon {
}
$writer.WriteLine(($response | ConvertTo-Json -Compress -Depth 6))
Invoke-DaemonPostResponseWorkers
}
catch {
Write-Log "Named pipe daemon error: $($_.Exception.Message)" 'ERROR'