KDS Install Agent: Grundversion
PowerShell-HTTP-Server mit Web-/Desktop-Oberflaeche: Programme (winget/Store), Office-ODT-Konfigurator, Desktop-Icons, System/Sicherheit, Energie, BitLocker, Windows-/Treiber-Updates, Profile und Job-Queue. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
||||
# Laufzeitdaten - nicht einchecken
|
||||
logs/
|
||||
bitlocker-keys/
|
||||
office/setup.exe
|
||||
office/source/
|
||||
office/configs/
|
||||
installers/*
|
||||
!installers/.gitkeep
|
||||
packages/winget/*
|
||||
!packages/winget/.gitkeep
|
||||
profiles/*.json
|
||||
@@ -0,0 +1,302 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
KDS Install Agent - Einrichtung von Windows-11-Notebooks per Web-/Desktop-Oberflaeche.
|
||||
.PARAMETER Port
|
||||
HTTP-Port (Standard 8090).
|
||||
.PARAMETER Remote
|
||||
Oberflaeche zusaetzlich im Netzwerk bereitstellen (Token-geschuetzt, Firewall-Regel wird angelegt).
|
||||
.PARAMETER NoWindow
|
||||
Kein Desktop-Fenster oeffnen (nur Web-Server).
|
||||
#>
|
||||
param(
|
||||
[int]$Port = 8090,
|
||||
[switch]$Remote,
|
||||
[switch]$NoWindow
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$global:KdsRoot = $PSScriptRoot
|
||||
|
||||
# --- Adminrechte sicherstellen ----------------------------------------------------
|
||||
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
if (-not $isAdmin) {
|
||||
$a = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$PSCommandPath`"", '-Port', $Port)
|
||||
if ($Remote) { $a += '-Remote' }
|
||||
if ($NoWindow) { $a += '-NoWindow' }
|
||||
Start-Process powershell.exe -Verb RunAs -ArgumentList $a
|
||||
exit
|
||||
}
|
||||
|
||||
. (Join-Path $PSScriptRoot 'lib\Functions.ps1')
|
||||
$Host.UI.RawUI.WindowTitle = 'KDS Install Agent'
|
||||
|
||||
$LogDir = Get-KdsDataDir "logs\$env:COMPUTERNAME"
|
||||
$global:KdsLogFile = Join-Path $LogDir 'agent.log'
|
||||
$Token = -join ((48..57) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char]$_ })
|
||||
|
||||
# --- Job-Verwaltung (sequenzielle Queue, damit sich Installer nicht blockieren) ---
|
||||
$Jobs = [ordered]@{}
|
||||
$Queue = New-Object System.Collections.Queue
|
||||
$JobSeq = 0
|
||||
|
||||
$JobRunner = {
|
||||
param($Root, $Fn, $Json, $LogFile, $ResultFile)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$global:KdsRoot = $Root
|
||||
$global:KdsLogFile = $LogFile
|
||||
. (Join-Path $Root 'lib\Functions.ps1')
|
||||
$data = $Json | ConvertFrom-Json
|
||||
try {
|
||||
$r = & $Fn $data
|
||||
if ($null -ne $r) { ConvertTo-Json -InputObject $r -Depth 8 | Set-Content -Path $ResultFile -Encoding UTF8 }
|
||||
Write-Log 'Aufgabe beendet.' 'OK'
|
||||
} catch {
|
||||
Write-Log "ABBRUCH: $($_.Exception.Message)" 'ERROR'
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
function Add-KdsJob {
|
||||
param([string]$Title, [string]$Fn, $Data)
|
||||
$script:JobSeq++
|
||||
$id = '{0:D3}' -f $script:JobSeq
|
||||
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$entry = [pscustomobject]@{
|
||||
id = $id; title = $Title; fn = $Fn; state = 'Wartet'; created = (Get-Date).ToString('HH:mm:ss')
|
||||
log = (Join-Path $LogDir "$stamp-$id-$Fn.log"); result = (Join-Path $LogDir "$stamp-$id-$Fn.json")
|
||||
json = (ConvertTo-Json -InputObject $Data -Depth 10 -Compress); job = $null
|
||||
}
|
||||
$script:Jobs[$id] = $entry
|
||||
$script:Queue.Enqueue($id)
|
||||
Write-Log "Aufgabe $id eingereiht: $Title"
|
||||
Update-KdsJobs
|
||||
return $id
|
||||
}
|
||||
|
||||
function Update-KdsJobs {
|
||||
$running = $false
|
||||
foreach ($e in $script:Jobs.Values) {
|
||||
if ($e.job) {
|
||||
switch ($e.job.State) {
|
||||
'Running' { $e.state = 'Laeuft'; $running = $true }
|
||||
'Completed' { $e.state = if ((Test-Path $e.log) -and (Select-String -Path $e.log -Pattern '\[ERROR\]' -Quiet)) { 'Fertig (mit Fehlern)' } else { 'Fertig' } }
|
||||
'Failed' { $e.state = 'Fehlgeschlagen' }
|
||||
'Stopped' { $e.state = 'Abgebrochen' }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $running -and $script:Queue.Count -gt 0) {
|
||||
$e = $script:Jobs[$script:Queue.Dequeue()]
|
||||
Set-Content -Path $e.log -Value "=== $($e.title) ($env:COMPUTERNAME, $(Get-Date)) ===" -Encoding UTF8
|
||||
$e.job = Start-Job -ScriptBlock $JobRunner -ArgumentList $global:KdsRoot, $e.fn, $e.json, $e.log, $e.result
|
||||
$e.state = 'Laeuft'
|
||||
}
|
||||
}
|
||||
|
||||
function Read-SharedText {
|
||||
# Liest Dateien, die ein Job gerade beschreibt (Get-Content wuerde ETS-Properties anhaengen, die ConvertTo-Json aufblaehen)
|
||||
param([string]$Path)
|
||||
if (-not (Test-Path $Path)) { return '' }
|
||||
$fs = [IO.File]::Open($Path, 'Open', 'Read', 'ReadWrite')
|
||||
try { $sr = New-Object IO.StreamReader($fs, [Text.Encoding]::UTF8); return [string]$sr.ReadToEnd() } finally { $fs.Close() }
|
||||
}
|
||||
|
||||
function Get-KdsJobView {
|
||||
param($e, [switch]$Full)
|
||||
$o = [ordered]@{ id = $e.id; title = $e.title; state = $e.state; created = $e.created }
|
||||
if ($Full) {
|
||||
$o.log = Read-SharedText $e.log
|
||||
$r = Read-SharedText $e.result
|
||||
# Elemente entpacken, sonst serialisiert PS 5.1 das Array als {value, Count}
|
||||
$o.result = if ($r) { @(($r | ConvertFrom-Json) | ForEach-Object { $_ }) } else { $null }
|
||||
}
|
||||
[pscustomobject]$o
|
||||
}
|
||||
|
||||
# --- HTTP-Helfer -----------------------------------------------------------------
|
||||
$Mime = @{ '.html' = 'text/html; charset=utf-8'; '.js' = 'application/javascript; charset=utf-8'; '.css' = 'text/css; charset=utf-8'
|
||||
'.json' = 'application/json; charset=utf-8'; '.png' = 'image/png'; '.ico' = 'image/x-icon'; '.svg' = 'image/svg+xml'; '.xml' = 'application/xml; charset=utf-8' }
|
||||
|
||||
function Send-Response {
|
||||
param($Ctx, [byte[]]$Bytes, [string]$Type = 'application/json; charset=utf-8', [int]$Code = 200)
|
||||
$r = $Ctx.Response
|
||||
$r.StatusCode = $Code
|
||||
$r.ContentType = $Type
|
||||
$r.Headers['Cache-Control'] = 'no-store'
|
||||
$r.ContentLength64 = $Bytes.Length
|
||||
$r.OutputStream.Write($Bytes, 0, $Bytes.Length)
|
||||
$r.OutputStream.Close()
|
||||
}
|
||||
|
||||
function Send-Json {
|
||||
param($Ctx, $Obj, [int]$Code = 200)
|
||||
$json = if ($null -eq $Obj) { 'null' } else { ConvertTo-Json -InputObject $Obj -Depth 10 -Compress }
|
||||
Send-Response $Ctx ([Text.Encoding]::UTF8.GetBytes($json)) -Code $Code
|
||||
}
|
||||
|
||||
function Read-Body {
|
||||
param($Ctx)
|
||||
$sr = New-Object IO.StreamReader($Ctx.Request.InputStream, [Text.Encoding]::UTF8)
|
||||
$txt = $sr.ReadToEnd(); $sr.Close()
|
||||
if ($txt) { return $txt | ConvertFrom-Json }
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-JsonFile($rel) {
|
||||
$p = Join-Path $global:KdsRoot $rel
|
||||
if (Test-Path $p) { return [IO.File]::ReadAllText($p, [Text.Encoding]::UTF8) | ConvertFrom-Json }
|
||||
return $null
|
||||
}
|
||||
|
||||
# --- Routing ---------------------------------------------------------------------
|
||||
function Invoke-Api {
|
||||
param($Ctx, [string]$Method, [string]$Path)
|
||||
$q = $Ctx.Request.QueryString
|
||||
$route = "$Method $Path"
|
||||
switch -Regex ($route) {
|
||||
'^GET /api/status$' { return Send-Json $Ctx (Get-SystemInfo) }
|
||||
'^GET /api/catalog$' { return Send-Json $Ctx (Get-JsonFile 'config\apps.json') }
|
||||
'^GET /api/presets$' {
|
||||
return Send-Json $Ctx ([pscustomobject]@{ shortcuts = (Get-JsonFile 'config\shortcuts.json'); timezones = @(Get-TimeZone -ListAvailable | ForEach-Object { @{ id = $_.Id; name = $_.DisplayName } }) })
|
||||
}
|
||||
'^GET /api/winget/search$' { return Send-Json $Ctx @(Search-Winget -Query $q['q'] -Source $(if ($q['source']) { $q['source'] } else { 'winget' })) }
|
||||
'^GET /api/local-installers$' {
|
||||
$dir = Join-Path $global:KdsRoot 'installers'
|
||||
$files = @(Get-ChildItem $dir -Recurse -Include *.exe, *.msi -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName.Substring($global:KdsRoot.Length + 1) })
|
||||
return Send-Json $Ctx $files
|
||||
}
|
||||
'^POST /api/apps/install$' { $b = Read-Body $Ctx; return Send-Json $Ctx @{ job = (Add-KdsJob "Programme installieren ($(@($b.apps).Count))" 'Install-Apps' $b) } }
|
||||
'^POST /api/apps/upgrade-all$' { return Send-Json $Ctx @{ job = (Add-KdsJob 'Alle Programme aktualisieren (winget)' 'Update-AllApps' @{}) } }
|
||||
'^POST /api/winget/install$' { return Send-Json $Ctx @{ job = (Add-KdsJob 'winget bereitstellen' 'Install-Winget' @{}) } }
|
||||
|
||||
'^POST /api/office/xml$' { $b = Read-Body $Ctx; return Send-Response $Ctx ([Text.Encoding]::UTF8.GetBytes((New-OfficeXml $b))) 'application/xml; charset=utf-8' }
|
||||
'^POST /api/office/install$' {
|
||||
$b = Read-Body $Ctx
|
||||
$t = @{ online = 'Office installieren'; download = 'Office-Build auf Stick erstellen'; offline = 'Office offline installieren' }[$b.mode]
|
||||
return Send-Json $Ctx @{ job = (Add-KdsJob $t 'Install-Office' $b) }
|
||||
}
|
||||
'^GET /api/office/configs$' {
|
||||
return Send-Json $Ctx @(Get-ChildItem (Join-Path $global:KdsRoot 'office\source') -Directory -ErrorAction SilentlyContinue | ForEach-Object Name)
|
||||
}
|
||||
|
||||
'^GET /api/shortcuts$' { return Send-Json $Ctx @(Get-PublicShortcuts) }
|
||||
'^POST /api/shortcuts$' { $b = Read-Body $Ctx; return Send-Json $Ctx @{ file = (New-PublicShortcut $b) } }
|
||||
'^DELETE /api/shortcuts$' { Remove-PublicShortcut $q['file']; return Send-Json $Ctx @{ ok = $true } }
|
||||
'^GET /api/icons$' {
|
||||
return Send-Json $Ctx @(Get-ChildItem (Join-Path $global:KdsRoot 'icons') -Include *.ico, *.exe, *.dll -Recurse -ErrorAction SilentlyContinue | ForEach-Object Name)
|
||||
}
|
||||
|
||||
'^POST /api/system$' { $b = Read-Body $Ctx; return Send-Json $Ctx @{ job = (Add-KdsJob 'Systemeinstellungen' 'Set-SystemConfig' $b) } }
|
||||
'^POST /api/security$' { $b = Read-Body $Ctx; return Send-Json $Ctx @{ job = (Add-KdsJob 'Sicherheitseinstellungen' 'Set-SecurityConfig' $b) } }
|
||||
'^POST /api/power$' { $b = Read-Body $Ctx; return Send-Json $Ctx @{ job = (Add-KdsJob 'Energieeinstellungen' 'Set-PowerConfig' $b) } }
|
||||
'^GET /api/power$' {
|
||||
$active = (powercfg /getactivescheme) -join ''
|
||||
return Send-Json $Ctx @{ active = $active; hibernate = [bool](Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Power' -Name HibernateEnabled -ErrorAction SilentlyContinue).HibernateEnabled }
|
||||
}
|
||||
'^GET /api/bitlocker$' { return Send-Json $Ctx (Get-BitLockerInfo) }
|
||||
'^POST /api/bitlocker$' { $b = Read-Body $Ctx; return Send-Json $Ctx @{ job = (Add-KdsJob "BitLocker: $($b.bitlocker.action)" 'Set-BitLockerConfig' $b) } }
|
||||
|
||||
'^POST /api/updates/scan$' { $b = Read-Body $Ctx; return Send-Json $Ctx @{ job = (Add-KdsJob "Update-Suche ($($b.type))" 'Find-WindowsUpdates' $b) } }
|
||||
'^POST /api/updates/install$' { $b = Read-Body $Ctx; return Send-Json $Ctx @{ job = (Add-KdsJob "Updates installieren ($(if($b.all){'alle'}else{@($b.ids).Count}))" 'Install-WindowsUpdates' $b) } }
|
||||
'^POST /api/updates/vendor$' { return Send-Json $Ctx @{ job = (Add-KdsJob 'Hersteller-Treiber-Update' 'Invoke-VendorDriverUpdate' @{}) } }
|
||||
'^POST /api/reboot$' { Restart-Computer -Force; return Send-Json $Ctx @{ ok = $true } }
|
||||
|
||||
'^GET /api/profiles$' {
|
||||
return Send-Json $Ctx @(Get-ChildItem (Join-Path $global:KdsRoot 'profiles') -Filter *.json -ErrorAction SilentlyContinue | ForEach-Object BaseName)
|
||||
}
|
||||
'^GET /api/profiles/(?<n>[\w\-\. ]+)$' {
|
||||
$p = Join-Path $global:KdsRoot "profiles\$($Matches.n).json"
|
||||
if (-not (Test-Path $p)) { return Send-Json $Ctx @{ error = 'nicht gefunden' } 404 }
|
||||
return Send-Response $Ctx ([IO.File]::ReadAllBytes($p))
|
||||
}
|
||||
'^POST /api/profiles/(?<n>[\w\-\. ]+)$' {
|
||||
$sr = New-Object IO.StreamReader($Ctx.Request.InputStream, [Text.Encoding]::UTF8); $txt = $sr.ReadToEnd(); $sr.Close()
|
||||
$dir = Join-Path $global:KdsRoot 'profiles'; New-Item $dir -ItemType Directory -Force | Out-Null
|
||||
[IO.File]::WriteAllText((Join-Path $dir "$($Matches.n).json"), $txt, (New-Object Text.UTF8Encoding($false)))
|
||||
return Send-Json $Ctx @{ ok = $true }
|
||||
}
|
||||
'^DELETE /api/profiles/(?<n>[\w\-\. ]+)$' { Remove-Item (Join-Path $global:KdsRoot "profiles\$($Matches.n).json") -ErrorAction SilentlyContinue; return Send-Json $Ctx @{ ok = $true } }
|
||||
'^POST /api/run-profile$' { $b = Read-Body $Ctx; return Send-Json $Ctx @{ job = (Add-KdsJob "Profil ausfuehren: $($b.name)" 'Invoke-FullProfile' $b) } }
|
||||
|
||||
'^GET /api/jobs$' { Update-KdsJobs; return Send-Json $Ctx @($script:Jobs.Values | ForEach-Object { Get-KdsJobView $_ }) }
|
||||
'^GET /api/jobs/(?<id>\d+)$' {
|
||||
Update-KdsJobs
|
||||
$e = $script:Jobs[$Matches.id]
|
||||
if (-not $e) { return Send-Json $Ctx @{ error = 'unbekannt' } 404 }
|
||||
return Send-Json $Ctx (Get-KdsJobView $e -Full)
|
||||
}
|
||||
'^DELETE /api/jobs/(?<id>\d+)$' {
|
||||
$e = $script:Jobs[$Matches.id]
|
||||
if ($e -and $e.job) { Stop-Job $e.job }
|
||||
elseif ($e) { $e.state = 'Abgebrochen'; $script:Queue = New-Object System.Collections.Queue (@($script:Queue.ToArray() | Where-Object { $_ -ne $e.id })) }
|
||||
return Send-Json $Ctx @{ ok = $true }
|
||||
}
|
||||
'^POST /api/shutdown$' { Send-Json $Ctx @{ ok = $true }; $script:Running = $false; return }
|
||||
default { return Send-Json $Ctx @{ error = "Unbekannte Route: $route" } 404 }
|
||||
}
|
||||
}
|
||||
|
||||
# --- Server starten --------------------------------------------------------------
|
||||
$listener = New-Object System.Net.HttpListener
|
||||
if ($Remote) {
|
||||
$listener.Prefixes.Add("http://+:$Port/")
|
||||
if (-not (Get-NetFirewallRule -DisplayName "KDS Install Agent $Port" -ErrorAction SilentlyContinue)) {
|
||||
New-NetFirewallRule -DisplayName "KDS Install Agent $Port" -Direction Inbound -Protocol TCP -LocalPort $Port -Action Allow -Profile Domain, Private | Out-Null
|
||||
}
|
||||
} else {
|
||||
$listener.Prefixes.Add("http://localhost:$Port/")
|
||||
$listener.Prefixes.Add("http://127.0.0.1:$Port/")
|
||||
}
|
||||
$listener.Start()
|
||||
|
||||
$localUrl = "http://localhost:$Port/?token=$Token"
|
||||
Write-Host ''
|
||||
Write-Host ' KDS Install Agent laeuft' -ForegroundColor Cyan
|
||||
Write-Host " Lokal: $localUrl"
|
||||
if ($Remote) {
|
||||
Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' } | ForEach-Object {
|
||||
Write-Host " Netzwerk: http://$($_.IPAddress):$Port/?token=$Token" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
Write-Host " Logs: $LogDir"
|
||||
Write-Host ' Beenden: Button "Beenden" in der Oberflaeche oder dieses Fenster schliessen.' -ForegroundColor DarkGray
|
||||
Write-Host ''
|
||||
|
||||
if (-not $NoWindow) {
|
||||
$edge = @("${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe", "$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe") | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if ($edge) {
|
||||
$profileDir = Join-Path $env:TEMP 'KDS-Agent-Edge'
|
||||
Start-Process $edge -ArgumentList "--app=$localUrl", '--window-size=1400,920', "--user-data-dir=`"$profileDir`"", '--no-first-run', '--no-default-browser-check'
|
||||
} else { Start-Process $localUrl }
|
||||
}
|
||||
|
||||
$WebRoot = Join-Path $PSScriptRoot 'web'
|
||||
$script:Running = $true
|
||||
while ($script:Running -and $listener.IsListening) {
|
||||
$async = $listener.BeginGetContext($null, $null)
|
||||
while (-not $async.AsyncWaitHandle.WaitOne(1000)) { Update-KdsJobs }
|
||||
$ctx = $listener.EndGetContext($async)
|
||||
$path = $ctx.Request.Url.AbsolutePath
|
||||
try {
|
||||
if ($path.StartsWith('/api/')) {
|
||||
$t = $ctx.Request.Headers['X-Token']
|
||||
if (-not $t) { $t = $ctx.Request.QueryString['token'] }
|
||||
if ($t -ne $Token) { Send-Json $ctx @{ error = 'Ungueltiges Token' } 401; continue }
|
||||
Invoke-Api $ctx $ctx.Request.HttpMethod $path
|
||||
} else {
|
||||
if ($path -eq '/') { $path = '/index.html' }
|
||||
$file = [IO.Path]::GetFullPath((Join-Path $WebRoot $path.TrimStart('/')))
|
||||
if ($file.StartsWith($WebRoot) -and (Test-Path $file -PathType Leaf)) {
|
||||
$type = $Mime[[IO.Path]::GetExtension($file)]; if (-not $type) { $type = 'application/octet-stream' }
|
||||
Send-Response $ctx ([IO.File]::ReadAllBytes($file)) $type
|
||||
} else { Send-Response $ctx ([Text.Encoding]::UTF8.GetBytes('Not found')) 'text/plain' 404 }
|
||||
}
|
||||
} catch {
|
||||
Write-Log "API-Fehler $path : $($_.Exception.Message)" 'ERROR'
|
||||
try { Send-Json $ctx @{ error = $_.Exception.Message } 500 } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
$listener.Stop()
|
||||
Get-Job | Stop-Job -ErrorAction SilentlyContinue
|
||||
Write-Host 'Agent beendet.'
|
||||
@@ -0,0 +1,58 @@
|
||||
# KDS Install Agent
|
||||
|
||||
Portabler Agent zum automatischen Einrichten von Windows-11-Notebooks – direkt vom USB-Stick, ohne Installation und ohne zusätzliche Abhängigkeiten (nur Bordmittel: PowerShell 5.1, Edge, winget).
|
||||
|
||||
## Start
|
||||
|
||||
| Datei | Zweck |
|
||||
|---|---|
|
||||
| `Start.cmd` | Startet den Agent (fordert Adminrechte an) und öffnet die Oberfläche als eigenes Desktop-Fenster (Edge-App-Modus). |
|
||||
| `Start-Remote.cmd` | Zusätzlich im Netzwerk erreichbar (Firewall-Regel für Domäne/Privat wird angelegt). Die URL mit Token steht im Konsolenfenster. |
|
||||
|
||||
Parameter für `Agent.ps1`: `-Port 8090`, `-Remote`, `-NoWindow` (nur Web-Server).
|
||||
|
||||
Die Web-API ist immer mit einem zufälligen Token geschützt, das bei jedem Start neu erzeugt wird.
|
||||
|
||||
## Funktionen
|
||||
|
||||
- **Programme**: Live-Suche im öffentlichen **winget**-Repository und im **Microsoft Store**, Schnellauswahl aus `config/apps.json`, eigene Installer aus `installers\`. Installation systemweit (`--scope machine`, Fallback auf Standard-Scope). „Alle aktualisieren“ über `winget upgrade --all`. Fehlt winget, wird es automatisch nachinstalliert (offline aus `packages\winget\`, sonst über Microsoft).
|
||||
- **Office**: Konfigurator für das Office Deployment Tool (M365 Apps Enterprise/Business, LTSC 2024/2021, Home & Business 2024, Visio/Project, Sprachen, Apps abwählen, Kanal, Architektur, MSI-Entfernung, Shared Activation, Makro-Sperre …) mit Live-Vorschau der `configuration.xml`.
|
||||
- *Online*: direkt vom Microsoft-CDN installieren
|
||||
- *Build*: Installationsdateien nach `office\source\<Produkt>-<Arch>` auf den Stick laden
|
||||
- *Offline*: vom Stick installieren
|
||||
- `setup.exe` wird bei Bedarf automatisch geladen; generierte XMLs landen in `office\configs\`.
|
||||
- **Desktop-Icons** für alle Benutzer (öffentlicher Desktop): Web-Links (`.url`) und Programmverknüpfungen (`.lnk`), eigene Icons aus `icons\` (werden nach `C:\ProgramData\KDS-Agent\Icons` kopiert). Vorlagen in `config/shortcuts.json`.
|
||||
- **System**: Computername, Zeitzone, Dateiendungen anzeigen (auch für neue Benutzer), Consumer-Features/Werbe-Apps aus.
|
||||
- **Sicherheit**: Defender (Echtzeit/Cloud/PUA), Firewall, SmartScreen, UAC, SMBv1 aus, Autorun aus, LSA-Schutz, Telemetrie, RDP an/aus, lokales Admin-Konto.
|
||||
- **Energie**: Energieplan, Timeouts für Bildschirm/Standby/Ruhezustand (Netz & Akku), Deckel- und Netzschalteraktion, Ruhezustand, Schnellstart.
|
||||
- **BitLocker**: Status, Aktivierung mit TPM oder TPM+PIN, XTS-AES 128/256, nur belegten Speicher, Sicherung in Entra ID / AD. Der Wiederherstellungsschlüssel wird **immer** unter `bitlocker-keys\` auf dem Stick gespeichert (Computername + Seriennummer).
|
||||
- **Updates & Treiber**: Windows- und Treiberupdates über die Windows-Update-API (inkl. Microsoft Update) suchen, auswählen, installieren. Hersteller-Tools: Dell Command Update, Lenovo System Update, HP Image Assistant.
|
||||
- **Profile**: Gesamte Konfiguration als JSON unter `profiles\` speichern/laden/importieren/exportieren.
|
||||
- **Einrichtung mit einem Klick**: Auf der Übersicht alle Schritte in sinnvoller Reihenfolge ausführen.
|
||||
- **Aufgaben**: Alle Aktionen laufen als Hintergrund-Jobs in einer Warteschlange (nacheinander, damit sich Installer nicht blockieren). Live-Protokoll in der Oberfläche; Logs unter `logs\<Computername>\`.
|
||||
|
||||
## Ordnerstruktur
|
||||
|
||||
```
|
||||
Start.cmd / Start-Remote.cmd Starter
|
||||
Agent.ps1 HTTP-Server, API, Job-Queue
|
||||
lib\Functions.ps1 Alle Einrichtungsfunktionen
|
||||
web\ Oberfläche (HTML/CSS/JS)
|
||||
config\apps.json Programm-Schnellauswahl (anpassbar)
|
||||
config\shortcuts.json Verknüpfungs-Vorlagen (anpassbar)
|
||||
installers\ eigene Setups (.exe/.msi)
|
||||
icons\ eigene Icons (.ico)
|
||||
packages\winget\ optional: winget-Offlinepakete (*.msixbundle + Abhängigkeiten *.appx)
|
||||
office\ setup.exe, configs\, source\ (Offline-Builds)
|
||||
profiles\ gespeicherte Profile
|
||||
logs\, bitlocker-keys\ werden zur Laufzeit angelegt
|
||||
```
|
||||
|
||||
Ist der Stick schreibgeschützt, schreibt der Agent Logs und Schlüssel nach `C:\ProgramData\KDS-Agent\`.
|
||||
|
||||
## Hinweise
|
||||
|
||||
- **Sicherheit**: Profile können Klartext-Kennwörter (lokaler Admin, BitLocker-PIN) enthalten, und `bitlocker-keys\` enthält Wiederherstellungsschlüssel. Den Stick entsprechend schützen. Beide Ordner sind per `.gitignore` von der Versionsverwaltung ausgeschlossen.
|
||||
- BitLocker benötigt Windows 11 Pro/Enterprise/Education. Unter Home steht nur die Geräteverschlüsselung zur Verfügung.
|
||||
- Die Store-Installation (`msstore`) braucht Internet und kann bei Konten ohne Store-Zugang fehlschlagen.
|
||||
- Einige Einstellungen (Computername, LSA-Schutz, SMBv1, BitLocker mit Hardwaretest) werden erst nach einem Neustart wirksam.
|
||||
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
REM KDS Install Agent - zusaetzlich im Netzwerk erreichbar (Token-geschuetzt)
|
||||
cd /d "%~dp0"
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Agent.ps1" -Remote %*
|
||||
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
REM KDS Install Agent - Start vom USB-Stick (Desktop-Fenster + lokale Web-Oberflaeche)
|
||||
cd /d "%~dp0"
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Agent.ps1" %*
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"_info": "Katalog fuer die Schnellauswahl. source: winget | msstore | local. Bei local: path relativ zum Stick (installers\\...) und args fuer Silent-Install.",
|
||||
"apps": [
|
||||
{ "category": "Browser", "name": "Google Chrome", "id": "Google.Chrome", "source": "winget" },
|
||||
{ "category": "Browser", "name": "Mozilla Firefox (DE)", "id": "Mozilla.Firefox.de", "source": "winget" },
|
||||
{ "category": "Browser", "name": "Brave", "id": "Brave.Brave", "source": "winget" },
|
||||
|
||||
{ "category": "Kommunikation", "name": "Microsoft Teams", "id": "Microsoft.Teams", "source": "winget" },
|
||||
{ "category": "Kommunikation", "name": "Zoom Workplace", "id": "Zoom.Zoom", "source": "winget" },
|
||||
{ "category": "Kommunikation", "name": "Mozilla Thunderbird (DE)", "id": "Mozilla.Thunderbird.de", "source": "winget" },
|
||||
{ "category": "Kommunikation", "name": "Webex", "id": "Cisco.Webex", "source": "winget" },
|
||||
|
||||
{ "category": "Office & PDF", "name": "Adobe Acrobat Reader (64-Bit)", "id": "Adobe.Acrobat.Reader.64-bit", "source": "winget" },
|
||||
{ "category": "Office & PDF", "name": "PDF24 Creator", "id": "geeksoftwareGmbH.PDF24Creator", "source": "winget" },
|
||||
{ "category": "Office & PDF", "name": "LibreOffice", "id": "TheDocumentFoundation.LibreOffice", "source": "winget" },
|
||||
{ "category": "Office & PDF", "name": "Microsoft OneDrive", "id": "Microsoft.OneDrive", "source": "winget" },
|
||||
|
||||
{ "category": "Tools", "name": "7-Zip", "id": "7zip.7zip", "source": "winget" },
|
||||
{ "category": "Tools", "name": "Notepad++", "id": "Notepad++.Notepad++", "source": "winget" },
|
||||
{ "category": "Tools", "name": "Greenshot", "id": "Greenshot.Greenshot", "source": "winget" },
|
||||
{ "category": "Tools", "name": "Microsoft PowerToys", "id": "Microsoft.PowerToys", "source": "winget" },
|
||||
{ "category": "Tools", "name": "KeePassXC", "id": "KeePassXCTeam.KeePassXC", "source": "winget" },
|
||||
{ "category": "Tools", "name": "Everything", "id": "voidtools.Everything", "source": "winget" },
|
||||
|
||||
{ "category": "Fernwartung", "name": "TeamViewer", "id": "TeamViewer.TeamViewer", "source": "winget" },
|
||||
{ "category": "Fernwartung", "name": "TeamViewer QuickSupport", "id": "TeamViewer.TeamViewer.QuickSupport", "source": "winget" },
|
||||
{ "category": "Fernwartung", "name": "AnyDesk", "id": "AnyDesk.AnyDesk", "source": "winget" },
|
||||
|
||||
{ "category": "Medien", "name": "VLC Media Player", "id": "VideoLAN.VLC", "source": "winget" },
|
||||
{ "category": "Medien", "name": "IrfanView", "id": "IrfanSkiljan.IrfanView", "source": "winget" },
|
||||
|
||||
{ "category": "Laufzeiten", "name": "Visual C++ 2015-2022 x64", "id": "Microsoft.VCRedist.2015+.x64", "source": "winget" },
|
||||
{ "category": "Laufzeiten", "name": ".NET Desktop Runtime 8", "id": "Microsoft.DotNet.DesktopRuntime.8", "source": "winget" },
|
||||
{ "category": "Laufzeiten", "name": "Java Runtime (Temurin 21)", "id": "EclipseAdoptium.Temurin.21.JRE", "source": "winget" },
|
||||
|
||||
{ "category": "Microsoft Store", "name": "Unternehmensportal (Intune)", "id": "9WZDNCRFJ3PZ", "source": "msstore" },
|
||||
{ "category": "Microsoft Store", "name": "WhatsApp", "id": "9NKSQGP7F2NH", "source": "msstore" },
|
||||
|
||||
{ "category": "Entwicklung", "name": "Visual Studio Code", "id": "Microsoft.VisualStudioCode", "source": "winget" },
|
||||
{ "category": "Entwicklung", "name": "Git", "id": "Git.Git", "source": "winget" },
|
||||
{ "category": "Entwicklung", "name": "WinSCP", "id": "WinSCP.WinSCP", "source": "winget" },
|
||||
{ "category": "Entwicklung", "name": "PuTTY", "id": "PuTTY.PuTTY", "source": "winget" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{ "name": "Outlook (Web)", "type": "url", "target": "https://outlook.office.com" },
|
||||
{ "name": "Microsoft 365", "type": "url", "target": "https://www.office.com" },
|
||||
{ "name": "Fernwartung (Support)", "type": "lnk", "target": "%ProgramFiles%\\TeamViewer\\TeamViewer.exe" },
|
||||
{ "name": "Word", "type": "lnk", "target": "%ProgramFiles%\\Microsoft Office\\root\\Office16\\WINWORD.EXE" },
|
||||
{ "name": "Excel", "type": "lnk", "target": "%ProgramFiles%\\Microsoft Office\\root\\Office16\\EXCEL.EXE" },
|
||||
{ "name": "Outlook", "type": "lnk", "target": "%ProgramFiles%\\Microsoft Office\\root\\Office16\\OUTLOOK.EXE" },
|
||||
{ "name": "Google Chrome", "type": "lnk", "target": "%ProgramFiles%\\Google\\Chrome\\Application\\chrome.exe" },
|
||||
{ "name": "Dieser PC", "type": "lnk", "target": "explorer.exe", "args": "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}", "icon": "%SystemRoot%\\System32\\imageres.dll,-109" }
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
Platzhalter
|
||||
@@ -0,0 +1 @@
|
||||
Platzhalter
|
||||
@@ -0,0 +1,779 @@
|
||||
# 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" }
|
||||
if ($file -like '*.msi') {
|
||||
$p = Start-Process msiexec.exe -ArgumentList "/i `"$file`" /qn /norestart $($a.args)" -Wait -PassThru
|
||||
} else {
|
||||
$p = Start-Process -FilePath $file -ArgumentList $a.args -Wait -PassThru
|
||||
}
|
||||
$code = $p.ExitCode
|
||||
} 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 { " <Language ID=`"$(& $e $_)`" />" }) -join "`r`n"
|
||||
$exclXml = (@($Cfg.exclude) | Where-Object { $_ } | ForEach-Object { " <ExcludeApp ID=`"$(& $e $_)`" />" }) -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 += " <Product ID=`"$(& $e $Cfg.product)`"$pidAttr>`r`n$langXml$(if($exclXml){"`r`n$exclXml"})`r`n </Product>"
|
||||
foreach ($extra in @($Cfg.extraProducts)) {
|
||||
if ($extra) { $products += " <Product ID=`"$(& $e $extra)`">`r`n$langXml`r`n </Product>" }
|
||||
}
|
||||
|
||||
$x = @()
|
||||
$x += "<Configuration ID=`"$([guid]::NewGuid())`">"
|
||||
$x += " <Add $addAttr>"
|
||||
$x += $products
|
||||
$x += ' </Add>'
|
||||
$x += " <Property Name=`"SharedComputerLicensing`" Value=`"$(if(Test-True $Cfg.sharedComputer){'1'}else{'0'})`" />"
|
||||
$x += " <Property Name=`"FORCEAPPSHUTDOWN`" Value=`"TRUE`" />"
|
||||
$x += " <Property Name=`"DeviceBasedLicensing`" Value=`"0`" />"
|
||||
$x += " <Property Name=`"SCLCacheOverride`" Value=`"0`" />"
|
||||
if ($Cfg.product -like '*Volume') { $x += " <Property Name=`"AUTOACTIVATE`" Value=`"1`" />" }
|
||||
$x += " <Updates Enabled=`"$(if(Test-True $Cfg.updates){'TRUE'}else{'FALSE'})`" />"
|
||||
if (Test-True $Cfg.removeMsi) { $x += ' <RemoveMSI />' }
|
||||
$x += ' <AppSettings>'
|
||||
if ($Cfg.company) { $x += " <Setup Name=`"Company`" Value=`"$(& $e $Cfg.company)`" />" }
|
||||
if (Test-True $Cfg.ooxmlDefault) {
|
||||
$x += ' <User Key="software\microsoft\office\16.0\common\general" Name="shownfirstrunoptin" Value="1" Type="REG_DWORD" App="office16" Id="L_DisableOptinWizard" />'
|
||||
}
|
||||
if (Test-True $Cfg.disableVba) {
|
||||
foreach ($app in 'word', 'excel', 'powerpoint') {
|
||||
$x += " <User Key=`"software\policies\microsoft\office\16.0\$app\security`" Name=`"blockcontentexecutionfrominternet`" Value=`"1`" Type=`"REG_DWORD`" App=`"$app`16`" Id=`"L_BlockMacroExecutionFromInternet`" />"
|
||||
}
|
||||
}
|
||||
$x += ' </AppSettings>'
|
||||
$x += " <Display Level=`"$(if(Test-True $Cfg.showUi){'Full'}else{'None'})`" AcceptEULA=`"TRUE`" />"
|
||||
$x += '</Configuration>'
|
||||
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' } | ForEach-Object {
|
||||
$target = ''
|
||||
if ($_.Extension -eq '.lnk') { $target = $sh.CreateShortcut($_.FullName).TargetPath }
|
||||
else { $m = Select-String -Path $_.FullName -Pattern '^URL=(.*)$' | Select-Object -First 1; if ($m) { $target = $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 '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' }
|
||||
}
|
||||
}
|
||||
|
||||
#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 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 ('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
|
||||
@@ -0,0 +1 @@
|
||||
Platzhalter
|
||||
@@ -0,0 +1 @@
|
||||
x
|
||||
+411
@@ -0,0 +1,411 @@
|
||||
'use strict';
|
||||
// ---------------------------------------------------------------- Basis
|
||||
const $ = (s, r = document) => r.querySelector(s);
|
||||
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
|
||||
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
|
||||
const urlToken = new URLSearchParams(location.search).get('token');
|
||||
if (urlToken) { sessionStorage.setItem('kdsToken', urlToken); history.replaceState(null, '', '/' + location.hash); }
|
||||
const TOKEN = sessionStorage.getItem('kdsToken') || '';
|
||||
|
||||
async function api(method, path, body, raw) {
|
||||
const r = await fetch(path, {
|
||||
method, headers: { 'X-Token': TOKEN, 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : (typeof body === 'string' ? body : JSON.stringify(body))
|
||||
});
|
||||
if (raw) return r.text();
|
||||
const data = await r.json().catch(() => null);
|
||||
if (!r.ok) throw new Error(data?.error || `HTTP ${r.status}`);
|
||||
return data;
|
||||
}
|
||||
const arr = v => v == null ? [] : Array.isArray(v) ? v : [v];
|
||||
|
||||
function toast(msg, isErr, onClick) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = msg; if (isErr) d.className = 'err';
|
||||
d.onclick = () => { onClick?.(); d.remove(); };
|
||||
$('#toast').append(d); setTimeout(() => d.remove(), isErr ? 8000 : 4000);
|
||||
}
|
||||
async function guard(fn) { try { return await fn(); } catch (e) { toast(e.message, true); } }
|
||||
|
||||
// ---------------------------------------------------------------- Zustand / Profil
|
||||
const DEFAULTS = () => ({
|
||||
name: '',
|
||||
apps: [],
|
||||
office: {
|
||||
product: 'O365ProPlusRetail', channel: 'Current', arch: '64', languages: ['de-de'],
|
||||
exclude: ['Groove', 'Lync', 'Bing'], extraProducts: [], pidKey: '', company: '',
|
||||
updates: true, removeMsi: true, migrateArch: true, sharedComputer: false, disableVba: true, ooxmlDefault: true, showUi: false
|
||||
},
|
||||
officeMode: 'online',
|
||||
shortcuts: [],
|
||||
system: { computerName: '', timeZone: '', showExtensions: true, disableConsumer: true },
|
||||
security: { defender: true, firewall: true, smartScreen: true, uac: false, disableSmb1: true, disableAutorun: true, lsaProtection: false, telemetryMin: false, rdp: 'keep', localAdminName: '', localAdminPassword: '' },
|
||||
power: { plan: 'balanced', monitorAc: 15, monitorDc: 5, sleepAc: 0, sleepDc: 15, hibernateAc: 0, hibernateDc: 60, lidAc: 'nothing', lidDc: 'sleep', buttonAc: 'shutdown', buttonDc: 'shutdown', hibernate: '', fastStartup: 'off' },
|
||||
bitlocker: { method: 'XtsAes256', usedSpaceOnly: true, skipHardwareTest: true, usePin: false, pin: '', backupAad: false, backupAd: false },
|
||||
steps: ['system', 'security', 'power', 'apps', 'office', 'shortcuts', 'updates']
|
||||
});
|
||||
let S = DEFAULTS();
|
||||
try { const saved = JSON.parse(localStorage.getItem('kdsState') || 'null'); if (saved) S = deepMerge(DEFAULTS(), saved); } catch { }
|
||||
|
||||
function deepMerge(base, add) {
|
||||
for (const k of Object.keys(add || {})) {
|
||||
if (add[k] && typeof add[k] === 'object' && !Array.isArray(add[k]) && base[k] && typeof base[k] === 'object') deepMerge(base[k], add[k]);
|
||||
else base[k] = add[k];
|
||||
}
|
||||
return base;
|
||||
}
|
||||
const getPath = p => p.split('.').reduce((o, k) => o?.[k], S);
|
||||
function setPath(p, v) { const ks = p.split('.'); const last = ks.pop(); ks.reduce((o, k) => o[k] ??= {}, S)[last] = v; }
|
||||
function persist() { try { localStorage.setItem('kdsState', JSON.stringify(S)); } catch { } renderSummary(); }
|
||||
|
||||
// generische Formularbindung: data-bind="pfad" und data-bind-list="pfad" (Checkbox-Gruppen)
|
||||
function bindForms() {
|
||||
$$('[data-bind]').forEach(el => {
|
||||
el.addEventListener(el.type === 'checkbox' ? 'change' : 'input', () => {
|
||||
let v = el.type === 'checkbox' ? el.checked : el.value;
|
||||
if (el.type === 'number') v = el.value === '' ? '' : Number(el.value);
|
||||
setPath(el.dataset.bind, v); persist(); onChange(el.dataset.bind);
|
||||
});
|
||||
if (el.tagName === 'SELECT') el.addEventListener('change', () => el.dispatchEvent(new Event('input')));
|
||||
});
|
||||
$$('[data-bind-list]').forEach(box => box.addEventListener('change', () => {
|
||||
setPath(box.dataset.bindList, $$('input:checked', box).map(i => i.value)); persist(); onChange(box.dataset.bindList);
|
||||
}));
|
||||
}
|
||||
function fillForms() {
|
||||
$$('[data-bind]').forEach(el => {
|
||||
const v = getPath(el.dataset.bind);
|
||||
if (el.type === 'checkbox') el.checked = !!v; else el.value = v ?? '';
|
||||
});
|
||||
$$('[data-bind-list]').forEach(box => {
|
||||
const v = arr(getPath(box.dataset.bindList));
|
||||
$$('input', box).forEach(i => i.checked = v.includes(i.value));
|
||||
});
|
||||
$$('#steps input').forEach(i => i.checked = arr(S.steps).includes(i.value));
|
||||
$('#profileName').textContent = 'Profil: ' + (S.name || '(ungespeichert)');
|
||||
$$('.pinonly').forEach(e => e.classList.toggle('hidden', !S.bitlocker.usePin));
|
||||
}
|
||||
function onChange(path) {
|
||||
if (path.startsWith('office')) { if (path === 'office.product') { renderOfficeChannels(); renderOfficeExtras(); } updateOfficeXml(); }
|
||||
if (path === 'bitlocker.usePin') fillForms();
|
||||
}
|
||||
function loadState(obj) { S = deepMerge(DEFAULTS(), obj); persist(); fillForms(); renderAll(); }
|
||||
|
||||
// ---------------------------------------------------------------- Navigation
|
||||
function showTab(t) {
|
||||
$$('#nav a').forEach(a => a.classList.toggle('active', a.dataset.tab === t));
|
||||
$$('.tab').forEach(s => s.classList.toggle('active', s.id === 'tab-' + t));
|
||||
({ shortcuts: loadShortcuts, bitlocker: loadBitlocker, profiles: loadProfiles, jobs: pollJobs, power: loadPower, office: loadOfficeBuilds })[t]?.();
|
||||
}
|
||||
$('#nav').onclick = e => { const a = e.target.closest('a'); if (a) showTab(a.dataset.tab); };
|
||||
|
||||
// ---------------------------------------------------------------- Übersicht
|
||||
async function loadStatus() {
|
||||
const s = await api('GET', '/api/status');
|
||||
const tpm = s.tpm ? (s.tpm.present ? (s.tpm.ready ? 'vorhanden, bereit' : 'vorhanden, nicht bereit') : 'nicht vorhanden') : 'unbekannt';
|
||||
const items = [['Computer', s.computerName], ['Hersteller / Modell', `${s.manufacturer} ${s.model}`], ['Seriennummer', s.serial],
|
||||
['Windows', `${s.os}<br><span class="muted small">${esc(s.build)}</span>`], ['Prozessor', s.cpu], ['RAM', s.ramGB + ' GB'],
|
||||
['Systemlaufwerk', `${s.diskFreeGB} GB frei von ${s.diskSizeGB} GB`], ['TPM', tpm], ['Domäne', s.partOfDomain ? s.domain : 'Arbeitsgruppe'],
|
||||
['winget', s.winget ? 'verfügbar' : '<span style="color:var(--err)">fehlt – wird automatisch installiert</span>'], ['Akku', s.battery || '–'], ['Agent-Pfad', s.root]];
|
||||
$('#sysinfo').innerHTML = items.map(([k, v]) => `<div class="stat"><div class="k">${k}</div><div class="v">${/<|&/.test(v) && k !== 'Seriennummer' ? v : esc(v)}</div></div>`).join('');
|
||||
}
|
||||
function renderSummary() {
|
||||
const o = S.office, prod = PRODUCTS.find(p => p.id === o.product);
|
||||
$('#summary').innerHTML = [
|
||||
`<b>${arr(S.apps).length}</b> Programme`, `Office: <b>${esc(prod?.name || o.product)}</b> (${o.arch}-Bit, ${esc(S.officeMode)})`,
|
||||
`<b>${arr(S.shortcuts).length}</b> Desktop-Icons`, `Energieplan: <b>${esc(S.power.plan || 'unverändert')}</b>`,
|
||||
S.system.computerName ? `Neuer Name: <b>${esc(S.system.computerName)}</b>` : ''
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
$('#steps').onchange = () => { S.steps = $$('#steps input:checked').map(i => i.value); persist(); };
|
||||
$('#btnRunAll').onclick = () => guard(async () => {
|
||||
if (!S.steps.length) return toast('Keine Schritte ausgewählt', true);
|
||||
if (S.steps.includes('bitlocker')) S.bitlocker.action = 'enable';
|
||||
if (!confirm(`Einrichtung mit ${S.steps.length} Schritten jetzt starten?`)) return;
|
||||
const r = await api('POST', '/api/run-profile', S); jobStarted(r.job);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Programme
|
||||
let catalog = [];
|
||||
async function loadCatalog() {
|
||||
catalog = arr((await api('GET', '/api/catalog'))?.apps);
|
||||
const groups = {};
|
||||
catalog.forEach(a => (groups[a.category] ??= []).push(a));
|
||||
$('#catalog').innerHTML = Object.entries(groups).map(([c, list]) => `<div class="cat"><h3>${esc(c)}</h3><div class="checks">${list.map(a =>
|
||||
`<label><input type="checkbox" data-app="${esc(a.id)}"> ${esc(a.name)}</label>`).join('')}</div></div>`).join('');
|
||||
syncCatalogChecks();
|
||||
const local = arr(await api('GET', '/api/local-installers'));
|
||||
$('#localFile').innerHTML = local.length ? local.map(f => `<option>${esc(f)}</option>`).join('') : '<option value="">(keine Dateien in installers\\)</option>';
|
||||
}
|
||||
$('#catalog').onchange = e => {
|
||||
const id = e.target.dataset.app; if (!id) return;
|
||||
const a = catalog.find(x => x.id === id);
|
||||
if (e.target.checked) addApp({ name: a.name, id: a.id, source: a.source, override: a.override });
|
||||
else removeApp(id);
|
||||
};
|
||||
function syncCatalogChecks() { $$('#catalog input').forEach(i => i.checked = S.apps.some(a => a.id === i.dataset.app)); }
|
||||
function addApp(a) { if (S.apps.some(x => x.id === a.id)) return; S.apps.push(a); persist(); renderSelectedApps(); }
|
||||
function removeApp(id) { S.apps = S.apps.filter(a => a.id !== id); persist(); renderSelectedApps(); }
|
||||
function renderSelectedApps() {
|
||||
$('#appCount').textContent = S.apps.length;
|
||||
$('#selectedApps').innerHTML = S.apps.length ? S.apps.map(a => `<div class="item"><div class="grow"><div>${esc(a.name)}</div>
|
||||
<div class="sub">${esc(a.source)} · ${esc(a.source === 'local' ? a.path : a.id)}</div></div>
|
||||
<button class="small ghost" data-rm="${esc(a.id)}">✕</button></div>`).join('') : '<p class="muted">Nichts ausgewählt.</p>';
|
||||
syncCatalogChecks();
|
||||
}
|
||||
$('#selectedApps').onclick = e => { const id = e.target.dataset.rm; if (id) removeApp(id); };
|
||||
async function doSearch() {
|
||||
const q = $('#appSearch').value.trim(); if (!q) return;
|
||||
$('#searchResults').innerHTML = '<p class="muted">Suche …</p>';
|
||||
await guard(async () => {
|
||||
const res = arr(await api('GET', `/api/winget/search?q=${encodeURIComponent(q)}&source=${$('#appSource').value}`));
|
||||
$('#searchResults').innerHTML = res.length ? res.map((r, i) => `<div class="item"><div class="grow"><div>${esc(r.name)}</div>
|
||||
<div class="sub">${esc(r.id)} · ${esc(r.version)}</div></div><button class="small" data-add="${i}">+ Hinzufügen</button></div>`).join('') : '<p class="muted">Keine Treffer.</p>';
|
||||
$('#searchResults').onclick = e => { const i = e.target.dataset.add; if (i != null) { addApp(res[i]); e.target.textContent = '✓'; } };
|
||||
});
|
||||
}
|
||||
$('#btnSearch').onclick = doSearch;
|
||||
$('#appSearch').onkeydown = e => { if (e.key === 'Enter') doSearch(); };
|
||||
$('#btnAddLocal').onclick = () => {
|
||||
const f = $('#localFile').value; if (!f) return;
|
||||
addApp({ name: f.split('\\').pop(), id: 'local:' + f, source: 'local', path: f, args: $('#localArgs').value });
|
||||
};
|
||||
$('#btnInstallApps').onclick = () => guard(async () => {
|
||||
if (!S.apps.length) return toast('Keine Programme ausgewählt', true);
|
||||
jobStarted((await api('POST', '/api/apps/install', { apps: S.apps })).job);
|
||||
});
|
||||
$('#btnUpgradeAll').onclick = $('#btnWingetUpg').onclick = () => guard(async () => jobStarted((await api('POST', '/api/apps/upgrade-all', {})).job));
|
||||
|
||||
// ---------------------------------------------------------------- Office
|
||||
const PRODUCTS = [
|
||||
{ id: 'O365ProPlusRetail', name: 'Microsoft 365 Apps for enterprise', channels: ['Current', 'MonthlyEnterprise', 'SemiAnnual', 'SemiAnnualPreview', 'BetaChannel'], extras: ['VisioProRetail', 'ProjectProRetail'] },
|
||||
{ id: 'O365BusinessRetail', name: 'Microsoft 365 Apps for business', channels: ['Current', 'MonthlyEnterprise', 'SemiAnnual'], extras: ['VisioProRetail', 'ProjectProRetail'] },
|
||||
{ id: 'ProPlus2024Volume', name: 'Office LTSC Professional Plus 2024 (Volumenlizenz)', channels: ['PerpetualVL2024'], extras: ['VisioPro2024Volume', 'ProjectPro2024Volume'] },
|
||||
{ id: 'Standard2024Volume', name: 'Office LTSC Standard 2024 (Volumenlizenz)', channels: ['PerpetualVL2024'], extras: ['VisioStd2024Volume', 'ProjectStd2024Volume'] },
|
||||
{ id: 'ProPlus2021Volume', name: 'Office LTSC Professional Plus 2021 (Volumenlizenz)', channels: ['PerpetualVL2021'], extras: ['VisioPro2021Volume', 'ProjectPro2021Volume'] },
|
||||
{ id: 'HomeBusiness2024Retail', name: 'Office Home & Business 2024', channels: ['Current'], extras: [] },
|
||||
{ id: 'Home2024Retail', name: 'Office Home 2024', channels: ['Current'], extras: [] }
|
||||
];
|
||||
const CHANNEL_NAMES = { Current: 'Aktueller Kanal', MonthlyEnterprise: 'Monatlicher Enterprise-Kanal', SemiAnnual: 'Halbjährlicher Enterprise-Kanal', SemiAnnualPreview: 'Halbjährlich (Vorschau)', BetaChannel: 'Beta', PerpetualVL2024: 'LTSC 2024', PerpetualVL2021: 'LTSC 2021' };
|
||||
const OFFICE_APPS = [['Word', 'Word'], ['Excel', 'Excel'], ['PowerPoint', 'PowerPoint'], ['Outlook', 'Outlook (klassisch)'], ['OutlookForWindows', 'Outlook (neu)'], ['OneNote', 'OneNote'],
|
||||
['Access', 'Access'], ['Publisher', 'Publisher'], ['Teams', 'Teams'], ['OneDrive', 'OneDrive'], ['Lync', 'Skype for Business'], ['Groove', 'OneDrive for Business (alt)'], ['Bing', 'Bing-Suche in Chrome']];
|
||||
|
||||
function renderOffice() {
|
||||
$('#officeProduct').innerHTML = PRODUCTS.map(p => `<option value="${p.id}">${esc(p.name)}</option>`).join('');
|
||||
$('#officeApps').innerHTML = OFFICE_APPS.map(([id, n]) => `<label><input type="checkbox" value="${id}"> ${n}</label>`).join('');
|
||||
$('#officeProduct').value = S.office.product;
|
||||
renderOfficeChannels(); renderOfficeExtras(); renderOfficeApps(); updateOfficeXml();
|
||||
}
|
||||
function renderOfficeChannels() {
|
||||
const p = PRODUCTS.find(x => x.id === S.office.product) || PRODUCTS[0];
|
||||
$('#officeChannel').innerHTML = p.channels.map(c => `<option value="${c}">${CHANNEL_NAMES[c] || c}</option>`).join('');
|
||||
if (!p.channels.includes(S.office.channel)) S.office.channel = p.channels[0];
|
||||
$('#officeChannel').value = S.office.channel;
|
||||
S.office.extraProducts = arr(S.office.extraProducts).filter(e => p.extras.includes(e));
|
||||
}
|
||||
function renderOfficeExtras() {
|
||||
const p = PRODUCTS.find(x => x.id === S.office.product) || PRODUCTS[0];
|
||||
const label = e => `${e.startsWith('Visio') ? 'Visio' : 'Project'} ${/Std/.test(e) ? 'Standard' : 'Professional'} ${(e.match(/\d{4}/) || [''])[0]}`;
|
||||
$('#officeExtras').innerHTML = p.extras.length ? p.extras.map(e => `<label><input type="checkbox" value="${e}" ${arr(S.office.extraProducts).includes(e) ? 'checked' : ''}> ${label(e)}</label>`).join('') : '<span class="muted small">keine für dieses Produkt</span>';
|
||||
}
|
||||
function renderOfficeApps() { $$('#officeApps input').forEach(i => i.checked = !arr(S.office.exclude).includes(i.value)); }
|
||||
$('#officeApps').onchange = () => { S.office.exclude = $$('#officeApps input:not(:checked)').map(i => i.value); persist(); updateOfficeXml(); };
|
||||
$('#officeExtras').onchange = () => { S.office.extraProducts = $$('#officeExtras input:checked').map(i => i.value); persist(); updateOfficeXml(); };
|
||||
let xmlTimer;
|
||||
function updateOfficeXml() {
|
||||
clearTimeout(xmlTimer);
|
||||
xmlTimer = setTimeout(() => guard(async () => { $('#officeXml').textContent = await api('POST', '/api/office/xml', S.office, true); }), 250);
|
||||
}
|
||||
$('#btnXmlDownload').onclick = () => download('configuration.xml', $('#officeXml').textContent, 'application/xml');
|
||||
$('#btnXmlCopy').onclick = () => navigator.clipboard.writeText($('#officeXml').textContent).then(() => toast('XML kopiert'));
|
||||
$('#btnOffice').onclick = () => guard(async () => {
|
||||
jobStarted((await api('POST', '/api/office/install', { office: S.office, mode: S.officeMode, name: S.name })).job);
|
||||
});
|
||||
async function loadOfficeBuilds() {
|
||||
const b = arr(await api('GET', '/api/office/configs').catch(() => []));
|
||||
$('#officeBuilds').innerHTML = b.length ? 'Vorhandene Builds auf dem Stick: ' + b.map(esc).join(', ') : 'Noch kein Offline-Build auf dem Stick.';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Desktop-Icons
|
||||
let presets = [];
|
||||
function scFromForm() {
|
||||
return { type: $('#scType').value, name: $('#scName').value.trim(), target: $('#scTarget').value.trim(), args: $('#scArgs').value, icon: $('#scIcon').value.trim() };
|
||||
}
|
||||
$('#scType').onchange = () => $$('.lnkonly').forEach(e => e.classList.toggle('hidden', $('#scType').value === 'url'));
|
||||
$('#btnScCreate').onclick = () => guard(async () => {
|
||||
const s = scFromForm(); if (!s.name || !s.target) return toast('Name und Ziel angeben', true);
|
||||
await api('POST', '/api/shortcuts', s); toast('Verknüpfung angelegt'); loadShortcuts();
|
||||
});
|
||||
$('#btnScPlan').onclick = () => {
|
||||
const s = scFromForm(); if (!s.name || !s.target) return toast('Name und Ziel angeben', true);
|
||||
S.shortcuts = arr(S.shortcuts).filter(x => x.name !== s.name).concat(s); persist(); renderPlanned();
|
||||
};
|
||||
function renderPlanned() {
|
||||
const l = arr(S.shortcuts);
|
||||
$('#scPlanned').innerHTML = l.length ? l.map((s, i) => `<div class="item"><div class="grow"><div>${esc(s.name)}</div><div class="sub">${esc(s.target)}</div></div>
|
||||
<button class="small ghost" data-i="${i}">✕</button></div>`).join('') : '<p class="muted">Keine geplant.</p>';
|
||||
}
|
||||
$('#scPlanned').onclick = e => { const i = e.target.dataset.i; if (i != null) { S.shortcuts.splice(i, 1); persist(); renderPlanned(); } };
|
||||
$('#btnScApplyAll').onclick = () => guard(async () => {
|
||||
for (const s of arr(S.shortcuts)) { try { await api('POST', '/api/shortcuts', s); } catch (e) { toast(`${s.name}: ${e.message}`, true); } }
|
||||
toast('Verknüpfungen angelegt'); loadShortcuts();
|
||||
});
|
||||
async function loadShortcuts() {
|
||||
const [cur, icons] = await Promise.all([api('GET', '/api/shortcuts'), api('GET', '/api/icons')]);
|
||||
$('#scCurrent').innerHTML = arr(cur).length ? arr(cur).map(s => `<div class="item"><div class="grow"><div>${esc(s.name)}</div><div class="sub">${esc(s.target)}</div></div>
|
||||
<button class="small danger" data-del="${esc(s.file)}">Löschen</button></div>`).join('') : '<p class="muted">Keine Verknüpfungen.</p>';
|
||||
$('#iconList').innerHTML = arr(icons).map(i => `<option value="${esc(i)}">`).join('');
|
||||
}
|
||||
$('#scCurrent').onclick = e => {
|
||||
const f = e.target.dataset.del; if (!f || !confirm(`"${f}" vom Desktop aller Benutzer löschen?`)) return;
|
||||
guard(async () => { await api('DELETE', '/api/shortcuts?file=' + encodeURIComponent(f)); loadShortcuts(); });
|
||||
};
|
||||
$('#btnScRefresh').onclick = loadShortcuts;
|
||||
$('#scPresets').onclick = e => {
|
||||
const p = presets[e.target.dataset.p]; if (!p) return;
|
||||
S.shortcuts = arr(S.shortcuts).filter(x => x.name !== p.name).concat({ ...p }); persist(); renderPlanned();
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- System / Sicherheit / Energie
|
||||
$$('[data-run]').forEach(b => b.onclick = () => guard(async () => {
|
||||
const k = b.dataset.run; jobStarted((await api('POST', '/api/' + k, { [k]: S[k] })).job);
|
||||
}));
|
||||
const ACTIONS = [['', 'unverändert'], ['nothing', 'Nichts unternehmen'], ['sleep', 'Energiesparmodus'], ['hibernate', 'Ruhezustand'], ['shutdown', 'Herunterfahren']];
|
||||
$$('select.act').forEach(s => s.innerHTML = ACTIONS.map(([v, n]) => `<option value="${v}">${n}</option>`).join(''));
|
||||
async function loadPower() { const p = await api('GET', '/api/power').catch(() => null); if (p) $('#powerState').textContent = 'Aktuell: ' + p.active; }
|
||||
|
||||
// ---------------------------------------------------------------- BitLocker
|
||||
async function loadBitlocker() {
|
||||
$('#blStatus').textContent = 'Lade …';
|
||||
await guard(async () => {
|
||||
const b = await api('GET', '/api/bitlocker');
|
||||
let h = `<p class="small">${esc(b.edition)} · TPM: <b>${b.tpmPresent ? (b.tpmReady ? 'bereit' : 'nicht bereit') : 'nicht vorhanden'}</b></p>`;
|
||||
if (!b.available) h += `<p style="color:var(--err)">${esc(b.message)}</p>`;
|
||||
h += arr(b.volumes).map(v => `<div class="vol"><b>${esc(v.mount)}</b> <span class="muted small">${esc(v.type)}</span><br>
|
||||
Status: ${esc(v.status)} · Schutz: <b style="color:${v.protection === 'On' ? 'var(--ok)' : 'var(--warn)'}">${esc(v.protection)}</b> · ${esc(v.method)}<br>
|
||||
<span class="small muted">Schutzvorrichtungen: ${esc(arr(v.protectors).join(', ') || '–')}</span>
|
||||
<div class="bar"><i style="width:${Number(v.percent) || 0}%"></i></div></div>`).join('');
|
||||
$('#blStatus').innerHTML = h;
|
||||
});
|
||||
}
|
||||
$('#btnBlRefresh').onclick = loadBitlocker;
|
||||
$$('[data-bl]').forEach(b => b.onclick = () => guard(async () => {
|
||||
const action = b.dataset.bl;
|
||||
if (action === 'enable' && S.bitlocker.usePin && !/^\d{6,20}$/.test(S.bitlocker.pin || '')) return toast('PIN muss 6–20 Ziffern haben', true);
|
||||
if (action === 'disable' && !confirm('BitLocker wirklich deaktivieren und das Laufwerk entschlüsseln?')) return;
|
||||
jobStarted((await api('POST', '/api/bitlocker', { bitlocker: { ...S.bitlocker, action } })).job);
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------- Updates
|
||||
let updates = [];
|
||||
$$('[data-scan]').forEach(b => b.onclick = () => guard(async () => {
|
||||
const type = b.dataset.scan;
|
||||
const r = await api('POST', '/api/updates/scan', { type }); jobStarted(r.job, true);
|
||||
$('#updList').innerHTML = '<p class="muted">Suche läuft … (kann einige Minuten dauern)</p>';
|
||||
const j = await waitJob(r.job);
|
||||
updates = arr(j.result);
|
||||
$('#updCount').textContent = updates.length;
|
||||
$('#updList').innerHTML = updates.length ? updates.map((u, i) => `<label class="item"><input type="checkbox" data-u="${i}" checked>
|
||||
<div class="grow"><div>${esc(u.title)}</div><div class="sub">${u.type === 'Driver' ? 'Treiber' : 'Windows'} · ${esc(u.class)} ${u.kb ? '· KB' + esc(u.kb) : ''} · ${u.sizeMB} MB</div></div></label>`).join('')
|
||||
: `<p class="muted">${j.state.startsWith('Fertig') ? 'Keine Updates gefunden – System ist aktuell.' : 'Suche fehlgeschlagen, siehe Aufgaben-Protokoll.'}</p>`;
|
||||
}));
|
||||
$('#btnUpdSel').onclick = () => guard(async () => {
|
||||
const ids = $$('#updList input:checked').map(i => updates[i.dataset.u].id);
|
||||
if (!ids.length) return toast('Nichts ausgewählt', true);
|
||||
jobStarted((await api('POST', '/api/updates/install', { ids })).job);
|
||||
});
|
||||
$('#btnUpdAll').onclick = () => guard(async () => jobStarted((await api('POST', '/api/updates/install', { all: true, type: 'all' })).job));
|
||||
$('#btnVendor').onclick = () => guard(async () => jobStarted((await api('POST', '/api/updates/vendor', {})).job));
|
||||
$('#btnWinget').onclick = () => guard(async () => jobStarted((await api('POST', '/api/winget/install', {})).job));
|
||||
$('#btnReboot').onclick = () => { if (confirm('Computer jetzt neu starten?')) guard(() => api('POST', '/api/reboot', {})); };
|
||||
|
||||
// ---------------------------------------------------------------- Profile
|
||||
async function loadProfiles() {
|
||||
const l = arr(await api('GET', '/api/profiles'));
|
||||
$('#profileList').innerHTML = l.length ? l.map(n => `<div class="item"><div class="grow">${esc(n)}</div>
|
||||
<button class="small primary" data-load="${esc(n)}">Laden</button><button class="small danger" data-pdel="${esc(n)}">✕</button></div>`).join('') : '<p class="muted">Keine Profile gespeichert.</p>';
|
||||
}
|
||||
$('#profileList').onclick = e => guard(async () => {
|
||||
const { load, pdel } = e.target.dataset;
|
||||
if (load) { loadState(await api('GET', '/api/profiles/' + encodeURIComponent(load))); toast(`Profil "${load}" geladen`); }
|
||||
if (pdel && confirm(`Profil "${pdel}" löschen?`)) { await api('DELETE', '/api/profiles/' + encodeURIComponent(pdel)); loadProfiles(); }
|
||||
});
|
||||
$('#btnProfileSave').onclick = () => guard(async () => {
|
||||
const n = $('#profileSaveName').value.trim().replace(/[^\w\-. ]/g, '_');
|
||||
if (!n) return toast('Namen angeben', true);
|
||||
S.name = n; persist();
|
||||
await api('POST', '/api/profiles/' + encodeURIComponent(n), JSON.stringify(S, null, 2));
|
||||
toast(`Profil "${n}" gespeichert`); fillForms(); loadProfiles();
|
||||
});
|
||||
$('#btnExport').onclick = () => download((S.name || 'profil') + '.json', JSON.stringify(S, null, 2), 'application/json');
|
||||
$('#importFile').onchange = async e => { const f = e.target.files[0]; if (f) guard(async () => { loadState(JSON.parse(await f.text())); toast('Profil importiert'); }); e.target.value = ''; };
|
||||
$('#btnReset').onclick = () => { if (confirm('Alle Einstellungen auf Standard zurücksetzen?')) loadState(DEFAULTS()); };
|
||||
|
||||
function download(name, text, type) {
|
||||
const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([text], { type })); a.download = name; a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Aufgaben
|
||||
let selJob = null, jobTimer = null, knownStates = {};
|
||||
const stateClass = s => s === 'Laeuft' ? 'run' : s === 'Fertig' ? 'ok' : s.includes('Fehler') ? 'warn' : /Fehlg|Abgeb/.test(s) ? 'err' : '';
|
||||
const stateName = s => s === 'Laeuft' ? 'Läuft' : s;
|
||||
function jobStarted(id, quiet) {
|
||||
if (!quiet) toast(`Aufgabe ${id} gestartet – zum Protokoll klicken`, false, () => { selJob = id; showTab('jobs'); });
|
||||
selJob ??= id; pollJobs();
|
||||
}
|
||||
async function pollJobs() {
|
||||
clearTimeout(jobTimer);
|
||||
try {
|
||||
const jobs = arr(await api('GET', '/api/jobs')).reverse();
|
||||
const active = jobs.filter(j => j.state === 'Laeuft' || j.state === 'Wartet').length;
|
||||
$('#jobBadge').textContent = active; $('#jobBadge').classList.toggle('hidden', !active);
|
||||
for (const j of jobs) {
|
||||
if (knownStates[j.id] && knownStates[j.id] !== j.state && !/Laeuft|Wartet/.test(j.state))
|
||||
toast(`${j.title}: ${j.state}`, /Fehl|Abgeb/.test(j.state), () => { selJob = j.id; showTab('jobs'); });
|
||||
knownStates[j.id] = j.state;
|
||||
}
|
||||
$('#jobList').innerHTML = jobs.length ? jobs.map(j => `<div class="item click ${j.id === selJob ? 'sel' : ''}" data-j="${j.id}">
|
||||
<div class="grow"><div>${esc(j.title)}</div><div class="sub">#${j.id} · ${j.created}</div></div><span class="state ${stateClass(j.state)}">${esc(stateName(j.state))}</span></div>`).join('')
|
||||
: '<p class="muted">Noch keine Aufgaben.</p>';
|
||||
if (selJob && $('#tab-jobs').classList.contains('active')) {
|
||||
const j = await api('GET', '/api/jobs/' + selJob);
|
||||
$('#jobTitle').textContent = `#${j.id} ${j.title} – ${stateName(j.state)}`;
|
||||
const pre = $('#jobLog'), atBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 30;
|
||||
pre.innerHTML = esc(j.log || '').replace(/^.*\[(ERROR|WARN|OK)\].*$/gm, (l, lv) => `<span class="${lv}">${l}</span>`);
|
||||
if (atBottom) pre.scrollTop = pre.scrollHeight;
|
||||
$('#btnJobStop').classList.toggle('hidden', !/Laeuft|Wartet/.test(j.state));
|
||||
}
|
||||
jobTimer = setTimeout(pollJobs, active ? 1500 : 5000);
|
||||
} catch { jobTimer = setTimeout(pollJobs, 5000); }
|
||||
}
|
||||
$('#jobList').onclick = e => { const it = e.target.closest('[data-j]'); if (it) { selJob = it.dataset.j; pollJobs(); } };
|
||||
$('#btnJobStop').onclick = () => { if (selJob && confirm('Aufgabe abbrechen?')) guard(async () => { await api('DELETE', '/api/jobs/' + selJob); pollJobs(); }); };
|
||||
async function waitJob(id) {
|
||||
for (; ;) {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
const j = await api('GET', '/api/jobs/' + id);
|
||||
if (!/Laeuft|Wartet/.test(j.state)) return j;
|
||||
}
|
||||
}
|
||||
|
||||
$('#btnShutdown').onclick = () => { if (confirm('Agent beenden? Laufende Aufgaben werden abgebrochen.')) api('POST', '/api/shutdown', {}).finally(() => { document.body.innerHTML = '<main><h1>Agent beendet.</h1><p>Das Fenster kann geschlossen werden.</p></main>'; }); };
|
||||
|
||||
// ---------------------------------------------------------------- Start
|
||||
function renderAll() { renderOffice(); renderSelectedApps(); renderPlanned(); renderSummary(); }
|
||||
(async () => {
|
||||
if (!TOKEN) { document.body.innerHTML = '<main><h1>Kein Zugriffstoken</h1><p>Bitte die vollständige URL (mit ?token=…) aus dem Agent-Fenster öffnen.</p></main>'; return; }
|
||||
bindForms(); renderAll(); fillForms();
|
||||
await guard(loadStatus);
|
||||
await guard(loadCatalog);
|
||||
await guard(async () => {
|
||||
const p = await api('GET', '/api/presets');
|
||||
presets = arr(p.shortcuts);
|
||||
$('#scPresets').innerHTML = presets.map((s, i) => `<button data-p="${i}">+ ${esc(s.name)}</button>`).join('');
|
||||
$('#tzSelect').insertAdjacentHTML('beforeend', arr(p.timezones).map(t => `<option value="${esc(t.id)}">${esc(t.name)}</option>`).join(''));
|
||||
fillForms();
|
||||
});
|
||||
if (location.hash) showTab(location.hash.slice(1));
|
||||
pollJobs();
|
||||
})();
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>KDS Install Agent</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='3' fill='%230a64d6'/><path d='M4 8l3 3 5-6' stroke='white' stroke-width='2' fill='none'/></svg>">
|
||||
</head>
|
||||
<body>
|
||||
<aside class="side">
|
||||
<div class="brand">KDS <span>Install Agent</span></div>
|
||||
<nav id="nav">
|
||||
<a data-tab="overview" class="active">Übersicht</a>
|
||||
<a data-tab="apps">Programme</a>
|
||||
<a data-tab="office">Office</a>
|
||||
<a data-tab="shortcuts">Desktop-Icons</a>
|
||||
<a data-tab="system">System & Sicherheit</a>
|
||||
<a data-tab="power">Energie</a>
|
||||
<a data-tab="bitlocker">BitLocker</a>
|
||||
<a data-tab="updates">Updates & Treiber</a>
|
||||
<a data-tab="profiles">Profile</a>
|
||||
<a data-tab="jobs">Aufgaben <b id="jobBadge" class="badge hidden">0</b></a>
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
<div id="profileName" class="muted">Profil: (ungespeichert)</div>
|
||||
<button class="ghost" id="btnShutdown">Agent beenden</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<!-- Übersicht -->
|
||||
<section id="tab-overview" class="tab active">
|
||||
<h1>Übersicht</h1>
|
||||
<div id="sysinfo" class="grid4"></div>
|
||||
<div class="card">
|
||||
<h2>Komplette Einrichtung mit einem Klick</h2>
|
||||
<p class="muted">Führt die Einstellungen aus allen Tabs in sinnvoller Reihenfolge aus. Vorher in den Tabs konfigurieren oder ein Profil laden.</p>
|
||||
<div class="checks" id="steps">
|
||||
<label><input type="checkbox" value="system" checked> System</label>
|
||||
<label><input type="checkbox" value="security" checked> Sicherheit</label>
|
||||
<label><input type="checkbox" value="power" checked> Energie</label>
|
||||
<label><input type="checkbox" value="apps" checked> Programme</label>
|
||||
<label><input type="checkbox" value="office" checked> Office</label>
|
||||
<label><input type="checkbox" value="shortcuts" checked> Desktop-Icons</label>
|
||||
<label><input type="checkbox" value="drivers"> Hersteller-Treiber</label>
|
||||
<label><input type="checkbox" value="updates" checked> Windows Update</label>
|
||||
<label><input type="checkbox" value="bitlocker"> BitLocker</label>
|
||||
</div>
|
||||
<div id="summary" class="summary"></div>
|
||||
<button class="primary big" id="btnRunAll">▶ Einrichtung starten</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Programme -->
|
||||
<section id="tab-apps" class="tab">
|
||||
<h1>Programme</h1>
|
||||
<div class="cols">
|
||||
<div>
|
||||
<div class="card">
|
||||
<h2>Suche in öffentlichen Bibliotheken</h2>
|
||||
<div class="row">
|
||||
<input id="appSearch" placeholder="z.B. firefox, acrobat, teamviewer …">
|
||||
<select id="appSource"><option value="winget">winget (Community-Repo)</option><option value="msstore">Microsoft Store</option></select>
|
||||
<button id="btnSearch">Suchen</button>
|
||||
</div>
|
||||
<div id="searchResults" class="list"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Schnellauswahl</h2>
|
||||
<div id="catalog"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Eigene Installer vom Stick <span class="muted small">(Ordner <code>installers\</code>)</span></h2>
|
||||
<div class="row">
|
||||
<select id="localFile"></select>
|
||||
<input id="localArgs" placeholder="Silent-Parameter, z.B. /S oder /quiet">
|
||||
<button id="btnAddLocal">Hinzufügen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="card sticky">
|
||||
<h2>Ausgewählt <span id="appCount" class="badge">0</span></h2>
|
||||
<div id="selectedApps" class="list"></div>
|
||||
<button class="primary" id="btnInstallApps">Jetzt installieren</button>
|
||||
<button id="btnUpgradeAll">Alle installierten Programme aktualisieren</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Office -->
|
||||
<section id="tab-office" class="tab">
|
||||
<h1>Microsoft Office</h1>
|
||||
<div class="cols">
|
||||
<div class="card">
|
||||
<h2>Konfiguration</h2>
|
||||
<label class="f">Produkt<select id="officeProduct" data-bind="office.product"></select></label>
|
||||
<label class="f">Update-Kanal<select id="officeChannel" data-bind="office.channel"></select></label>
|
||||
<label class="f">Architektur
|
||||
<select data-bind="office.arch"><option value="64">64-Bit (empfohlen)</option><option value="32">32-Bit</option></select></label>
|
||||
<label class="f">Lizenzschlüssel (nur Volumenlizenz/MAK)<input data-bind="office.pidKey" placeholder="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"></label>
|
||||
<label class="f">Firmenname<input data-bind="office.company" placeholder="optional"></label>
|
||||
<h3>Sprachen</h3>
|
||||
<div class="checks" data-bind-list="office.languages">
|
||||
<label><input type="checkbox" value="de-de"> Deutsch</label>
|
||||
<label><input type="checkbox" value="en-us"> Englisch</label>
|
||||
<label><input type="checkbox" value="fr-fr"> Französisch</label>
|
||||
<label><input type="checkbox" value="it-it"> Italienisch</label>
|
||||
<label><input type="checkbox" value="es-es"> Spanisch</label>
|
||||
<label><input type="checkbox" value="nl-nl"> Niederländisch</label>
|
||||
<label><input type="checkbox" value="pl-pl"> Polnisch</label>
|
||||
<label><input type="checkbox" value="tr-tr"> Türkisch</label>
|
||||
<label><input type="checkbox" value="MatchOS"> Wie Windows</label>
|
||||
</div>
|
||||
<h3>Apps <span class="muted small">(abgewählte werden nicht installiert)</span></h3>
|
||||
<div class="checks" id="officeApps"></div>
|
||||
<h3>Zusatzprodukte</h3>
|
||||
<div class="checks" id="officeExtras"></div>
|
||||
<h3>Optionen</h3>
|
||||
<div class="checks col">
|
||||
<label><input type="checkbox" data-bind="office.updates"> Automatische Updates</label>
|
||||
<label><input type="checkbox" data-bind="office.removeMsi"> Alte MSI-Office-Versionen entfernen</label>
|
||||
<label><input type="checkbox" data-bind="office.migrateArch"> Architektur einer vorhandenen Installation migrieren</label>
|
||||
<label><input type="checkbox" data-bind="office.sharedComputer"> Aktivierung für gemeinsam genutzte Computer (RDS)</label>
|
||||
<label><input type="checkbox" data-bind="office.disableVba"> Makros aus dem Internet blockieren</label>
|
||||
<label><input type="checkbox" data-bind="office.ooxmlDefault"> Erststart-Dialog unterdrücken</label>
|
||||
<label><input type="checkbox" data-bind="office.showUi"> Installationsfortschritt anzeigen</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="card">
|
||||
<h2>Generierte configuration.xml</h2>
|
||||
<pre id="officeXml" class="code"></pre>
|
||||
<div class="row">
|
||||
<button id="btnXmlDownload">XML herunterladen</button>
|
||||
<button id="btnXmlCopy">Kopieren</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Ausführen</h2>
|
||||
<label class="f">Modus
|
||||
<select data-bind="officeMode">
|
||||
<option value="online">Online installieren (direkt von Microsoft)</option>
|
||||
<option value="download">Nur Build erstellen (Dateien auf den Stick laden)</option>
|
||||
<option value="offline">Vom Stick installieren (Build wird bei Bedarf erstellt)</option>
|
||||
</select></label>
|
||||
<p class="muted small">setup.exe des Office Deployment Tools wird bei Bedarf automatisch von Microsoft geladen und unter <code>office\</code> abgelegt. Builds liegen in <code>office\source\</code>.</p>
|
||||
<div id="officeBuilds" class="muted small"></div>
|
||||
<button class="primary" id="btnOffice">Ausführen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Desktop-Icons -->
|
||||
<section id="tab-shortcuts" class="tab">
|
||||
<h1>Desktop-Icons für alle Benutzer</h1>
|
||||
<div class="cols">
|
||||
<div>
|
||||
<div class="card">
|
||||
<h2>Neue Verknüpfung</h2>
|
||||
<label class="f">Typ<select id="scType"><option value="url">Webseite (URL)</option><option value="lnk">Programm / Datei / Ordner</option></select></label>
|
||||
<label class="f">Name<input id="scName" placeholder="z.B. Intranet"></label>
|
||||
<label class="f">Ziel<input id="scTarget" placeholder="https://… oder %ProgramFiles%\…\app.exe"></label>
|
||||
<label class="f lnkonly">Argumente<input id="scArgs"></label>
|
||||
<label class="f">Icon <span class="muted small">(Ordner <code>icons\</code> auf dem Stick oder Pfad)</span>
|
||||
<input id="scIcon" list="iconList" placeholder="optional"></label>
|
||||
<datalist id="iconList"></datalist>
|
||||
<div class="row">
|
||||
<button class="primary" id="btnScCreate">Sofort anlegen</button>
|
||||
<button id="btnScPlan">Zum Profil hinzufügen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Vorlagen</h2>
|
||||
<div id="scPresets" class="chips"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="card">
|
||||
<h2>Im Profil geplant</h2>
|
||||
<div id="scPlanned" class="list"></div>
|
||||
<button class="primary" id="btnScApplyAll">Alle geplanten jetzt anlegen</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Aktuell auf dem öffentlichen Desktop <button class="small ghost" id="btnScRefresh">↻</button></h2>
|
||||
<div id="scCurrent" class="list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- System & Sicherheit -->
|
||||
<section id="tab-system" class="tab">
|
||||
<h1>System & Sicherheit</h1>
|
||||
<div class="cols">
|
||||
<div class="card">
|
||||
<h2>System</h2>
|
||||
<label class="f">Computername <span class="muted small">(leer = unverändert, max. 15 Zeichen)</span><input data-bind="system.computerName" maxlength="15"></label>
|
||||
<label class="f">Zeitzone<select id="tzSelect" data-bind="system.timeZone"><option value="">unverändert</option></select></label>
|
||||
<div class="checks col">
|
||||
<label><input type="checkbox" data-bind="system.showExtensions"> Dateiendungen anzeigen (auch für neue Benutzer)</label>
|
||||
<label><input type="checkbox" data-bind="system.disableConsumer"> Werbe-Apps / Consumer-Features deaktivieren</label>
|
||||
</div>
|
||||
<button class="primary" data-run="system">Systemeinstellungen anwenden</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Sicherheit</h2>
|
||||
<div class="checks col">
|
||||
<label><input type="checkbox" data-bind="security.defender"> Defender: Echtzeit-, Cloud- & PUA-Schutz</label>
|
||||
<label><input type="checkbox" data-bind="security.firewall"> Firewall für alle Profile aktivieren</label>
|
||||
<label><input type="checkbox" data-bind="security.smartScreen"> SmartScreen aktivieren</label>
|
||||
<label><input type="checkbox" data-bind="security.uac"> UAC auf höchste Stufe</label>
|
||||
<label><input type="checkbox" data-bind="security.disableSmb1"> SMBv1 deaktivieren</label>
|
||||
<label><input type="checkbox" data-bind="security.disableAutorun"> Autorun / Autoplay deaktivieren</label>
|
||||
<label><input type="checkbox" data-bind="security.lsaProtection"> LSA-Schutz (Credential-Diebstahl erschweren)</label>
|
||||
<label><input type="checkbox" data-bind="security.telemetryMin"> Telemetrie minimieren</label>
|
||||
</div>
|
||||
<label class="f">Remotedesktop
|
||||
<select data-bind="security.rdp"><option value="keep">unverändert</option><option value="enable">aktivieren (mit NLA)</option><option value="disable">deaktivieren</option></select></label>
|
||||
<h3>Lokales Admin-Konto <span class="muted small">(optional)</span></h3>
|
||||
<div class="row">
|
||||
<input data-bind="security.localAdminName" placeholder="Benutzername">
|
||||
<input data-bind="security.localAdminPassword" type="password" placeholder="Kennwort" autocomplete="new-password">
|
||||
</div>
|
||||
<p class="muted small">Hinweis: Das Kennwort wird beim Speichern im Profil im Klartext abgelegt.</p>
|
||||
<button class="primary" data-run="security">Sicherheitseinstellungen anwenden</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Energie -->
|
||||
<section id="tab-power" class="tab">
|
||||
<h1>Energie</h1>
|
||||
<div class="card">
|
||||
<div id="powerState" class="muted small"></div>
|
||||
<label class="f">Energieplan
|
||||
<select data-bind="power.plan"><option value="">unverändert</option><option value="balanced">Ausbalanciert</option><option value="high">Höchstleistung</option><option value="ultimate">Ultimative Leistung</option></select></label>
|
||||
<table class="ptable">
|
||||
<tr><th></th><th>Netzbetrieb 🔌</th><th>Akku 🔋</th></tr>
|
||||
<tr><td>Bildschirm aus nach (Min., 0 = nie)</td><td><input type="number" min="0" data-bind="power.monitorAc"></td><td><input type="number" min="0" data-bind="power.monitorDc"></td></tr>
|
||||
<tr><td>Energiesparmodus nach</td><td><input type="number" min="0" data-bind="power.sleepAc"></td><td><input type="number" min="0" data-bind="power.sleepDc"></td></tr>
|
||||
<tr><td>Ruhezustand nach</td><td><input type="number" min="0" data-bind="power.hibernateAc"></td><td><input type="number" min="0" data-bind="power.hibernateDc"></td></tr>
|
||||
<tr><td>Deckel schließen</td><td><select data-bind="power.lidAc" class="act"></select></td><td><select data-bind="power.lidDc" class="act"></select></td></tr>
|
||||
<tr><td>Netzschalter</td><td><select data-bind="power.buttonAc" class="act"></select></td><td><select data-bind="power.buttonDc" class="act"></select></td></tr>
|
||||
</table>
|
||||
<div class="row">
|
||||
<label class="f">Ruhezustand<select data-bind="power.hibernate"><option value="">unverändert</option><option value="on">ein</option><option value="off">aus</option></select></label>
|
||||
<label class="f">Schnellstart<select data-bind="power.fastStartup"><option value="">unverändert</option><option value="on">ein</option><option value="off">aus (empfohlen für Updates)</option></select></label>
|
||||
</div>
|
||||
<button class="primary" data-run="power">Energieeinstellungen anwenden</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- BitLocker -->
|
||||
<section id="tab-bitlocker" class="tab">
|
||||
<h1>BitLocker</h1>
|
||||
<div class="cols">
|
||||
<div class="card">
|
||||
<h2>Status <button class="small ghost" id="btnBlRefresh">↻</button></h2>
|
||||
<div id="blStatus">Lade …</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Aktivieren</h2>
|
||||
<label class="f">Verschlüsselung<select data-bind="bitlocker.method"><option value="XtsAes256">XTS-AES 256 (empfohlen)</option><option value="XtsAes128">XTS-AES 128</option></select></label>
|
||||
<div class="checks col">
|
||||
<label><input type="checkbox" data-bind="bitlocker.usedSpaceOnly"> Nur belegten Speicher verschlüsseln (schnell, für neue Geräte)</label>
|
||||
<label><input type="checkbox" data-bind="bitlocker.skipHardwareTest"> Hardwaretest überspringen (sofort starten)</label>
|
||||
<label><input type="checkbox" data-bind="bitlocker.usePin"> Start-PIN zusätzlich zum TPM</label>
|
||||
<label><input type="checkbox" data-bind="bitlocker.backupAad"> Schlüssel in Entra ID (Azure AD) sichern</label>
|
||||
<label><input type="checkbox" data-bind="bitlocker.backupAd"> Schlüssel im Active Directory sichern</label>
|
||||
</div>
|
||||
<label class="f pinonly">PIN (6–20 Ziffern)<input data-bind="bitlocker.pin" type="password" inputmode="numeric"></label>
|
||||
<p class="muted small">Der Wiederherstellungsschlüssel wird immer auf dem Stick unter <code>bitlocker-keys\</code> abgelegt (Computername + Seriennummer).</p>
|
||||
<div class="row">
|
||||
<button class="primary" data-bl="enable">BitLocker aktivieren</button>
|
||||
<button data-bl="savekey">Schlüssel erneut sichern</button>
|
||||
<button class="danger" data-bl="disable">Deaktivieren</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Updates -->
|
||||
<section id="tab-updates" class="tab">
|
||||
<h1>Updates & Treiber</h1>
|
||||
<div class="card">
|
||||
<div class="row wrap">
|
||||
<button class="primary" data-scan="Software">Windows-Updates suchen</button>
|
||||
<button class="primary" data-scan="Driver">Treiber-Updates suchen</button>
|
||||
<button data-scan="all">Alles suchen</button>
|
||||
<span class="sep"></span>
|
||||
<button id="btnVendor">Hersteller-Tool (Dell/Lenovo/HP)</button>
|
||||
<button id="btnWingetUpg">Programme aktualisieren (winget)</button>
|
||||
<button id="btnWinget">winget reparieren</button>
|
||||
<span class="sep"></span>
|
||||
<button class="danger" id="btnReboot">Neustart</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Gefundene Updates <span id="updCount" class="badge">0</span></h2>
|
||||
<div id="updList" class="list"><p class="muted">Noch keine Suche durchgeführt.</p></div>
|
||||
<div class="row">
|
||||
<button class="primary" id="btnUpdSel">Ausgewählte installieren</button>
|
||||
<button id="btnUpdAll">Alle installieren</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Profile -->
|
||||
<section id="tab-profiles" class="tab">
|
||||
<h1>Profile</h1>
|
||||
<div class="cols">
|
||||
<div class="card">
|
||||
<h2>Gespeicherte Profile <span class="muted small">(Ordner <code>profiles\</code>)</span></h2>
|
||||
<div id="profileList" class="list"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Aktuelle Konfiguration speichern</h2>
|
||||
<div class="row"><input id="profileSaveName" placeholder="z.B. Buero-Standard"><button class="primary" id="btnProfileSave">Speichern</button></div>
|
||||
<h3>Import / Export</h3>
|
||||
<div class="row">
|
||||
<button id="btnExport">Als JSON exportieren</button>
|
||||
<label class="button">Importieren<input type="file" id="importFile" accept=".json" hidden></label>
|
||||
<button class="ghost" id="btnReset">Zurücksetzen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Jobs -->
|
||||
<section id="tab-jobs" class="tab">
|
||||
<h1>Aufgaben</h1>
|
||||
<div class="cols jobs">
|
||||
<div class="card"><div id="jobList" class="list"></div></div>
|
||||
<div class="card">
|
||||
<h2 id="jobTitle">Protokoll</h2>
|
||||
<pre id="jobLog" class="code log"></pre>
|
||||
<div class="row"><button class="danger hidden" id="btnJobStop">Abbrechen</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="toast"></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,83 @@
|
||||
:root {
|
||||
--bg: #f3f5f8; --card: #fff; --text: #1b2330; --muted: #677284; --line: #e1e5eb;
|
||||
--accent: #0a64d6; --accent-2: #e8f1fd; --ok: #138a4b; --warn: #b26a00; --err: #c62828;
|
||||
--side: #121a27; --side-text: #c7d0dd;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { --bg: #0f141c; --card: #182030; --text: #e4e9f0; --muted: #8d98a8; --line: #2a3446; --accent: #4c95ff; --accent-2: #1d2c45; --side: #0a0e14; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body { display: flex; font: 14px/1.45 "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif; background: var(--bg); color: var(--text); }
|
||||
.side { width: 220px; flex: none; background: var(--side); color: var(--side-text); display: flex; flex-direction: column; }
|
||||
.brand { padding: 20px 18px; font-size: 18px; font-weight: 700; color: #fff; }
|
||||
.brand span { font-weight: 400; color: var(--side-text); }
|
||||
nav { flex: 1; overflow: auto; }
|
||||
nav a { display: flex; justify-content: space-between; align-items: center; padding: 9px 18px; cursor: pointer; border-left: 3px solid transparent; }
|
||||
nav a:hover { background: rgba(255,255,255,.05); }
|
||||
nav a.active { background: rgba(255,255,255,.08); border-left-color: var(--accent); color: #fff; }
|
||||
.side-foot { padding: 14px 18px; display: grid; gap: 8px; }
|
||||
main { flex: 1; overflow: auto; padding: 24px 28px 60px; }
|
||||
h1 { margin: 0 0 18px; font-size: 24px; font-weight: 600; }
|
||||
h2 { margin: 0 0 12px; font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
||||
h3 { margin: 16px 0 6px; font-size: 13px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .03em; }
|
||||
.tab { display: none; } .tab.active { display: block; }
|
||||
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 18px; margin-bottom: 16px; }
|
||||
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }
|
||||
.cols.jobs { grid-template-columns: 340px 1fr; }
|
||||
.grid4 { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||
.stat { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; }
|
||||
.stat .k { font-size: 12px; color: var(--muted); } .stat .v { font-weight: 600; word-break: break-word; }
|
||||
.sticky { position: sticky; top: 0; }
|
||||
.muted { color: var(--muted); } .small { font-size: 12px; }
|
||||
.row { display: flex; gap: 8px; align-items: end; margin-top: 10px; } .row.wrap { flex-wrap: wrap; } .row > input, .row > select { flex: 1; }
|
||||
.sep { width: 1px; align-self: stretch; background: var(--line); margin: 0 4px; }
|
||||
label.f { display: block; margin-bottom: 10px; font-size: 12px; color: var(--muted); }
|
||||
label.f input, label.f select { display: block; width: 100%; margin-top: 3px; }
|
||||
.row label.f { flex: 1; margin: 0; }
|
||||
input, select { font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 7px 9px; min-width: 0; }
|
||||
input[type=checkbox] { padding: 0; accent-color: var(--accent); width: 16px; height: 16px; }
|
||||
input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||
button, .button { font: inherit; cursor: pointer; border: 1px solid var(--line); background: var(--card); color: var(--text); padding: 7px 14px; border-radius: 6px; display: inline-block; }
|
||||
button:hover, .button:hover { border-color: var(--accent); }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
button.primary:hover { filter: brightness(1.1); }
|
||||
button.danger { color: var(--err); } button.danger:hover { border-color: var(--err); }
|
||||
button.ghost { background: transparent; color: inherit; }
|
||||
button.small { padding: 2px 8px; font-size: 12px; }
|
||||
button.big { font-size: 16px; padding: 12px 26px; margin-top: 14px; }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
.card > button { margin-top: 12px; margin-right: 6px; }
|
||||
.checks { display: flex; flex-wrap: wrap; gap: 6px 16px; } .checks.col { flex-direction: column; }
|
||||
.checks label { display: flex; gap: 7px; align-items: center; cursor: pointer; }
|
||||
.cat { margin-bottom: 12px; } .cat h3 { margin-top: 0; }
|
||||
.list { display: flex; flex-direction: column; max-height: 420px; overflow: auto; }
|
||||
.item { display: flex; align-items: center; gap: 10px; padding: 8px 4px; border-bottom: 1px solid var(--line); }
|
||||
.item:last-child { border-bottom: 0; }
|
||||
.item .grow { flex: 1; min-width: 0; } .item .sub { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.item.click { cursor: pointer; } .item.click:hover, .item.sel { background: var(--accent-2); }
|
||||
.badge { background: var(--accent); color: #fff; border-radius: 10px; padding: 0 7px; font-size: 11px; font-weight: 600; }
|
||||
.hidden { display: none !important; }
|
||||
.code { background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 12px; font: 12px/1.5 Consolas, "Cascadia Mono", monospace; white-space: pre-wrap; word-break: break-all; max-height: 460px; overflow: auto; margin: 0; }
|
||||
.log { min-height: 380px; max-height: calc(100vh - 220px); }
|
||||
.log .ERROR { color: var(--err); font-weight: 600; } .log .WARN { color: var(--warn); } .log .OK { color: var(--ok); }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chips button { border-radius: 16px; padding: 4px 12px; }
|
||||
.state { font-size: 11px; padding: 1px 8px; border-radius: 10px; background: var(--line); white-space: nowrap; }
|
||||
.state.run { background: var(--accent); color: #fff; } .state.ok { background: var(--ok); color: #fff; }
|
||||
.state.warn { background: var(--warn); color: #fff; } .state.err { background: var(--err); color: #fff; }
|
||||
.ptable { border-collapse: collapse; margin: 6px 0 14px; }
|
||||
.ptable td, .ptable th { padding: 5px 10px 5px 0; text-align: left; font-weight: 400; }
|
||||
.ptable th { color: var(--muted); font-size: 12px; } .ptable input, .ptable select { width: 150px; }
|
||||
.summary { margin-top: 12px; font-size: 13px; color: var(--muted); }
|
||||
.vol { padding: 10px; border: 1px solid var(--line); border-radius: 8px; margin-bottom: 8px; }
|
||||
.bar { height: 6px; background: var(--line); border-radius: 3px; overflow: hidden; margin-top: 6px; } .bar i { display: block; height: 100%; background: var(--ok); }
|
||||
code { font-family: Consolas, monospace; font-size: 12px; }
|
||||
#toast { position: fixed; right: 20px; bottom: 20px; display: flex; flex-direction: column; gap: 8px; z-index: 9; }
|
||||
#toast div { background: var(--side); color: #fff; padding: 10px 16px; border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,.25); cursor: pointer; max-width: 380px; }
|
||||
#toast div.err { background: var(--err); }
|
||||
@media (max-width: 900px) {
|
||||
body { flex-direction: column; } .side { width: auto; } nav { display: flex; overflow-x: auto; } nav a { white-space: nowrap; border-left: 0; border-bottom: 3px solid transparent; }
|
||||
nav a.active { border-bottom-color: var(--accent); } .side-foot { display: none; }
|
||||
main { padding: 16px; } .cols, .cols.jobs { grid-template-columns: 1fr; }
|
||||
}
|
||||
Reference in New Issue
Block a user