# KDS Install Agent - Kernfunktionen # Wird vom Server (Agent.ps1) und von jedem Hintergrund-Job dot-sourced. [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $ProgressPreference = 'SilentlyContinue' #region Allgemein ---------------------------------------------------------------- function Write-Log { param([string]$Message, [ValidateSet('INFO', 'OK', 'WARN', 'ERROR')][string]$Level = 'INFO') $line = '{0} [{1}] {2}' -f (Get-Date -Format 'HH:mm:ss'), $Level, $Message if ($global:KdsLogFile) { try { Add-Content -Path $global:KdsLogFile -Value $line -Encoding UTF8 } catch { } } Write-Host $line } function Get-KdsDataDir { # Schreibbares Datenverzeichnis: bevorzugt auf dem Stick, sonst ProgramData param([string]$Name) $dir = Join-Path $global:KdsRoot $Name try { New-Item -ItemType Directory -Path $dir -Force -ErrorAction Stop | Out-Null $probe = Join-Path $dir '.write-test' Set-Content -Path $probe -Value 1 -ErrorAction Stop Remove-Item $probe -Force return $dir } catch { $dir = Join-Path $env:ProgramData "KDS-Agent\$Name" New-Item -ItemType Directory -Path $dir -Force | Out-Null return $dir } } function Set-RegValue { param([string]$Path, [string]$Name, $Value, [string]$Type = 'DWord') if (-not (Test-Path $Path)) { New-Item -Path $Path -Force | Out-Null } New-ItemProperty -Path $Path -Name $Name -Value $Value -PropertyType $Type -Force | Out-Null } function Test-True($v) { return ($v -eq $true -or $v -eq 'true' -or $v -eq 1) } function Get-SystemInfo { $os = Get-CimInstance Win32_OperatingSystem $cs = Get-CimInstance Win32_ComputerSystem $bios = Get-CimInstance Win32_BIOS $cpu = Get-CimInstance Win32_Processor | Select-Object -First 1 $disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='$env:SystemDrive'" $tpm = $null try { $t = Get-Tpm -ErrorAction Stop; $tpm = @{ present = $t.TpmPresent; ready = $t.TpmReady } } catch { } $bat = Get-CimInstance Win32_Battery -ErrorAction SilentlyContinue | Select-Object -First 1 [pscustomobject]@{ computerName = $env:COMPUTERNAME os = $os.Caption build = "$($os.Version) ($((Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').DisplayVersion))" manufacturer = $cs.Manufacturer model = $cs.Model serial = $bios.SerialNumber cpu = $cpu.Name.Trim() ramGB = [math]::Round($cs.TotalPhysicalMemory / 1GB, 1) diskFreeGB = [math]::Round($disk.FreeSpace / 1GB, 1) diskSizeGB = [math]::Round($disk.Size / 1GB, 1) domain = $cs.Domain partOfDomain = $cs.PartOfDomain tpm = $tpm battery = if ($bat) { "$($bat.EstimatedChargeRemaining) %" } else { $null } winget = [bool](Get-WingetPath) user = "$env:USERDOMAIN\$env:USERNAME" root = $global:KdsRoot } } #endregion #region winget ------------------------------------------------------------------ function Get-WingetPath { $c = Get-Command winget.exe -ErrorAction SilentlyContinue if ($c) { return $c.Source } $p = Get-ChildItem "$env:ProgramFiles\WindowsApps\Microsoft.DesktopAppInstaller_*_x64__8wekyb3d8bbwe\winget.exe" -ErrorAction SilentlyContinue | Sort-Object FullName -Descending | Select-Object -First 1 if ($p) { return $p.FullName } return $null } function Install-Winget { if (Get-WingetPath) { return $true } Write-Log 'winget nicht gefunden - starte Installation ...' 'WARN' # 1) Offline-Pakete vom Stick (packages\winget\*.appx / *.msixbundle) $offline = Join-Path $global:KdsRoot 'packages\winget' if (Test-Path $offline) { Get-ChildItem $offline -Filter *.appx | ForEach-Object { try { Add-AppxPackage -Path $_.FullName -ErrorAction Stop; Write-Log " Abhaengigkeit installiert: $($_.Name)" } catch { } } Get-ChildItem $offline -Filter *.msixbundle | ForEach-Object { try { Add-AppxPackage -Path $_.FullName -ErrorAction Stop; Write-Log " winget installiert (offline): $($_.Name)" 'OK' } catch { Write-Log " $_" 'WARN' } } if (Get-WingetPath) { return $true } } # 2) Offizieller Weg: Microsoft.WinGet.Client / Repair-WinGetPackageManager try { Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope AllUsers | Out-Null Set-PSRepository -Name PSGallery -InstallationPolicy Trusted Install-Module Microsoft.WinGet.Client -Force -Scope AllUsers -AllowClobber Import-Module Microsoft.WinGet.Client Repair-WinGetPackageManager -AllUsers -Latest Write-Log 'winget ueber Repair-WinGetPackageManager installiert' 'OK' } catch { Write-Log "Repair-WinGetPackageManager fehlgeschlagen: $_" 'WARN' # 3) Fallback: direkter Download try { $tmp = Join-Path $env:TEMP 'kds-winget'; New-Item $tmp -ItemType Directory -Force | Out-Null Invoke-WebRequest 'https://aka.ms/Microsoft.VCLibs.x64.14.00.Desktop.appx' -OutFile "$tmp\vclibs.appx" -UseBasicParsing try { Add-AppxPackage "$tmp\vclibs.appx" } catch { } Invoke-WebRequest 'https://aka.ms/getwinget' -OutFile "$tmp\winget.msixbundle" -UseBasicParsing Add-AppxPackage "$tmp\winget.msixbundle" } catch { Write-Log "winget-Fallback fehlgeschlagen: $_" 'ERROR' } } return [bool](Get-WingetPath) } function Invoke-Winget { # Fuehrt winget aus, schreibt bereinigte Ausgabe ins Log, gibt ExitCode zurueck param([string[]]$Arguments) $wg = Get-WingetPath [Console]::OutputEncoding = [Text.Encoding]::UTF8 & $wg @Arguments 2>&1 | ForEach-Object { $l = ("$_" -split "`r")[-1].TrimEnd() if ($l -and $l -notmatch '^\s*[-\\|/]\s*$' -and $l -notmatch '[\u2588\u2592]') { Write-Log " $l" } } return $LASTEXITCODE } function Search-Winget { param([string]$Query, [string]$Source = 'winget') $wg = Get-WingetPath if (-not $wg) { throw 'winget ist nicht installiert (Tab Updates -> winget installieren).' } if (Get-Module -ListAvailable Microsoft.WinGet.Client) { try { Import-Module Microsoft.WinGet.Client -ErrorAction Stop return @(Find-WinGetPackage -Query $Query -Source $Source -ErrorAction Stop | Select-Object -First 50 | ForEach-Object { [pscustomobject]@{ name = $_.Name; id = $_.Id; version = $_.Version; source = $Source } }) } catch { } } [Console]::OutputEncoding = [Text.Encoding]::UTF8 $out = & $wg search --query $Query --source $Source --accept-source-agreements --disable-interactivity 2>$null $lines = @($out | ForEach-Object { ("$_" -split "`r")[-1] }) $sep = -1 for ($i = 0; $i -lt $lines.Count; $i++) { if ($lines[$i] -match '^-{5,}') { $sep = $i; break } } if ($sep -lt 1) { return @() } # Spaltenpositionen aus der (lokalisierten) Kopfzeile ermitteln $starts = @([regex]::Matches($lines[$sep - 1], '\S+') | ForEach-Object { $_.Index }) if ($starts.Count -lt 3) { return @() } $res = foreach ($l in $lines[($sep + 1)..($lines.Count - 1)]) { if ($l.Length -lt $starts[2]) { continue } $verEnd = if ($starts.Count -gt 3) { [math]::Min($starts[3], $l.Length) } else { $l.Length } [pscustomobject]@{ name = $l.Substring(0, $starts[1]).Trim() id = $l.Substring($starts[1], $starts[2] - $starts[1]).Trim() version = $l.Substring($starts[2], $verEnd - $starts[2]).Trim() source = $Source } } return @($res | Where-Object { $_.id } | Select-Object -First 50) } #endregion #region Programme --------------------------------------------------------------- function Install-Apps { param($Data) $apps = @($Data.apps) if (-not $apps.Count) { Write-Log 'Keine Programme ausgewaehlt.' 'WARN'; return } $needWinget = @($apps | Where-Object { $_.source -ne 'local' }).Count -gt 0 if ($needWinget -and -not (Install-Winget)) { throw 'winget konnte nicht bereitgestellt werden.' } if ($needWinget) { Invoke-Winget @('source', 'update') | Out-Null } $ok = 0; $fail = @() $n = 0 foreach ($a in $apps) { $n++ Write-Log "[$n/$($apps.Count)] $($a.name)" try { if ($a.source -eq 'local') { $file = $a.path if (-not [IO.Path]::IsPathRooted($file)) { $file = Join-Path $global:KdsRoot $file } if (-not (Test-Path $file)) { throw "Installer nicht gefunden: $file" } $ext = [IO.Path]::GetExtension($file).ToLower() $sp = @{ Wait = $true; PassThru = $true; WorkingDirectory = (Split-Path $file) } switch ($ext) { '.msi' { $sp.FilePath = 'msiexec.exe'; $sp.ArgumentList = "/i `"$file`" /qn /norestart $($a.args)" } '.ps1' { $sp.FilePath = 'powershell.exe'; $sp.ArgumentList = "-NoProfile -ExecutionPolicy Bypass -File `"$file`" $($a.args)" } { $_ -in '.cmd', '.bat' } { $sp.FilePath = 'cmd.exe'; $sp.ArgumentList = "/c `"`"$file`" $($a.args)`"" } { $_ -in '.msix', '.msixbundle', '.appx', '.appxbundle' } { $sp = $null } default { $sp.FilePath = $file; if ($a.args) { $sp.ArgumentList = $a.args } } } if ($sp) { $code = (Start-Process @sp).ExitCode } else { # Paket fuer alle (auch kuenftige) Benutzer bereitstellen Add-AppxProvisionedPackage -Online -PackagePath $file -SkipLicense | Out-Null $code = 0 } } else { $src = if ($a.source) { $a.source } else { 'winget' } $base = @('install', '--id', $a.id, '--exact', '--source', $src, '--silent', '--accept-package-agreements', '--accept-source-agreements', '--disable-interactivity') if ($a.override) { $base += @('--override', $a.override) } $code = Invoke-Winget ($base + @('--scope', 'machine')) # Kein systemweiter Installer verfuegbar -> ohne Scope erneut if ($code -eq -1978335216 -or $code -eq -1978335212) { Write-Log ' Kein Maschinen-Installer, versuche Standard-Scope ...' $code = Invoke-Winget $base } } # 0 = OK, 3010/1641 = Neustart noetig, -1978335189 = bereits aktuell/installiert if ($code -in 0, 3010, 1641, -1978335189, -1978335135) { Write-Log " -> OK ($code)" 'OK'; $ok++ } else { throw "ExitCode $code (0x{0:X8})" -f $code } } catch { Write-Log " -> Fehler: $_" 'ERROR'; $fail += $a.name } } Write-Log "Programme: $ok erfolgreich, $($fail.Count) fehlgeschlagen $(if($fail){'(' + ($fail -join ', ') + ')'})" $(if ($fail) { 'WARN' } else { 'OK' }) } function Update-AllApps { param($Data) if (-not (Install-Winget)) { throw 'winget nicht verfuegbar.' } Write-Log 'Aktualisiere alle Programme ueber winget ...' $code = Invoke-Winget @('upgrade', '--all', '--silent', '--include-unknown', '--accept-package-agreements', '--accept-source-agreements', '--disable-interactivity') Write-Log "winget upgrade beendet (ExitCode $code)" 'OK' } #endregion #region Office ------------------------------------------------------------------ function New-OfficeXml { param($Cfg) $e = { param($s) [Security.SecurityElement]::Escape([string]$s) } $langs = @($Cfg.languages | Where-Object { $_ }) if (-not $langs.Count) { $langs = @('MatchOS') } $langXml = ($langs | ForEach-Object { " " }) -join "`r`n" $exclXml = (@($Cfg.exclude) | Where-Object { $_ } | ForEach-Object { " " }) -join "`r`n" $addAttr = "OfficeClientEdition=`"$($Cfg.arch)`" Channel=`"$(& $e $Cfg.channel)`"" if ($Cfg.sourcePath) { $addAttr += " SourcePath=`"$(& $e $Cfg.sourcePath)`"" } if (Test-True $Cfg.migrateArch) { $addAttr += ' MigrateArch="TRUE"' } $products = @() $pidAttr = if ($Cfg.pidKey) { " PIDKEY=`"$(& $e $Cfg.pidKey)`"" } else { '' } $products += " `r`n$langXml$(if($exclXml){"`r`n$exclXml"})`r`n " foreach ($extra in @($Cfg.extraProducts)) { if ($extra) { $products += " `r`n$langXml`r`n " } } $x = @() $x += "" $x += " " $x += $products $x += ' ' $x += " " $x += " " $x += " " $x += " " if ($Cfg.product -like '*Volume') { $x += " " } $x += " " if (Test-True $Cfg.removeMsi) { $x += ' ' } $x += ' ' if ($Cfg.company) { $x += " " } if (Test-True $Cfg.ooxmlDefault) { $x += ' ' } if (Test-True $Cfg.disableVba) { foreach ($app in 'word', 'excel', 'powerpoint') { $x += " " } } $x += ' ' $x += " " $x += '' return ($x -join "`r`n") } function Get-OdtSetup { $dir = Join-Path $global:KdsRoot 'office' New-Item $dir -ItemType Directory -Force | Out-Null $setup = Join-Path $dir 'setup.exe' if (-not (Test-Path $setup)) { Write-Log 'setup.exe (Office Deployment Tool) nicht vorhanden - lade von Microsoft CDN ...' Invoke-WebRequest 'https://officecdn.microsoft.com/pr/wsus/setup.exe' -OutFile $setup -UseBasicParsing Write-Log 'setup.exe heruntergeladen' 'OK' } return $setup } function Install-Office { param($Data) $cfg = $Data.office $mode = if ($Data.mode) { $Data.mode } else { 'online' } # online | download | offline $setup = Get-OdtSetup $cfgDir = Join-Path $global:KdsRoot 'office\configs' New-Item $cfgDir -ItemType Directory -Force | Out-Null if ($mode -in 'download', 'offline') { $src = Join-Path $global:KdsRoot "office\source\$($cfg.product)-$($cfg.arch)" New-Item $src -ItemType Directory -Force | Out-Null $cfg | Add-Member -NotePropertyName sourcePath -NotePropertyValue $src -Force } $xml = New-OfficeXml $cfg $name = if ($Data.name) { $Data.name -replace '[^\w\-]', '_' } else { "$($cfg.product)-$($cfg.arch)" } $xmlFile = Join-Path $cfgDir "$name.xml" Set-Content -Path $xmlFile -Value $xml -Encoding UTF8 Write-Log "Konfiguration gespeichert: $xmlFile" if ($mode -in 'download', 'offline') { $hasData = Test-Path (Join-Path $cfg.sourcePath 'Office\Data') if ($mode -eq 'download' -or -not $hasData) { Write-Log "Lade Office-Installationsdateien nach $($cfg.sourcePath) (kann dauern) ..." $p = Start-Process $setup -ArgumentList "/download `"$xmlFile`"" -Wait -PassThru -WindowStyle Hidden if ($p.ExitCode -ne 0) { throw "setup.exe /download ExitCode $($p.ExitCode)" } Write-Log 'Office-Build auf dem Stick abgeschlossen' 'OK' } if ($mode -eq 'download') { return } } Write-Log "Installiere $($cfg.product) ($($cfg.arch)-Bit, Kanal $($cfg.channel)) ..." $p = Start-Process $setup -ArgumentList "/configure `"$xmlFile`"" -Wait -PassThru -WindowStyle Hidden if ($p.ExitCode -ne 0) { throw "setup.exe /configure ExitCode $($p.ExitCode) - Logs unter $env:TEMP\$env:COMPUTERNAME-*.log" } Write-Log 'Office installiert' 'OK' } #endregion #region Desktop-Verknuepfungen --------------------------------------------------- function Get-PublicDesktop { [Environment]::GetFolderPath('CommonDesktopDirectory') } function Get-PublicShortcuts { $d = Get-PublicDesktop $sh = New-Object -ComObject WScript.Shell @(Get-ChildItem $d -File | Where-Object { $_.Extension -in '.lnk', '.url', '.rdp' } | ForEach-Object { $target = '' if ($_.Extension -eq '.lnk') { $target = $sh.CreateShortcut($_.FullName).TargetPath } else { $pat = if ($_.Extension -eq '.rdp') { '^full address:s:(.*)$' } else { '^URL=(.*)$' } $m = Select-String -Path $_.FullName -Pattern $pat | Select-Object -First 1 if ($m) { $target = [string]$m.Matches[0].Groups[1].Value } } [pscustomobject]@{ file = $_.Name; name = $_.BaseName; type = $_.Extension.TrimStart('.'); target = $target } }) } function Resolve-IconPath { param([string]$Icon) if (-not $Icon) { return $null } $src = $Icon if (-not [IO.Path]::IsPathRooted($src)) { $src = Join-Path $global:KdsRoot "icons\$Icon" } if (-not (Test-Path $src)) { return $Icon } # z.B. "C:\Programme\x.exe,0" # Icons vom Stick lokal ablegen, damit sie nach Abziehen bestehen bleiben $dst = Join-Path $env:ProgramData 'KDS-Agent\Icons' New-Item $dst -ItemType Directory -Force | Out-Null Copy-Item $src $dst -Force return (Join-Path $dst (Split-Path $src -Leaf)) } function New-PublicShortcut { param($S) $d = Get-PublicDesktop $safe = ($S.name -replace '[\\/:*?"<>|]', '_').Trim() if (-not $safe) { throw 'Name fehlt' } $icon = Resolve-IconPath $S.icon if ($S.type -eq 'rdp') { $file = Join-Path $d "$safe.rdp" if ($S.rdpFile) { # hochgeladene .rdp-Datei vom Stick verteilen $src = Join-Path $global:KdsRoot "rdp\$(Split-Path $S.rdpFile -Leaf)" if (-not (Test-Path $src)) { throw "RDP-Datei nicht gefunden: $src" } Copy-Item $src $file -Force } else { Set-Content -Path $file -Value (New-RdpContent $S.rdp) -Encoding Unicode } } elseif ($S.type -eq 'url') { $file = Join-Path $d "$safe.url" $c = "[InternetShortcut]`r`nURL=$($S.target)`r`n" if ($icon) { $c += "IconFile=$icon`r`nIconIndex=0`r`n" } Set-Content -Path $file -Value $c -Encoding ASCII } else { $file = Join-Path $d "$safe.lnk" $sh = New-Object -ComObject WScript.Shell $lnk = $sh.CreateShortcut($file) $lnk.TargetPath = [Environment]::ExpandEnvironmentVariables($S.target) if ($S.args) { $lnk.Arguments = $S.args } if ($S.workDir) { $lnk.WorkingDirectory = $S.workDir } elseif (Test-Path $lnk.TargetPath -PathType Leaf) { $lnk.WorkingDirectory = Split-Path $lnk.TargetPath } if ($icon) { $icon = [Environment]::ExpandEnvironmentVariables($icon) $lnk.IconLocation = if ($icon -match ',-?\d+$') { $icon } else { "$icon,0" } } $lnk.Save() } Write-Log "Verknuepfung erstellt: $file" 'OK' return $file } function Remove-PublicShortcut { param([string]$File) $path = Join-Path (Get-PublicDesktop) (Split-Path $File -Leaf) if (Test-Path $path) { Remove-Item $path -Force } } function Set-DesktopShortcuts { param($Data) foreach ($s in @($Data.shortcuts)) { try { New-PublicShortcut $s | Out-Null } catch { Write-Log "Verknuepfung '$($s.name)': $_" 'ERROR' } } } function New-RdpContent { # Erzeugt den Inhalt einer .rdp-Datei aus den Formularwerten param($R) if (-not $R.host) { throw 'RDP: Server/Host fehlt' } $b = { param($v) if (Test-True $v) { 1 } else { 0 } } $addr = $R.host if ($R.port -and "$($R.port)" -ne '3389') { $addr += ":$($R.port)" } $l = @( "full address:s:$addr" "screen mode id:i:$(if (Test-True $R.fullscreen) { 2 } else { 1 })" "use multimon:i:$(& $b $R.multimon)" 'dynamic resolution:i:1' 'smart sizing:i:1' "redirectclipboard:i:$(& $b $R.clipboard)" "redirectprinters:i:$(& $b $R.printers)" "redirectsmartcards:i:$(& $b $R.smartcards)" "drivestoredirect:s:$(if (Test-True $R.drives) { '*' } else { '' })" "audiomode:i:$(switch ($R.audio) { 'remote' { 1 } 'none' { 2 } default { 0 } })" "audiocapturemode:i:$(& $b $R.microphone)" "camerastoredirect:s:$(if (Test-True $R.camera) { '*' } else { '' })" 'authentication level:i:2' 'prompt for credentials:i:0' 'promptcredentialonce:i:1' 'enablecredsspsupport:i:1' 'autoreconnection enabled:i:1' ) if ($R.user) { $l += "username:s:$($R.user)" } if ($R.gateway) { $l += "gatewayhostname:s:$($R.gateway)", 'gatewayusagemethod:i:1', 'gatewaycredentialssource:i:4', 'gatewayprofileusagemethod:i:1', 'gatewaybrokeringtype:i:0' } else { $l += 'gatewayusagemethod:i:4' } if ($R.remoteApp) { $l += 'remoteapplicationmode:i:1', "remoteapplicationprogram:s:||$($R.remoteApp)", "alternate shell:s:||$($R.remoteApp)", "remoteapplicationname:s:$(if ($R.remoteAppName) { $R.remoteAppName } else { $R.remoteApp })", 'disableremoteappcapscheck:i:1' } if (Test-True $R.admin) { $l += 'administrative session:i:1' } if ($R.extra) { $l += @($R.extra -split "`r?`n" | Where-Object { $_ -match '^[^:]+:[isb]:' }) } return ($l -join "`r`n") + "`r`n" } #endregion #region Dateien / Uploads / Blaupausen -------------------------------------------- $script:UploadTypes = @{ installers = '.exe', '.msi', '.msix', '.msixbundle', '.appx', '.appxbundle', '.ps1', '.cmd', '.bat' icons = '.ico', '.exe', '.dll' rdp = '.rdp' } function Resolve-UploadTarget { param([string]$Kind, [string]$Name) if (-not $script:UploadTypes.ContainsKey("$Kind")) { throw "Unbekannter Upload-Typ: $Kind" } $n = ([IO.Path]::GetFileName("$Name") -replace '[^\w\-\.\(\)\+ ]', '_').Trim() $ext = [IO.Path]::GetExtension($n).ToLower() if (-not $n -or $ext -notin $script:UploadTypes[$Kind]) { throw "Dateityp '$ext' ist fuer $Kind nicht erlaubt ($($script:UploadTypes[$Kind] -join ', '))" } return @((Join-Path $global:KdsRoot $Kind), $n) } function Get-InstallerMetaFile { Join-Path $global:KdsRoot 'installers\installers.json' } function Get-InstallerMeta { $f = Get-InstallerMetaFile if (Test-Path $f) { return @([IO.File]::ReadAllText($f) | ConvertFrom-Json | ForEach-Object { $_ }) } return @() } function Set-InstallerMeta { # Name + Silent-Parameter je Installer merken (installers\installers.json) param($M) $list = @(Get-InstallerMeta | Where-Object { $_.file -ne $M.file }) if (-not $M.remove) { $list += [pscustomobject]@{ file = $M.file; name = $M.name; args = $M.args } } New-Item (Split-Path (Get-InstallerMetaFile)) -ItemType Directory -Force | Out-Null [IO.File]::WriteAllText((Get-InstallerMetaFile), (ConvertTo-Json -InputObject @($list) -Depth 3), (New-Object Text.UTF8Encoding($false))) } function Get-LocalInstallers { $dir = Join-Path $global:KdsRoot 'installers' $meta = Get-InstallerMeta @(Get-ChildItem $dir -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Extension.ToLower() -in $script:UploadTypes.installers } | ForEach-Object { $rel = $_.FullName.Substring($global:KdsRoot.Length + 1) $m = $meta | Where-Object file -eq $rel | Select-Object -First 1 [pscustomobject]@{ file = $rel name = if ($m.name) { [string]$m.name } else { $_.BaseName } args = if ($m) { [string]$m.args } else { '' } sizeMB = [math]::Round($_.Length / 1MB, 1) } }) } function Get-BlueprintPath { param([string]$Id) $safe = ($Id -replace '[^\w\-\. ]', '_').Trim(' .') if (-not $safe) { throw 'Ungueltiger Name' } return (Join-Path $global:KdsRoot "blueprints\$safe.json") } function Get-Blueprints { @(Get-ChildItem (Join-Path $global:KdsRoot 'blueprints') -Filter *.json -ErrorAction SilentlyContinue | ForEach-Object { $f = $_ try { $j = [IO.File]::ReadAllText($f.FullName) | ConvertFrom-Json [pscustomobject]@{ id = $f.BaseName name = if ($j.name) { [string]$j.name } else { $f.BaseName } customer = [string]$j.customer description = [string]$j.description apps = @($j.apps).Where({ $_ }).Count shortcuts = @($j.shortcuts).Where({ $_ }).Count office = [string]$j.office.product steps = @($j.steps) updated = $f.LastWriteTime.ToString('dd.MM.yyyy HH:mm') } } catch { Write-Log "Blaupause $($f.Name) fehlerhaft: $_" 'WARN' } }) } #endregion #region System / Sicherheit ----------------------------------------------------- function Set-SystemConfig { param($Data) $c = $Data.system if ($c.timeZone) { Set-TimeZone -Id $c.timeZone; Write-Log "Zeitzone: $($c.timeZone)" 'OK' } if (Test-True $c.showExtensions) { # Default-Profil + aktueller Benutzer Set-RegValue 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' 'HideFileExt' 0 reg load HKU\KdsDefault "$env:SystemDrive\Users\Default\NTUSER.DAT" | Out-Null reg add 'HKU\KdsDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' /v HideFileExt /t REG_DWORD /d 0 /f | Out-Null [gc]::Collect(); reg unload HKU\KdsDefault | Out-Null Write-Log 'Dateiendungen werden angezeigt (inkl. neue Benutzer)' 'OK' } if (Test-True $c.disableConsumer) { Set-RegValue 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\CloudContent' 'DisableWindowsConsumerFeatures' 1 Write-Log 'Consumer-Features / Werbe-Apps deaktiviert' 'OK' } if ($c.computerName -and $c.computerName -ne $env:COMPUTERNAME) { Rename-Computer -NewName $c.computerName -Force Write-Log "Computer wird nach Neustart umbenannt in '$($c.computerName)'" 'OK' } } function Set-SecurityConfig { param($Data) $c = $Data.security if (Test-True $c.defender) { try { Set-MpPreference -DisableRealtimeMonitoring $false -PUAProtection Enabled -MAPSReporting Advanced -SubmitSamplesConsent SendSafeSamples -ErrorAction Stop Write-Log 'Defender: Echtzeitschutz, PUA-Schutz, Cloud-Schutz aktiv' 'OK' Update-MpSignature -ErrorAction SilentlyContinue } catch { Write-Log "Defender: $_ (evtl. Drittanbieter-AV aktiv)" 'WARN' } } if (Test-True $c.firewall) { Set-NetFirewallProfile -Profile Domain, Private, Public -Enabled True Write-Log 'Firewall fuer alle Profile aktiviert' 'OK' } if (Test-True $c.smartScreen) { Set-RegValue 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System' 'EnableSmartScreen' 1 Set-RegValue 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System' 'ShellSmartScreenLevel' 'Warn' 'String' Write-Log 'SmartScreen aktiviert' 'OK' } if (Test-True $c.disableSmb1) { try { Disable-WindowsOptionalFeature -Online -FeatureName SMB1Protocol -NoRestart -ErrorAction Stop | Out-Null } catch { } Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force -ErrorAction SilentlyContinue Write-Log 'SMBv1 deaktiviert' 'OK' } if (Test-True $c.uac) { $p = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' Set-RegValue $p 'EnableLUA' 1; Set-RegValue $p 'ConsentPromptBehaviorAdmin' 2; Set-RegValue $p 'PromptOnSecureDesktop' 1 Write-Log 'UAC auf hoechste Stufe gesetzt' 'OK' } if (Test-True $c.disableAutorun) { $p = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer' Set-RegValue $p 'NoDriveTypeAutoRun' 255; Set-RegValue $p 'NoAutorun' 1 Write-Log 'Autorun/Autoplay deaktiviert' 'OK' } if (Test-True $c.lsaProtection) { Set-RegValue 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' 'RunAsPPL' 1 Write-Log 'LSA-Schutz aktiviert (Neustart noetig)' 'OK' } if (Test-True $c.telemetryMin) { Set-RegValue 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection' 'AllowTelemetry' 1 Write-Log 'Telemetrie auf "Erforderlich" reduziert' 'OK' } switch ($c.rdp) { 'enable' { Set-RegValue 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' 'fDenyTSConnections' 0 Set-RegValue 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' 'UserAuthentication' 1 Enable-NetFirewallRule -DisplayGroup '@FirewallAPI.dll,-28752' -ErrorAction SilentlyContinue Write-Log 'Remotedesktop aktiviert (mit NLA)' 'OK' } 'disable' { Set-RegValue 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' 'fDenyTSConnections' 1 Disable-NetFirewallRule -DisplayGroup '@FirewallAPI.dll,-28752' -ErrorAction SilentlyContinue Write-Log 'Remotedesktop deaktiviert' 'OK' } } if ($c.localAdminName -and $c.localAdminPassword) { $pw = ConvertTo-SecureString $c.localAdminPassword -AsPlainText -Force if (Get-LocalUser -Name $c.localAdminName -ErrorAction SilentlyContinue) { Set-LocalUser -Name $c.localAdminName -Password $pw } else { New-LocalUser -Name $c.localAdminName -Password $pw -PasswordNeverExpires -AccountNeverExpires | Out-Null } $grp = (Get-LocalGroup -SID 'S-1-5-32-544').Name Add-LocalGroupMember -Group $grp -Member $c.localAdminName -ErrorAction SilentlyContinue Write-Log "Lokaler Admin '$($c.localAdminName)' eingerichtet" 'OK' } } #endregion #region Energie ----------------------------------------------------------------- function Set-PowerConfig { param($Data) $c = $Data.power $plans = @{ balanced = '381b4222-f694-41f0-9685-ff5bb260df2e'; high = '8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c'; ultimate = 'e9a42b02-d5df-448d-aa00-03f14749eb61' } if ($c.plan -and $plans[$c.plan]) { $guid = $plans[$c.plan] powercfg /setactive $guid 2>$null if ($LASTEXITCODE -ne 0) { # Plan ausgeblendet (z.B. Modern Standby) -> duplizieren $o = powercfg /duplicatescheme $guid 2>$null if ($o -match '([0-9a-f\-]{36})') { powercfg /setactive $Matches[1] } } Write-Log "Energieplan: $($c.plan)" $(if ($LASTEXITCODE -eq 0) { 'OK' } else { 'WARN' }) } $map = @{ monitorAc = 'monitor-timeout-ac'; monitorDc = 'monitor-timeout-dc'; sleepAc = 'standby-timeout-ac'; sleepDc = 'standby-timeout-dc'; hibernateAc = 'hibernate-timeout-ac'; hibernateDc = 'hibernate-timeout-dc' } foreach ($k in $map.Keys) { $v = $c.$k if ($null -ne $v -and "$v" -ne '') { powercfg /change $map[$k] ([int]$v); Write-Log " $($map[$k]) = $v min" } } $actions = @{ nothing = 0; sleep = 1; hibernate = 2; shutdown = 3 } foreach ($pair in @(@('lidAc', '/setacvalueindex', 'LIDACTION'), @('lidDc', '/setdcvalueindex', 'LIDACTION'), @('buttonAc', '/setacvalueindex', 'PBUTTONACTION'), @('buttonDc', '/setdcvalueindex', 'PBUTTONACTION'))) { $v = $c.($pair[0]) if ($v -and $actions.ContainsKey($v)) { powercfg $pair[1] SCHEME_CURRENT SUB_BUTTONS $pair[2] $actions[$v] Write-Log " $($pair[2]) $($pair[0]) = $v" } } powercfg /setactive SCHEME_CURRENT if ($c.hibernate -in 'on', 'off') { powercfg /hibernate $c.hibernate; Write-Log "Ruhezustand: $($c.hibernate)" 'OK' } if ($c.fastStartup -in 'on', 'off') { Set-RegValue 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Power' 'HiberbootEnabled' $(if ($c.fastStartup -eq 'on') { 1 } else { 0 }) Write-Log "Schnellstart: $($c.fastStartup)" 'OK' } Write-Log 'Energieeinstellungen angewendet' 'OK' } #endregion #region BitLocker --------------------------------------------------------------- function Get-BitLockerInfo { $info = [ordered]@{ available = $false; edition = (Get-CimInstance Win32_OperatingSystem).Caption } try { $t = Get-Tpm -ErrorAction Stop; $info.tpmPresent = $t.TpmPresent; $info.tpmReady = $t.TpmReady } catch { $info.tpmPresent = $false } if (-not (Get-Command Get-BitLockerVolume -ErrorAction SilentlyContinue)) { $info.message = 'BitLocker-Cmdlets nicht verfuegbar (Windows Home?)' return [pscustomobject]$info } $info.available = $true $info.volumes = @(Get-BitLockerVolume | ForEach-Object { [pscustomobject]@{ mount = $_.MountPoint type = "$($_.VolumeType)" status = "$($_.VolumeStatus)" protection = "$($_.ProtectionStatus)" percent = $_.EncryptionPercentage method = "$($_.EncryptionMethod)" protectors = @($_.KeyProtector | ForEach-Object { "$($_.KeyProtectorType)" }) } }) return [pscustomobject]$info } function Save-RecoveryKey { param([string]$Mount) $vol = Get-BitLockerVolume -MountPoint $Mount $rp = @($vol.KeyProtector | Where-Object KeyProtectorType -eq 'RecoveryPassword') $dir = Get-KdsDataDir 'bitlocker-keys' $file = Join-Path $dir ("{0}_{1}_{2}.txt" -f $env:COMPUTERNAME, ($Mount.TrimEnd(':\')), (Get-Date -Format 'yyyyMMdd-HHmm')) $sn = (Get-CimInstance Win32_BIOS).SerialNumber $txt = @("BitLocker-Wiederherstellungsschluessel", "Computer: $env:COMPUTERNAME", "Seriennummer: $sn", "Laufwerk: $Mount", "Datum: $(Get-Date)", '') foreach ($k in $rp) { $txt += "ID: $($k.KeyProtectorId)"; $txt += "Schluessel: $($k.RecoveryPassword)"; $txt += '' } Set-Content -Path $file -Value $txt -Encoding UTF8 Write-Log "Wiederherstellungsschluessel gespeichert: $file" 'OK' return $rp } function Set-BitLockerConfig { param($Data) $c = $Data.bitlocker $mount = if ($c.mount) { $c.mount } else { $env:SystemDrive } if (-not (Get-Command Enable-BitLocker -ErrorAction SilentlyContinue)) { throw 'BitLocker ist auf dieser Edition nicht verfuegbar.' } if ($c.action -eq 'disable') { Disable-BitLocker -MountPoint $mount | Out-Null Write-Log "Entschluesselung von $mount gestartet" 'OK' return } if ($c.action -eq 'savekey') { Save-RecoveryKey $mount | Out-Null; return } if ($c.action -ne 'enable') { return } $tpm = Get-Tpm if (-not $tpm.TpmPresent -or -not $tpm.TpmReady) { throw 'TPM nicht vorhanden oder nicht bereit.' } if ($c.usePin) { $fve = 'HKLM:\SOFTWARE\Policies\Microsoft\FVE' Set-RegValue $fve 'UseAdvancedStartup' 1; Set-RegValue $fve 'EnableBDEWithNoTPM' 0 Set-RegValue $fve 'UseTPM' 2; Set-RegValue $fve 'UseTPMPIN' 2; Set-RegValue $fve 'UseTPMKey' 2; Set-RegValue $fve 'UseTPMKeyPIN' 2 if ($c.enhancedPin) { Set-RegValue $fve 'UseEnhancedPin' 1 } } $method = if ($c.method) { $c.method } else { 'XtsAes256' } $vol = Get-BitLockerVolume -MountPoint $mount if (-not ($vol.KeyProtector | Where-Object KeyProtectorType -eq 'RecoveryPassword')) { Add-BitLockerKeyProtector -MountPoint $mount -RecoveryPasswordProtector | Out-Null Write-Log 'Wiederherstellungskennwort-Schutz hinzugefuegt' } if ("$($vol.VolumeStatus)" -eq 'FullyDecrypted') { $p = @{ MountPoint = $mount; EncryptionMethod = $method; ErrorAction = 'Stop' } if (Test-True $c.usedSpaceOnly) { $p.UsedSpaceOnly = $true } if (Test-True $c.skipHardwareTest) { $p.SkipHardwareTest = $true } if ($c.usePin -and $c.pin) { $p.TpmAndPinProtector = $true; $p.Pin = (ConvertTo-SecureString $c.pin -AsPlainText -Force) } else { $p.TpmProtector = $true } Enable-BitLocker @p | Out-Null Write-Log "BitLocker auf $mount aktiviert ($method)" 'OK' } else { # z.B. Geraeteverschluesselung "Warten auf Aktivierung" Write-Log "Volume-Status: $($vol.VolumeStatus) - ergaenze Schutzvorrichtungen und aktiviere Schutz" $types = @($vol.KeyProtector | ForEach-Object { "$($_.KeyProtectorType)" }) if ($c.usePin -and $c.pin -and 'TpmPin' -notin $types) { Add-BitLockerKeyProtector -MountPoint $mount -TpmAndPinProtector -Pin (ConvertTo-SecureString $c.pin -AsPlainText -Force) | Out-Null } elseif (-not ($types | Where-Object { $_ -like 'Tpm*' })) { Add-BitLockerKeyProtector -MountPoint $mount -TpmProtector | Out-Null } Resume-BitLocker -MountPoint $mount | Out-Null Write-Log "Schutz auf $mount aktiv" 'OK' } $rp = Save-RecoveryKey $mount foreach ($k in $rp) { if (Test-True $c.backupAad) { try { BackupToAAD-BitLockerKeyProtector -MountPoint $mount -KeyProtectorId $k.KeyProtectorId -ErrorAction Stop | Out-Null; Write-Log 'Schluessel in Entra ID gesichert' 'OK' } catch { Write-Log "Entra-ID-Sicherung fehlgeschlagen: $_" 'WARN' } } if (Test-True $c.backupAd) { try { Backup-BitLockerKeyProtector -MountPoint $mount -KeyProtectorId $k.KeyProtectorId -ErrorAction Stop | Out-Null; Write-Log 'Schluessel im AD gesichert' 'OK' } catch { Write-Log "AD-Sicherung fehlgeschlagen: $_" 'WARN' } } } if (-not (Test-True $c.skipHardwareTest) -and "$($vol.VolumeStatus)" -eq 'FullyDecrypted') { Write-Log 'Hardwaretest aktiv: Verschluesselung startet nach dem naechsten Neustart.' 'WARN' } } #endregion #region Windows Update / Treiber ------------------------------------------------ $script:MUServiceId = '7971f918-a847-4430-9279-4a52d1efe18d' function New-UpdateSearcher { $session = New-Object -ComObject Microsoft.Update.Session $session.ClientApplicationID = 'KDS Install Agent' $searcher = $session.CreateUpdateSearcher() try { # Microsoft Update aktivieren (liefert Treiber + Office/andere MS-Produkte) $sm = New-Object -ComObject Microsoft.Update.ServiceManager $sm.AddService2($script:MUServiceId, 7, '') | Out-Null $searcher.ServerSelection = 3 $searcher.ServiceID = $script:MUServiceId } catch { Write-Log "Microsoft Update nicht registrierbar, nutze Windows Update: $_" 'WARN' } return @{ Session = $session; Searcher = $searcher } } function Find-WindowsUpdates { param($Data) $types = if ($Data.type -eq 'all' -or -not $Data.type) { @('Software', 'Driver') } else { @($Data.type) } $s = New-UpdateSearcher $list = @() foreach ($t in $types) { Write-Log "Suche nach $(if($t -eq 'Driver'){'Treiber'}else{'Windows'})-Updates ..." $r = $s.Searcher.Search("IsInstalled=0 and IsHidden=0 and Type='$t'") foreach ($u in $r.Updates) { $list += [pscustomobject]@{ id = $u.Identity.UpdateID title = $u.Title type = $t kb = (@($u.KBArticleIDs) -join ',') sizeMB = [math]::Round($u.MaxDownloadSize / 1MB, 1) class = if ($t -eq 'Driver') { "$($u.DriverClass)" } else { (@($u.Categories | Select-Object -First 1 | ForEach-Object Name) -join '') } } } Write-Log " $(@($r.Updates).Count) gefunden" } return , $list } function Install-WindowsUpdates { param($Data) $ids = @($Data.ids) $s = New-UpdateSearcher Write-Log 'Ermittle Updates ...' $r = $s.Searcher.Search("IsInstalled=0 and IsHidden=0") $coll = New-Object -ComObject Microsoft.Update.UpdateColl foreach ($u in $r.Updates) { if ($Data.all -or $u.Identity.UpdateID -in $ids) { if ($Data.type -and $Data.type -ne 'all' -and $u.Type -ne $(if ($Data.type -eq 'Driver') { 2 } else { 1 })) { continue } if (-not $u.EulaAccepted) { $u.AcceptEula() } $coll.Add($u) | Out-Null Write-Log " + $($u.Title)" } } if ($coll.Count -eq 0) { Write-Log 'Keine passenden Updates.' 'OK'; return } Write-Log "Lade $($coll.Count) Update(s) herunter ..." $dl = $s.Session.CreateUpdateDownloader(); $dl.Updates = $coll $dr = $dl.Download() Write-Log "Download-Ergebnis: $($dr.ResultCode) (2=OK)" $inst = New-Object -ComObject Microsoft.Update.UpdateColl foreach ($u in $coll) { if ($u.IsDownloaded) { $inst.Add($u) | Out-Null } } Write-Log "Installiere $($inst.Count) Update(s) ..." $installer = $s.Session.CreateUpdateInstaller(); $installer.Updates = $inst $ir = $installer.Install() for ($i = 0; $i -lt $inst.Count; $i++) { $rc = $ir.GetUpdateResult($i).ResultCode Write-Log (" {0}: {1}" -f $inst.Item($i).Title, @{2 = 'OK'; 3 = 'OK mit Fehlern'; 4 = 'Fehlgeschlagen'; 5 = 'Abgebrochen' }[[int]$rc]) $(if ($rc -eq 2) { 'OK' } else { 'WARN' }) } if ($ir.RebootRequired) { Write-Log 'NEUSTART ERFORDERLICH' 'WARN' } return [pscustomobject]@{ rebootRequired = $ir.RebootRequired; result = $ir.ResultCode } } function Invoke-VendorDriverUpdate { param($Data) $man = (Get-CimInstance Win32_ComputerSystem).Manufacturer Write-Log "Hersteller: $man" if (-not (Install-Winget)) { throw 'winget nicht verfuegbar.' } if ($man -match 'Dell') { Invoke-Winget @('install', '--id', 'Dell.CommandUpdate', '-e', '--silent', '--accept-package-agreements', '--accept-source-agreements', '--disable-interactivity') | Out-Null $cli = @("$env:ProgramFiles\Dell\CommandUpdate\dcu-cli.exe", "${env:ProgramFiles(x86)}\Dell\CommandUpdate\dcu-cli.exe") | Where-Object { Test-Path $_ } | Select-Object -First 1 if (-not $cli) { throw 'dcu-cli.exe nicht gefunden' } Write-Log 'Dell Command Update: suche und installiere Treiber/BIOS/Firmware ...' & $cli /applyUpdates -silent -reboot=disable -autoSuspendBitLocker=enable 2>&1 | ForEach-Object { Write-Log " $_" } Write-Log "dcu-cli ExitCode $LASTEXITCODE (0=OK, 1/5=Neustart noetig, 500=keine Updates)" 'OK' } elseif ($man -match 'Lenovo') { Invoke-Winget @('install', '--id', 'Lenovo.SystemUpdate', '-e', '--silent', '--accept-package-agreements', '--accept-source-agreements', '--disable-interactivity') | Out-Null $tvsu = "${env:ProgramFiles(x86)}\Lenovo\System Update\tvsu.exe" if (-not (Test-Path $tvsu)) { throw 'tvsu.exe nicht gefunden' } Write-Log 'Lenovo System Update: installiere Treiber ...' $p = Start-Process $tvsu -ArgumentList '/CM -search A -action INSTALL -includerebootpackages 1,3,4 -noicons -noreboot -nolicense' -Wait -PassThru Write-Log "tvsu ExitCode $($p.ExitCode)" 'OK' } elseif ($man -match 'HP|Hewlett') { Invoke-Winget @('install', '--id', 'HP.ImageAssistant', '-e', '--silent', '--accept-package-agreements', '--accept-source-agreements', '--disable-interactivity') | Out-Null $hpia = Get-ChildItem "$env:ProgramFiles\HP\HPIA", "${env:ProgramFiles(x86)}\HP\HPIA", 'C:\SWSetup' -Filter HPImageAssistant.exe -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $hpia) { throw 'HPImageAssistant.exe nicht gefunden' } $rep = Get-KdsDataDir 'logs\hpia' Write-Log 'HP Image Assistant: analysiere und installiere ...' $p = Start-Process $hpia.FullName -ArgumentList "/Operation:Analyze /Action:Install /Category:Drivers,Firmware /Selection:All /Silent /ReportFolder:`"$rep`" /SoftpaqDownloadFolder:`"$env:TEMP\hpia`"" -Wait -PassThru Write-Log "HPIA ExitCode $($p.ExitCode) (0/256=OK, 3010=Neustart)" 'OK' } else { Write-Log 'Kein Hersteller-Tool hinterlegt - bitte Treiber ueber Windows Update installieren.' 'WARN' } } #endregion #region Bloatware --------------------------------------------------------------- function Get-UninstallEntries { $keys = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' Get-ItemProperty $keys -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -and -not $_.SystemComponent -and -not $_.ParentKeyName } } function Get-BloatwareInventory { # Liefert alle auf diesem Geraet gefundenen Bloatware-Kandidaten $cat = [IO.File]::ReadAllText((Join-Path $global:KdsRoot 'config\bloatware.json')) | ConvertFrom-Json $items = New-Object System.Collections.ArrayList # 1) Store-/Appx-Apps (installiert fuer irgendeinen Benutzer oder provisioniert fuer neue Benutzer) $installed = @(Get-AppxPackage -AllUsers -ErrorAction SilentlyContinue | Where-Object { -not $_.NonRemovable }) $provisioned = @(Get-AppxProvisionedPackage -Online -ErrorAction SilentlyContinue) foreach ($e in $cat.appx) { $names = @($installed | Where-Object Name -like $e.pattern | ForEach-Object Name) + @($provisioned | Where-Object DisplayName -like $e.pattern | ForEach-Object DisplayName) foreach ($n in ($names | Sort-Object -Unique)) { if ($items | Where-Object id -eq "appx:$n") { continue } [void]$items.Add([pscustomobject]@{ id = "appx:$n"; kind = 'appx'; name = $(if ($e.pattern -notmatch '\*') { $e.name } else { "$($e.name) ($n)" }) detail = $n; category = $e.category; recommended = [bool]$e.recommended }) } } # 2) Klassische Programme + vorinstallierte Office-Versionen (Click-to-Run) foreach ($u in Get-UninstallEntries) { $dn = [string]$u.DisplayName if ($u.UninstallString -match 'OfficeClickToRun\.exe') { # Consumer-Vorinstallationen (Home/Personal/OneNote Free) empfehlen, Business-Versionen nur anzeigen $consumer = $u.UninstallString -match 'productstoremove=(O365HomePrem|O365Personal|HomeStudent|HomeBusiness|Personal|OneNoteFree|Professional)Retail' [void]$items.Add([pscustomobject]@{ id = "arp:$dn"; kind = 'win32'; name = $dn; detail = $(if ($u.UninstallString -match 'productstoremove=([^ ]+)') { $Matches[1] } else { 'Click-to-Run' }) category = 'Office / OneNote vorinstalliert'; recommended = $consumer }) continue } if ($dn -match '^Microsoft OneDrive') { continue } # eigener Eintrag $hit = $cat.win32 | Where-Object { $dn -match $_.pattern } | Select-Object -First 1 if ($hit) { [void]$items.Add([pscustomobject]@{ id = "arp:$dn"; kind = 'win32'; name = $dn; detail = "$($u.Publisher) $($u.DisplayVersion)".Trim() category = $hit.category; recommended = [bool]$hit.recommended }) } } # 3) OneDrive (systemweit + Autostart fuer neue Benutzer) if ((Get-OneDriveSetups) -or (Get-UninstallEntries | Where-Object DisplayName -match '^Microsoft OneDrive')) { [void]$items.Add([pscustomobject]@{ id = 'onedrive'; kind = 'onedrive'; name = 'Microsoft OneDrive'; detail = 'Deinstallation + keine Einrichtung fuer neue Benutzer' category = 'Office / OneNote vorinstalliert'; recommended = $false }) } return @($items) } function Get-OneDriveSetups { @("$env:SystemRoot\System32\OneDriveSetup.exe", "$env:SystemRoot\SysWOW64\OneDriveSetup.exe") + @(Get-ChildItem "$env:ProgramFiles\Microsoft OneDrive\*\OneDriveSetup.exe", "${env:ProgramFiles(x86)}\Microsoft OneDrive\*\OneDriveSetup.exe" -ErrorAction SilentlyContinue | ForEach-Object FullName) | Where-Object { Test-Path $_ } } function Remove-OneDrive { Get-Process OneDrive -ErrorAction SilentlyContinue | Stop-Process -Force foreach ($s in Get-OneDriveSetups) { $a = if ($s -like "$env:ProgramFiles*" -or $s -like "${env:ProgramFiles(x86)}*") { '/uninstall /allusers' } else { '/uninstall' } Write-Log " $s $a" Start-Process $s -ArgumentList $a -Wait -WindowStyle Hidden } # Automatische Einrichtung fuer neue Benutzerprofile verhindern reg load HKU\KdsDefault "$env:SystemDrive\Users\Default\NTUSER.DAT" 2>$null | Out-Null reg delete 'HKU\KdsDefault\Software\Microsoft\Windows\CurrentVersion\Run' /v OneDriveSetup /f 2>$null | Out-Null [gc]::Collect(); reg unload HKU\KdsDefault 2>$null | Out-Null Remove-Item "$env:PUBLIC\Desktop\OneDrive.lnk", "$env:ProgramData\Microsoft\Windows\Start Menu\Programs\OneDrive.lnk" -Force -ErrorAction SilentlyContinue } function Remove-Win32App { param([string]$DisplayName) $u = Get-UninstallEntries | Where-Object DisplayName -eq $DisplayName | Select-Object -First 1 if (-not $u) { Write-Log ' nicht (mehr) installiert'; return 0 } $us = [string]$u.UninstallString if ($us -match 'OfficeClickToRun\.exe') { # ARP-String von Click-to-Run + DisplayLevel=False = stille Deinstallation if ($us -match '^"([^"]+)"\s*(.*)$') { $exe = $Matches[1]; $rest = $Matches[2] } else { $exe, $rest = $us -split ' ', 2 } return (Start-Process $exe -ArgumentList "$rest DisplayLevel=False" -Wait -PassThru).ExitCode } if ($us -match 'msiexec' -and $u.PSChildName -match '^\{[0-9A-F\-]+\}$') { return (Start-Process msiexec.exe -ArgumentList "/x $($u.PSChildName) /qn /norestart" -Wait -PassThru).ExitCode } if ($u.QuietUninstallString) { return (Start-Process cmd.exe -ArgumentList "/c `"$($u.QuietUninstallString)`"" -Wait -PassThru -WindowStyle Hidden).ExitCode } # Fallback: winget kennt fuer viele ARP-Eintraege den passenden Silent-Schalter if (Get-WingetPath) { $arch = if ($u.PSPath -match 'WOW6432Node') { 'X86' } else { 'X64' } $code = Invoke-Winget @('uninstall', '--id', "ARP\Machine\$arch\$($u.PSChildName)", '--exact', '--silent', '--force', '--accept-source-agreements', '--disable-interactivity') if ($code -eq 0) { return 0 } } Write-Log ' Kein stiller Deinstaller bekannt - starte Standard-Deinstallation (evtl. mit Dialog)' 'WARN' return (Start-Process cmd.exe -ArgumentList "/c `"$us`"" -Wait -PassThru).ExitCode } function Remove-Bloatware { param($Data) $cfg = $Data.bloatware Write-Log 'Ermittle installierte Bloatware ...' $inv = Get-BloatwareInventory $ids = @($cfg.ids) $keep = @($cfg.keep) $sel = @($inv | Where-Object { ($_.id -in $ids) -or ((Test-True $cfg.recommended) -and $_.recommended -and $_.id -notin $keep) }) if (-not $sel.Count) { Write-Log 'Nichts zu entfernen.' 'OK'; return } Write-Log "$($sel.Count) Eintraege werden entfernt" $fail = 0 foreach ($it in $sel) { Write-Log "- $($it.name) [$($it.category)]" try { switch ($it.kind) { 'appx' { $n = $it.detail Get-AppxPackage -AllUsers -Name $n -ErrorAction SilentlyContinue | ForEach-Object { try { Remove-AppxPackage -Package $_.PackageFullName -AllUsers -ErrorAction Stop } catch { Write-Log " $($_.Exception.Message)" 'WARN' } } Get-AppxProvisionedPackage -Online | Where-Object DisplayName -eq $n | ForEach-Object { Remove-AppxProvisionedPackage -Online -PackageName $_.PackageName -AllUsers -ErrorAction SilentlyContinue | Out-Null } Write-Log ' entfernt' 'OK' } 'win32' { $code = Remove-Win32App ($it.id -replace '^arp:', '') if ($code -in 0, 3010, 1605, 1641) { Write-Log " entfernt ($code)" 'OK' } else { throw "ExitCode $code" } } 'onedrive' { Remove-OneDrive; Write-Log ' OneDrive entfernt' 'OK' } } } catch { $fail++; Write-Log " Fehler: $_" 'ERROR' } } Write-Log "Bloatware: $($sel.Count - $fail) entfernt, $fail Fehler" $(if ($fail) { 'WARN' } else { 'OK' }) if ($sel | Where-Object name -match 'McAfee') { Write-Log 'Tipp: McAfee-Reste ggf. mit dem McAfee Consumer Product Removal Tool (MCPR) entfernen.' 'WARN' } } #endregion #region Gesamtprofil ------------------------------------------------------------ function Invoke-FullProfile { param($Data) $steps = @($Data.steps) Write-Log "Starte Profil '$($Data.name)' mit Schritten: $($steps -join ', ')" $run = { param($title, $block) Write-Log "===== $title =====" try { & $block } catch { Write-Log "$title fehlgeschlagen: $_" 'ERROR' } } if ('system' -in $steps) { & $run 'System' { Set-SystemConfig $Data } } if ('bloatware' -in $steps) { & $run 'Bloatware entfernen' { Remove-Bloatware $Data } } if ('security' -in $steps) { & $run 'Sicherheit' { Set-SecurityConfig $Data } } if ('power' -in $steps) { & $run 'Energie' { Set-PowerConfig $Data } } if ('apps' -in $steps) { & $run 'Programme' { Install-Apps $Data } } if ('office' -in $steps) { & $run 'Office' { Install-Office ([pscustomobject]@{ office = $Data.office; mode = $Data.officeMode; name = $Data.name }) } } if ('shortcuts' -in $steps) { & $run 'Desktop-Verknuepfungen' { Set-DesktopShortcuts $Data } } if ('drivers' -in $steps) { & $run 'Hersteller-Treiber' { Invoke-VendorDriverUpdate $Data } } if ('updates' -in $steps) { & $run 'Windows Update' { Install-WindowsUpdates ([pscustomobject]@{ all = $true; type = 'all' }) | Out-Null } } if ('bitlocker' -in $steps) { & $run 'BitLocker' { Set-BitLockerConfig $Data } } Write-Log 'Profil abgeschlossen.' 'OK' } #endregion