<#
.SYNOPSIS
    Sets system colors, wallpaper, and visual theme.

.DESCRIPTION
    Applies X9.cz visual identity: dark taskbar/Start with accent color #223B47
    (deep blue-gray), light app mode, no transparency. Wallpaper is set to a solid
    color matching the accent. BackInfo.exe (Step 07) overwrites the wallpaper with
    a live system info BMP on every logon - solid color is only the fallback.

.ITEMS
    system-tema-taskbar-start-dark: SystemUsesLightTheme=0 in Themes\Personalize. Dark mode for shell (taskbar, Start menu, Action Center, notification area). Does NOT affect application windows - those stay light. Reduces eye strain in dim environments.
    aplikacni-tema-light: AppsUseLightTheme=1. Application windows (File Explorer, Settings, Calculator, etc.) use white/light backgrounds. Majority of business applications (Office, browsers) also respect this and show light mode.
    accent-barva-223b47-tmave-modroseda: AccentColor DWORD = 0xFF473B22 (stored as ABGR: A=FF, B=47, G=3B, R=22). The deep blue-gray #223B47 is the X9.cz brand color, also used as the solid wallpaper background.
    accent-barva-na-start-a-taskbaru-ano: ColorPrevalence=1. Applies accent color to taskbar background and Start menu surface. The taskbar becomes the brand color instead of default black, creating a distinct recognizable look on X9.cz-deployed machines.
    pruhlednost-vypnuta: EnableTransparency=0. Disables Aero translucency on taskbar and Start. Improves text readability on the taskbar, reduces subtle GPU usage, and looks more professional/consistent on business machines.
    tapeta-jednobarevna-223b47-bez-obrazku: Wallpaper set to solid color #223B47 via SystemParametersInfo(SPI_SETDESKWALLPAPER). BackInfo.exe generates a BMP with hostname, username, OS, network info and sets it as wallpaper on every logon. Solid color = fallback only.
#>
param(
    [object]$Config,
    [string]$LogFile
)

$ErrorActionPreference = "Continue"

function Write-Log {
    param([string]$Message, [string]$Level = "INFO")
    $line = "[$(Get-Date -Format 'HH:mm:ss')] [$Level] $Message"
    $null = New-Item -ItemType Directory -Force -Path (Split-Path $LogFile -Parent) -ErrorAction SilentlyContinue
    Add-Content -Path $LogFile -Value $line -Encoding UTF8
}

# Accent color #223B47 stored as ABGR DWORD: 0xFF473B22
# A=FF B=47 G=3B R=22 -> 0xFF473B22 = 4283612962
$AccentColorABGR = 0xFF473B22

# Gradient colors (Windows generates these automatically but we set them explicitly)
# AccentPalette is 32 bytes - 8 shades of the accent color (BGRA each)
# We use the same color for all shades as a safe default
$AccentColorHex = "#223B47"

function Set-Reg {
    param([string]$Path, [string]$Name, $Value, [string]$Type = "DWord")
    try {
        if (-not (Test-Path $Path)) { New-Item -Path $Path -Force | Out-Null }
        Set-ItemProperty -Path $Path -Name $Name -Value $Value -Type $Type -Force
        Write-Log "  SET $Path\$Name = $Value" -Level OK
    }
    catch {
        Write-Log "  FAILED $Path\$Name - $_" -Level ERROR
    }
}

function Apply-ThemeSettings {
    param([string]$HiveRoot)  # "HKCU:" or "Registry::HKU\DefaultProfile"

    # -----------------------------------------------------------------------
    # System theme - Dark (taskbar, Start, action center)
    # -----------------------------------------------------------------------
    Set-Reg -Path "$HiveRoot\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" `
            -Name "SystemUsesLightTheme" -Value 0

    # -----------------------------------------------------------------------
    # App theme - Light
    # -----------------------------------------------------------------------
    Set-Reg -Path "$HiveRoot\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" `
            -Name "AppsUseLightTheme" -Value 1

    # -----------------------------------------------------------------------
    # Accent color on Start and taskbar
    # -----------------------------------------------------------------------
    Set-Reg -Path "$HiveRoot\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" `
            -Name "ColorPrevalence" -Value 1

    # -----------------------------------------------------------------------
    # Transparency effects - disabled
    # -----------------------------------------------------------------------
    Set-Reg -Path "$HiveRoot\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" `
            -Name "EnableTransparency" -Value 0

    # -----------------------------------------------------------------------
    # Accent color
    # -----------------------------------------------------------------------
    Set-Reg -Path "$HiveRoot\Software\Microsoft\Windows\DWM" `
            -Name "AccentColor" -Value $AccentColorABGR -Type "DWord"

    Set-Reg -Path "$HiveRoot\Software\Microsoft\Windows\DWM" `
            -Name "ColorizationColor" -Value $AccentColorABGR -Type "DWord"

    Set-Reg -Path "$HiveRoot\Software\Microsoft\Windows\DWM" `
            -Name "ColorizationAfterglow" -Value $AccentColorABGR -Type "DWord"

    # Accent color on title bars and borders
    Set-Reg -Path "$HiveRoot\Software\Microsoft\Windows\DWM" `
            -Name "ColorPrevalence" -Value 1

    # -----------------------------------------------------------------------
    # Accent color for taskbar
    # Windows taskbar reads AccentColorMenu from Explorer\Accent, NOT DWM\AccentColor.
    # Without this key the taskbar keeps the Windows default accent (light blue).
    # -----------------------------------------------------------------------
    Set-Reg -Path "$HiveRoot\Software\Microsoft\Windows\CurrentVersion\Explorer\Accent" `
            -Name "AccentColorMenu" -Value $AccentColorABGR -Type "DWord"

    Set-Reg -Path "$HiveRoot\Software\Microsoft\Windows\CurrentVersion\Explorer\Accent" `
            -Name "StartColorMenu" -Value $AccentColorABGR -Type "DWord"

    # -----------------------------------------------------------------------
    # Wallpaper - solid color #223B47 (fallback before DesktopInfo runs)
    # -----------------------------------------------------------------------
    # Background color as decimal RGB
    Set-Reg -Path "$HiveRoot\Control Panel\Colors" `
            -Name "Background" -Value "34 59 71" -Type "String"

    # Empty Wallpaper path = solid color from Background key above.
    # Without this, new users created from Default hive inherit a broken/missing
    # wallpaper path and Windows falls back to black desktop.
    Set-Reg -Path "$HiveRoot\Control Panel\Desktop" `
            -Name "Wallpaper" -Value "" -Type "String"

    Set-Reg -Path "$HiveRoot\Control Panel\Desktop" `
            -Name "WallpaperStyle" -Value "0" -Type "String"

    Set-Reg -Path "$HiveRoot\Control Panel\Desktop" `
            -Name "TileWallpaper" -Value "0" -Type "String"

    # -----------------------------------------------------------------------
    # Desktop icons - show This PC
    # -----------------------------------------------------------------------
    Set-Reg -Path "$HiveRoot\Software\Microsoft\Windows\CurrentVersion\Explorer\HideDesktopIcons\NewStartPanel" `
            -Name "{20D04FE0-3AEA-1069-A2D8-08002B30309D}" -Value 0
}

# -----------------------------------------------------------------------
# Load Default hive
# -----------------------------------------------------------------------
$hivePath = "C:\Users\Default\NTUSER.DAT"
$hiveKey  = "DefaultProfile"

Write-Log "Loading Default hive for personalization" -Level INFO

& reg unload "HKU\$hiveKey" 2>&1 | Out-Null
$loadResult = & reg load "HKU\$hiveKey" $hivePath 2>&1
if ($LASTEXITCODE -ne 0) {
    Write-Log "Failed to load Default hive: $loadResult" -Level ERROR
    Write-Log "Applying personalization to current user only" -Level WARN

    Write-Log "Applying theme to current user (HKCU)" -Level STEP
    Apply-ThemeSettings -HiveRoot "HKCU:"

    # Set wallpaper via SystemParametersInfo for current user
    Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public class WallpaperHelper {
    [DllImport("user32.dll", CharSet=CharSet.Auto)]
    public static extern int SystemParametersInfo(int uAction, int uParam, string lpvParam, int fuWinIni);
}
"@ -ErrorAction SilentlyContinue
    [WallpaperHelper]::SystemParametersInfo(20, 0, "", 3) | Out-Null
    exit 0
}

try {
    Write-Log "Applying theme to Default hive" -Level STEP
    Apply-ThemeSettings -HiveRoot "Registry::HKU\DefaultProfile"

    Write-Log "Applying theme to current user (HKCU)" -Level STEP
    Apply-ThemeSettings -HiveRoot "HKCU:"
}
finally {
    [GC]::Collect()
    [GC]::WaitForPendingFinalizers()
    Start-Sleep -Milliseconds 500

    $unloadResult = & reg unload "HKU\$hiveKey" 2>&1
    if ($LASTEXITCODE -eq 0) {
        Write-Log "Default hive unloaded" -Level OK
    } else {
        Write-Log "Failed to unload Default hive: $unloadResult" -Level ERROR
    }
}

# -----------------------------------------------------------------------
# Apply wallpaper (solid color) to current desktop session
# -----------------------------------------------------------------------
Write-Log "Setting desktop wallpaper to solid color" -Level INFO

try {
    Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public class WallpaperHelper {
    [DllImport("user32.dll", CharSet=CharSet.Auto)]
    public static extern int SystemParametersInfo(int uAction, int uParam, string lpvParam, int fuWinIni);
}
"@ -ErrorAction SilentlyContinue

    # SPI_SETDESKTOPWALLPAPER=20, SPIF_UPDATEINIFILE|SPIF_SENDCHANGE=3
    # Empty string = solid color defined in Control Panel\Colors\Background
    [WallpaperHelper]::SystemParametersInfo(20, 0, "", 3) | Out-Null
    Write-Log "  Desktop wallpaper updated" -Level OK
}
catch {
    Write-Log "  Failed to update wallpaper: $_" -Level WARN
}

Write-Log "Step 5 complete" -Level OK


;; This INI file should use the following format
;;
;; [General]
;; BackgroundColor = <COLORREF value>       ; The background color to use (default = 0 (black))
;; AutoBackground = [0 | 1]                 ; Use background color of current desktop (default = 0)
;; BackgroundBitmap = <path to BMP file>    ; Overrides AutoBackground and BackgroundColor values. Loads background bitmap from BMP file
;; XOffset = <horizontal offset in pixels>  ; Horizontal offest of the entire text block from the bitmap's center. Can be negative. Default = 0
;; YOffset = <vertical offset in pixels>    ; Vertical offest of the entire text block from the bitmap's center. Can be negative. Default = 0
;; Output = <file name>                     ; Name of output bitmap file (default = "", use popup message)
;; UpdateDesktop = [0 | 1]                  ; Update background desktop bitmap (default = 0)
;; ForceDesktopCenter = [0 | 1]             ; Force the desktop to display the bitmap as cenetered (instead of tiled / streched). Default = 1

;; LineSpacing = <value>                    ; Line spacing (default = 3)
;; SuppressErrors = [0 | 1]                 ; If 1, errors are NOT displayed (default = 0)
;;
;; [LineN]                                  ; Text settings for line N, where N between [1..20]
;; Type = [CompName | UserName | SysVer  |  ; Type of information to display on the line
;;         SysInfo  | NetInfo  | FileVer |
;;         RegValue | FreeText |
           UpdateTime | Unused]
;;                                          ; CompName - Computer name
;;                                          ; UserName - User name
;;                                          ; SysVer   - Operating system version
;;                                          ; SysInfo  - Hardware information
;;                                          ; NetInfo  - Network information
;;                                          ; FileVer  - Version of a file specified in 'FileName' option
;;                                          ; RegValue - Registry string value.
;;                                          ; Reg root from 'RegRoot' (e.g. HKLM)
;;                                          ; Reg path from 'RegPath' (e.g. SOFTWARE\Microsoft\Windows NT\CurrentVersion)
;;                                          ; Reg value from 'RegValue' (e.g. CurrentType)
;;                                          ; Reg title from 'RegTitle' (e.g. "The value of X is")
;;                                          ; FreeText - Text specified in 'Text' will be displayed as is
;;                                          ; UpdateTime - The date and time the bitmap was created
;;                                          ; Unused   - Line will not be displayed
;;
;; Font = <Face name>                       ; Font name (default = "Arial")
;; Size = <Font size>                       ; Font size (default = 22)
;; Color = <COLORREF value>                 ; Font color (default = WHITE)
;; Bold = [0 | 1]                           ; Font boldness (default = 0)
;; Italic = [0 | 1]                         ; Font italicness (default = 0)
;; Alignment = [Left | Right | Center]      ; Font alignment (default = Left)
;;
;; ShadowX = <X offset value>               ; Shadow X offset (positive only, 0 = No X shadow. Default = 0)
;; ShadowY = <Y offset value>               ; Shadow Y offset (positive only, 0 = No Y shadow. Default = 0)
;; ShadowColor = <COLORREF value>           ; Shadow Color (default = 0 (black))
;;
;; RegRoot = [HKLM | HKCU]                  ; Registry root to use for 'Type' = 'RegValue'
;; RegPath = <Registry path to read from>   ; Registry path to use for 'Type' = 'RegValue'
;; RegValue = <Registry value to read from> ; Registry value to use for 'Type' = 'RegValue'. Must be of type REG_SZ
;; RegTitle = <Display title of read value> ; Registry value to use for 'Type' = 'RegValue'
;;
;; Text = <free text to display>            ; Free text to display. Used if 'Type' = 'FreeText'
;;
;; FilePath = <full path to file>           ; Path to file to display version for. Used if 'Type' = 'FileVer'
;; FileName = <display name of file>        ; Display name of file specified in 'FilePath'. Used if 'Type' = 'FileVer'
;;

[General]
; #223B47 in COLORREF (0x00BBGGRR): R=34 G=59 B=71 = 71*65536 + 59*256 + 34 = 4668194
BackgroundColor = 4668194
; AutoBackground=0: do NOT read live desktop color - unreliable during deployment
; (live session may not yet reflect the registry background color change)
AutoBackground = 0
Output = %temp%\backinfo.bmp
UpdateDesktop = 1
LineSpacing = 2
ForceDesktopCenter = 1
SuppressErrors = 1

[Line1]
Font = Trebuchet MS
Size = 42
Color = 16777215
Bold = 1
Italic = 0
Alignment = Center
ShadowX = 2
ShadowY = 2
ShadowColor = 4210752
Type = CompName

[Line2]
Font = Trebuchet MS
Size = 20
Color = 10526880
Bold = 0
Italic = 0
Alignment = Center
ShadowX = 0
ShadowY = 0
ShadowColor = 4210752
Type = UserName

[Line3]
Font = Trebuchet MS
Size = 20
Color = 10526880
Bold = 1
Italic = 0
Alignment = Center
ShadowX = 0
ShadowY = 0
ShadowColor = 4210752
Type = RegValue
RegRoot = HKLM
RegPath = SOFTWARE\BackInfo
RegValue = OSName
RegTitle = OS:

[Line4]
Font = Trebuchet MS
Size = 20
Color = 10526880
Bold = 0
Italic = 0
Alignment = Center
ShadowX = 0
ShadowY = 0
ShadowColor = 4210752
Type = SysInfo

[Line5]
Font = Trebuchet MS
Size = 20
Color = 10526880
Bold = 0
Italic = 0
Alignment = Center
ShadowX = 0
ShadowY = 0
ShadowColor = 4210752
Type = NetInfo

;;
;; SAMPLE - how to display free text lines
;;
;  [Line6]
;  Font = Trebuchet MS
;  Size = 20
;  Color = 10526880
;  Bold = 0
;  Italic = 0
;  Alignment = Center
;  ShadowX = 0
;  ShadowY = 0
;  ShadowColor = 4210752
;  Type = FreeText
;  Text = System path is %windir%
;;;
;; SAMPLE - how to display file version
;;
;  [Line7]
;  Font = Trebuchet MS
;  Size = 20
;  Color = 10526880
;  Bold = 0
;  Italic = 0
;  Alignment = Center
;  ShadowX = 0
;  ShadowY = 0
;  ShadowColor = 4210752
;  Type = FileVer
;  FilePath = %ProgramFiles%\backinfo\backinfo.exe
;  FileName = backinfo.exe
;;
;; SAMPLE - how to display registry value
;;
;  [Line8]
;  Font = Trebuchet MS
;  Size = 20
;  Color = 10526880
;  Bold = 0
;  Italic = 0
;  Alignment = Center
;  ShadowX = 0
;  ShadowY = 0
;  ShadowColor = 4210752
;  Type = RegValue
;  RegRoot = HKLM
;  RegPath = SOFTWARE\Microsoft\Windows NT\CurrentVersion
;  RegValue = CurrentType
;  RegTitle = OS type


AskUserQuestion CronCreate CronDelete CronList EnterPlanMode EnterWorktree ExitPlanMode ExitWorktree Monitor NotebookEdit RemoteTrigger TaskCreate TaskGet TaskList TaskOutput TaskStop TaskUpdate WebFetch WebSearch


Bash

find /opt/xetup -type f -name "*.go" | sort

List all Go files in the project


/opt/xetup/cmd/xetup/main.go
/opt/xetup/embed.go
/opt/xetup/internal/config/config.go
/opt/xetup/internal/gui/gui.go
/opt/xetup/internal/runner/hidecmd_other.go
/opt/xetup/internal/runner/hidecmd_windows.go
/opt/xetup/internal/runner/runner.go


1	// Command xetup is the GUI launcher for Windows deployment.
2	//
3	// All PowerShell scripts and assets are embedded in the binary and extracted
4	// to a temp directory at runtime. The GUI collects configuration, streams
5	// live log output while the scripts run, and shows a final summary.
6	//
7	// Cross-compile for Windows (requires MinGW):
8	//
9	//	CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc \
10	//	  GOOS=windows GOARCH=amd64 \
11	//	  go build -ldflags="-s -w -H windowsgui" -o xetup.exe ./cmd/xetup
12	package main
13
14	import (
15		"fmt"
16		"log"
17		"os"
18		"path/filepath"
19
20		xetupembed "git.xetup.x9.cz/x9/xetup"
21		"git.xetup.x9.cz/x9/xetup/internal/config"
22		"git.xetup.x9.cz/x9/xetup/internal/gui"
23		"git.xetup.x9.cz/x9/xetup/internal/runner"
24	)
25
26	func main() {
27		// Load config (falls back to defaults when config.json is missing)
28		cfgPath := config.ConfigPath()
29		cfg, err := config.Load(cfgPath)
30		if err != nil {
31			fmt.Fprintf(os.Stderr, "Warning: config load failed (%v), using defaults\n", err)
32		}
33
34		// Temp working directory – cleaned up on exit
35		tmpDir, err := os.MkdirTemp("", "xetup-*")
36		if err != nil {
37			log.Fatalf("Cannot create temp dir: %v", err)
38		}
39		defer os.RemoveAll(tmpDir)
40
41		// Extract embedded scripts and assets
42		if err := runner.ExtractScripts(xetupembed.Scripts, tmpDir); err != nil {
43			log.Fatalf("Failed to extract scripts: %v", err)
44		}
45		if err := runner.ExtractAssets(xetupembed.Assets, tmpDir); err != nil {
46			log.Fatalf("Failed to extract assets: %v", err)
47		}
48
49		// Write runtime config JSON so PowerShell scripts can read it
50		cfgRuntimePath, err := runner.WriteConfig(cfg, tmpDir)
51		if err != nil {
52			log.Fatalf("Failed to write runtime config: %v", err)
53		}
54
55		runCfg := runner.RunConfig{
56			ScriptsDir:  filepath.Join(tmpDir, "scripts"),
57			ConfigPath:  cfgRuntimePath,
58			LogFile:     `C:\Windows\Setup\Scripts\Deploy.log`,
59			ProfileType: cfg.Deployment.ProfileType,
60		}
61
62		gui.Run(cfg, runCfg, cfgPath)
63	}
64

// Command xetup is the GUI launcher for Windows deployment.
//
// All PowerShell scripts and assets are embedded in the binary and extracted
// to a temp directory at runtime. The GUI collects configuration, streams
// live log output while the scripts run, and shows a final summary.
//
// Cross-compile for Windows (requires MinGW):
//
//	CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc \
//	  GOOS=windows GOARCH=amd64 \
//	  go build -ldflags="-s -w -H windowsgui" -o xetup.exe ./cmd/xetup
package main

import (
	"fmt"
	"log"
	"os"
	"path/filepath"

	xetupembed "git.xetup.x9.cz/x9/xetup"
	"git.xetup.x9.cz/x9/xetup/internal/config"
	"git.xetup.x9.cz/x9/xetup/internal/gui"
	"git.xetup.x9.cz/x9/xetup/internal/runner"
)

func main() {
	// Load config (falls back to defaults when config.json is missing)
	cfgPath := config.ConfigPath()
	cfg, err := config.Load(cfgPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "Warning: config load failed (%v), using defaults\n", err)
	}

	// Temp working directory – cleaned up on exit
	tmpDir, err := os.MkdirTemp("", "xetup-*")
	if err != nil {
		log.Fatalf("Cannot create temp dir: %v", err)
	}
	defer os.RemoveAll(tmpDir)

	// Extract embedded scripts and assets
	if err := runner.ExtractScripts(xetupembed.Scripts, tmpDir); err != nil {
		log.Fatalf("Failed to extract scripts: %v", err)
	}
	if err := runner.ExtractAssets(xetupembed.Assets, tmpDir); err != nil {
		log.Fatalf("Failed to extract assets: %v", err)
	}

	// Write runtime config JSON so PowerShell scripts can read it
	cfgRuntimePath, err := runner.WriteConfig(cfg, tmpDir)
	if err != nil {
		log.Fatalf("Failed to write runtime config: %v", err)
	}

	runCfg := runner.RunConfig{
		ScriptsDir:  filepath.Join(tmpDir, "scripts"),
		ConfigPath:  cfgRuntimePath,
		LogFile:     `C:\Windows\Setup\Scripts\Deploy.log`,
		ProfileType: cfg.Deployment.ProfileType,
	}

	gui.Run(cfg, runCfg, cfgPath)
}


1	// Package runner executes PowerShell deployment scripts and streams log output.
2	package runner
3
4	import (
5		"bufio"
6		"context"
7		"encoding/json"
8		"fmt"
9		"os"
10		"os/exec"
11		"path"
12		"path/filepath"
13		"strings"
14		"time"
15	)
16
17	// Step describes a single deployment step.
18	type Step struct {
19		ID         string // e.g. "adminAccount"
20		Num        string // display number e.g. "00"
21		Name       string
22		ScriptName string // e.g. "00-admin-account.ps1"
23		Enabled    bool
24	}
25
26	// AllSteps returns the ordered list of deployment steps.
27	func AllSteps() []Step {
28		return []Step{
29			{ID: "adminAccount",    Num: "00", Name: "Admin ucet",              ScriptName: "00-admin-account.ps1"},
30			{ID: "bloatware",       Num: "01", Name: "Bloatware removal",        ScriptName: "01-bloatware.ps1"},
31			{ID: "software",        Num: "02", Name: "Software (winget)",         ScriptName: "02-software.ps1"},
32			{ID: "systemRegistry",  Num: "03", Name: "System Registry (HKLM)",   ScriptName: "03-system-registry.ps1"},
33			{ID: "defaultProfile",  Num: "04", Name: "Default Profile",          ScriptName: "04-default-profile.ps1"},
34			{ID: "personalization", Num: "05", Name: "Personalizace",             ScriptName: "05-personalization.ps1"},
35			{ID: "scheduledTasks",  Num: "06", Name: "Scheduled Tasks",           ScriptName: "06-scheduled-tasks.ps1"},
36			{ID: "backinfo",        Num: "07", Name: "BackInfo",                  ScriptName: "07-backinfo.ps1"},
37			{ID: "activation",      Num: "08", Name: "Windows aktivace",          ScriptName: "08-activation.ps1"},
38			{ID: "pcIdentity",      Num: "09", Name: "PC identita",               ScriptName: "09-pc-identity.ps1"},
39			{ID: "network",         Num: "10", Name: "Network discovery",         ScriptName: "10-network.ps1"},
40			{ID: "dellUpdate",      Num: "11", Name: "Dell Command | Update",      ScriptName: "11-dell-update.ps1"},
41			{ID: "windowsUpdate",   Num: "12", Name: "Windows Update",            ScriptName: "12-windows-update.ps1"},
42		}
43	}
44
45	// Feature is a single toggleable sub-item within a deployment step.
46	type Feature struct {
47		ID    string
48		Label string
49	}
50
51	// StepFeatures returns per-step feature lists. Steps absent from this map
52	// have no sub-features and are controlled at the step level only.
53	func StepFeatures() map[string][]Feature {
54		return map[string][]Feature{
55			"software": {
56				{ID: "wingetInstalls", Label: "Instalace SW ze seznamu (winget)"},
57				{ID: "pdfDefault",     Label: "Adobe Reader jako vychozi PDF"},
58				{ID: "ateraAgent",     Label: "Atera RMM agent"},
59			},
60			"systemRegistry": {
61				{ID: "systemTweaks",      Label: "Windows tweaky (Widgets, GameDVR, Recall...)"},
62				{ID: "edgePolicies",      Label: "Edge policies (tlacitka, vyhledavac, telemetrie)"},
63				{ID: "oneDriveUninstall", Label: "OneDrive uninstall (consumer pre-install)"},
64				{ID: "powercfg",          Label: "Nastaveni napajeni (timeout AC/DC)"},
65				{ID: "proxyDisable",      Label: "Zakaz WPAD proxy auto-detect"},
66			},
67			"defaultProfile": {
68				{ID: "taskbarTweaks",   Label: "Taskbar – zarovnani, tlacitka, layout XML"},
69				{ID: "startMenuTweaks", Label: "Start menu – cisteni pinu, Bing, Copilot"},
70				{ID: "explorerTweaks",  Label: "Explorer – pripony, LaunchTo, ShowRecent"},
71			},
72			"dellUpdate": {
73				{ID: "drivers", Label: "Dell drivery + firmware"},
74				{ID: "bios",    Label: "Dell BIOS update"},
75			},
76		}
77	}
78
79	// SelectableItem is a single toggleable row in the TUI checklist.
80	// It represents either a whole step (FeatureID == "") or a specific feature.
81	type SelectableItem struct {
82		Key       string // "stepID" or "stepID.featureID"
83		StepID    string
84		FeatureID string // empty for step-level items
85		Label     string
86		Num       string
87	}
88
89	// AllSelectableItems returns the flat ordered list of all TUI toggle rows.
90	// Steps with features are expanded to individual feature rows.
91	// Steps without features appear as a single step-level row.
92	func AllSelectableItems() []SelectableItem {
93		steps    := AllSteps()
94		features := StepFeatures()
95		var items []SelectableItem
96		for _, s := range steps {
97			feats, hasFeatures := features[s.ID]
98			if !hasFeatures {
99				items = append(items, SelectableItem{
100					Key:    s.ID,
101					StepID: s.ID,
102					Label:  s.Num + " – " + s.Name,
103					Num:    s.Num,
104				})
105			} else {
106				for _, f := range feats {
107					items = append(items, SelectableItem{
108						Key:       s.ID + "." + f.ID,
109						StepID:    s.ID,
110						FeatureID: f.ID,
111						Label:     s.Num + " – " + f.Label,
112						Num:       s.Num,
113					})
114				}
115			}
116		}
117		return items
118	}
119
120	// RunConfig holds runtime parameters passed to each script.
121	type RunConfig struct {
122		ScriptsDir  string
123		ConfigPath  string
124		LogFile     string
125		ProfileType string
126	}
127
128	// Result is the outcome of a single step.
129	type Result struct {
130		Step    Step
131		Status  string // "OK", "ERROR", "SKIPPED"
132		Elapsed time.Duration
133	}
134
135	// LogLine is a single output line from a running script.
136	type LogLine struct {
137		StepID string
138		Text   string
139		Level  string // INFO, OK, ERROR, WARN, STEP - parsed from [LEVEL] prefix
140	}
141
142	// Runner executes deployment steps sequentially.
143	type Runner struct {
144		cfg         RunConfig
145		onLog       func(LogLine)
146		onStepStart func(Step)
147		onResult    func(Result)
148		cancel      context.CancelFunc
149	}
150
151	// New creates a Runner. onLog is called for each output line, onResult after each step.
152	// onStepStart (optional) is called immediately before a step's script is launched.
153	func New(cfg RunConfig, onLog func(LogLine), onStepStart func(Step), onResult func(Result)) *Runner {
154		return &Runner{cfg: cfg, onLog: onLog, onStepStart: onStepStart, onResult: onResult}
155	}
156
157	// Run executes enabled steps sequentially. Blocks until done or context cancelled.
158	func (r *Runner) Run(ctx context.Context, steps []Step) []Result {
159		ctx, cancel := context.WithCancel(ctx)
160		r.cancel = cancel
161		defer cancel()
162
163		// Write config JSON to temp file so scripts can read it
164		cfgArg := r.cfg.ConfigPath
165
166		var results []Result
167		for _, step := range steps {
168			if !step.Enabled {
169				res := Result{Step: step, Status: "SKIPPED"}
170				r.onResult(res)
171				results = append(results, res)
172				continue
173			}
174
175			if r.onStepStart != nil {
176				r.onStepStart(step)
177			}
178			start := time.Now()
179			err := r.runScript(ctx, step, cfgArg)
180			elapsed := time.Since(start)
181
182			status := "OK"
183			if err != nil {
184				if ctx.Err() != nil {
185					status = "CANCELLED"
186				} else {
187					status = "ERROR"
188				}
189			}
190
191			res := Result{Step: step, Status: status, Elapsed: elapsed}
192			r.onResult(res)
193			results = append(results, res)
194
195			if ctx.Err() != nil {
196				break
197			}
198		}
199		return results
200	}
201
202	// Stop cancels the running deployment.
203	func (r *Runner) Stop() {
204		if r.cancel != nil {
205			r.cancel()
206		}
207	}
208
209	func (r *Runner) runScript(ctx context.Context, step Step, cfgArg string) error {
210		scriptPath := filepath.Join(r.cfg.ScriptsDir, step.ScriptName)
211
212		// Build argument list
213		args := []string{
214			"-NonInteractive",
215			"-ExecutionPolicy", "Bypass",
216			"-File", scriptPath,
217			"-LogFile", r.cfg.LogFile,
218		}
219
220		// Pass config object as JSON string (script reads it inline)
221		if cfgArg != "" {
222			args = append(args, "-Config", fmt.Sprintf("(Get-Content '%s' | ConvertFrom-Json)", cfgArg))
223		}
224
225		// ProfileType for step 04
226		if step.ID == "defaultProfile" && r.cfg.ProfileType != "" {
227			args = append(args, "-ProfileType", r.cfg.ProfileType)
228		}
229
230		cmd := exec.CommandContext(ctx, "powershell.exe", args...)
231		hideWindow(cmd) // prevent PS console window from appearing over the GUI
232
233		stdout, err := cmd.StdoutPipe()
234		if err != nil {
235			return err
236		}
237		cmd.Stderr = cmd.Stdout // merge stderr into stdout
238
239		if err := cmd.Start(); err != nil {
240			return err
241		}
242
243		scanner := bufio.NewScanner(stdout)
244		for scanner.Scan() {
245			line := scanner.Text()
246			if skipPSNoiseLine(line) {
247				continue
248			}
249			r.onLog(LogLine{
250				StepID: step.ID,
251				Text:   line,
252				Level:  parseLevel(line),
253			})
254		}
255
256		return cmd.Wait()
257	}
258
259	// skipPSNoiseLine returns true for PowerShell stderr noise that clutters the log:
260	// multi-line error blocks (At line:N, CategoryInfo, FullyQualifiedErrorId, etc.),
261	// blank lines, and VERBOSE: prefix lines already handled by Write-Log.
262	func skipPSNoiseLine(line string) bool {
263		trimmed := strings.TrimSpace(line)
264		if trimmed == "" {
265			return true
266		}
267		for _, prefix := range []string{
268			"At line:",
269			"+ CategoryInfo",
270			"+ FullyQualifiedErrorId",
271			"+ PositionMessage",
272			"VERBOSE:",
273			"DEBUG:",
274		} {
275			if strings.HasPrefix(trimmed, prefix) {
276				return true
277			}
278		}
279		// PS error continuation lines start with spaces + "+" or "~"
280		if len(trimmed) > 0 && (trimmed[0] == '+' || strings.HasPrefix(trimmed, "~")) {
281			return true
282		}
283		return false
284	}
285
286	// parseLevel extracts the log level from lines formatted as "[HH:mm:ss] [LEVEL] message".
287	func parseLevel(line string) string {
288		if strings.Contains(line, "] [OK]") {
289			return "OK"
290		}
291		if strings.Contains(line, "] [ERROR]") {
292			return "ERROR"
293		}
294		if strings.Contains(line, "] [WARN]") {
295			return "WARN"
296		}
297		if strings.Contains(line, "] [STEP]") {
298			return "STEP"
299		}
300		return "INFO"
301	}
302
303	// ExtractScripts unpacks embedded scripts to a temp directory.
304	// Returns the directory path. Caller is responsible for cleanup.
305	func ExtractScripts(fs interface{ ReadDir(string) ([]os.DirEntry, error); ReadFile(string) ([]byte, error) }, tmpDir string) error {
306		entries, err := fs.ReadDir("scripts")
307		if err != nil {
308			return fmt.Errorf("read embedded scripts: %w", err)
309		}
310		scriptsDir := filepath.Join(tmpDir, "scripts")
311		if err := os.MkdirAll(scriptsDir, 0755); err != nil {
312			return err
313		}
314		for _, e := range entries {
315			if e.IsDir() {
316				continue
317			}
318			// embed.FS always uses forward slashes regardless of OS
319			data, err := fs.ReadFile(path.Join("scripts", e.Name()))
320			if err != nil {
321				return err
322			}
323			if err := os.WriteFile(filepath.Join(scriptsDir, e.Name()), data, 0644); err != nil {
324				return err
325			}
326		}
327		return nil
328	}
329
330	// ExtractAssets unpacks embedded assets to tmpDir/assets.
331	func ExtractAssets(fs interface{ ReadDir(string) ([]os.DirEntry, error); ReadFile(string) ([]byte, error) }, tmpDir string) error {
332		return extractDir(fs, "assets", tmpDir)
333	}
334
335	func extractDir(fs interface{ ReadDir(string) ([]os.DirEntry, error); ReadFile(string) ([]byte, error) }, src, dstBase string) error {
336		entries, err := fs.ReadDir(src)
337		if err != nil {
338			return err
339		}
340		dst := filepath.Join(dstBase, filepath.FromSlash(src))
341		if err := os.MkdirAll(dst, 0755); err != nil {
342			return err
343		}
344		for _, e := range entries {
345			// embed.FS always uses forward slashes regardless of OS
346			srcPath := path.Join(src, e.Name())
347			dstPath := filepath.Join(dstBase, filepath.FromSlash(srcPath))
348			if e.IsDir() {
349				if err := extractDir(fs, srcPath, dstBase); err != nil {
350					return err
351				}
352				continue
353			}
354			data, err := fs.ReadFile(srcPath)
355			if err != nil {
356				return err
357			}
358			if err := os.WriteFile(dstPath, data, 0644); err != nil {
359				return err
360			}
361		}
362		return nil
363	}
364
365	// WriteConfig serialises cfg to a temp JSON file and returns its path.
366	func WriteConfig(cfg interface{}, tmpDir string) (string, error) {
367		path := filepath.Join(tmpDir, "config-runtime.json")
368		data, err := json.MarshalIndent(cfg, "", "  ")
369		if err != nil {
370			return "", err
371		}
372		return path, os.WriteFile(path, data, 0644)
373	}
374

// Package runner executes PowerShell deployment scripts and streams log output.
package runner

import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"strings"
	"time"
)

// Step describes a single deployment step.
type Step struct {
	ID         string // e.g. "adminAccount"
	Num        string // display number e.g. "00"
	Name       string
	ScriptName string // e.g. "00-admin-account.ps1"
	Enabled    bool
}

// AllSteps returns the ordered list of deployment steps.
func AllSteps() []Step {
	return []Step{
		{ID: "adminAccount",    Num: "00", Name: "Admin ucet",              ScriptName: "00-admin-account.ps1"},
		{ID: "bloatware",       Num: "01", Name: "Bloatware removal",        ScriptName: "01-bloatware.ps1"},
		{ID: "software",        Num: "02", Name: "Software (winget)",         ScriptName: "02-software.ps1"},
		{ID: "systemRegistry",  Num: "03", Name: "System Registry (HKLM)",   ScriptName: "03-system-registry.ps1"},
		{ID: "defaultProfile",  Num: "04", Name: "Default Profile",          ScriptName: "04-default-profile.ps1"},
		{ID: "personalization", Num: "05", Name: "Personalizace",             ScriptName: "05-personalization.ps1"},
		{ID: "scheduledTasks",  Num: "06", Name: "Scheduled Tasks",           ScriptName: "06-scheduled-tasks.ps1"},
		{ID: "backinfo",        Num: "07", Name: "BackInfo",                  ScriptName: "07-backinfo.ps1"},
		{ID: "activation",      Num: "08", Name: "Windows aktivace",          ScriptName: "08-activation.ps1"},
		{ID: "pcIdentity",      Num: "09", Name: "PC identita",               ScriptName: "09-pc-identity.ps1"},
		{ID: "network",         Num: "10", Name: "Network discovery",         ScriptName: "10-network.ps1"},
		{ID: "dellUpdate",      Num: "11", Name: "Dell Command | Update",      ScriptName: "11-dell-update.ps1"},
		{ID: "windowsUpdate",   Num: "12", Name: "Windows Update",            ScriptName: "12-windows-update.ps1"},
	}
}

// Feature is a single toggleable sub-item within a deployment step.
type Feature struct {
	ID    string
	Label string
}

// StepFeatures returns per-step feature lists. Steps absent from this map
// have no sub-features and are controlled at the step level only.
func StepFeatures() map[string][]Feature {
	return map[string][]Feature{
		"software": {
			{ID: "wingetInstalls", Label: "Instalace SW ze seznamu (winget)"},
			{ID: "pdfDefault",     Label: "Adobe Reader jako vychozi PDF"},
			{ID: "ateraAgent",     Label: "Atera RMM agent"},
		},
		"systemRegistry": {
			{ID: "systemTweaks",      Label: "Windows tweaky (Widgets, GameDVR, Recall...)"},
			{ID: "edgePolicies",      Label: "Edge policies (tlacitka, vyhledavac, telemetrie)"},
			{ID: "oneDriveUninstall", Label: "OneDrive uninstall (consumer pre-install)"},
			{ID: "powercfg",          Label: "Nastaveni napajeni (timeout AC/DC)"},
			{ID: "proxyDisable",      Label: "Zakaz WPAD proxy auto-detect"},
		},
		"defaultProfile": {
			{ID: "taskbarTweaks",   Label: "Taskbar – zarovnani, tlacitka, layout XML"},
			{ID: "startMenuTweaks", Label: "Start menu – cisteni pinu, Bing, Copilot"},
			{ID: "explorerTweaks",  Label: "Explorer – pripony, LaunchTo, ShowRecent"},
		},
		"dellUpdate": {
			{ID: "drivers", Label: "Dell drivery + firmware"},
			{ID: "bios",    Label: "Dell BIOS update"},
		},
	}
}

// SelectableItem is a single toggleable row in the TUI checklist.
// It represents either a whole step (FeatureID == "") or a specific feature.
type SelectableItem struct {
	Key       string // "stepID" or "stepID.featureID"
	StepID    string
	FeatureID string // empty for step-level items
	Label     string
	Num       string
}

// AllSelectableItems returns the flat ordered list of all TUI toggle rows.
// Steps with features are expanded to individual feature rows.
// Steps without features appear as a single step-level row.
func AllSelectableItems() []SelectableItem {
	steps    := AllSteps()
	features := StepFeatures()
	var items []SelectableItem
	for _, s := range steps {
		feats, hasFeatures := features[s.ID]
		if !hasFeatures {
			items = append(items, SelectableItem{
				Key:    s.ID,
				StepID: s.ID,
				Label:  s.Num + " – " + s.Name,
				Num:    s.Num,
			})
		} else {
			for _, f := range feats {
				items = append(items, SelectableItem{
					Key:       s.ID + "." + f.ID,
					StepID:    s.ID,
					FeatureID: f.ID,
					Label:     s.Num + " – " + f.Label,
					Num:       s.Num,
				})
			}
		}
	}
	return items
}

// RunConfig holds runtime parameters passed to each script.
type RunConfig struct {
	ScriptsDir  string
	ConfigPath  string
	LogFile     string
	ProfileType string
}

// Result is the outcome of a single step.
type Result struct {
	Step    Step
	Status  string // "OK", "ERROR", "SKIPPED"
	Elapsed time.Duration
}

// LogLine is a single output line from a running script.
type LogLine struct {
	StepID string
	Text   string
	Level  string // INFO, OK, ERROR, WARN, STEP - parsed from [LEVEL] prefix
}

// Runner executes deployment steps sequentially.
type Runner struct {
	cfg         RunConfig
	onLog       func(LogLine)
	onStepStart func(Step)
	onResult    func(Result)
	cancel      context.CancelFunc
}

// New creates a Runner. onLog is called for each output line, onResult after each step.
// onStepStart (optional) is called immediately before a step's script is launched.
func New(cfg RunConfig, onLog func(LogLine), onStepStart func(Step), onResult func(Result)) *Runner {
	return &Runner{cfg: cfg, onLog: onLog, onStepStart: onStepStart, onResult: onResult}
}

// Run executes enabled steps sequentially. Blocks until done or context cancelled.
func (r *Runner) Run(ctx context.Context, steps []Step) []Result {
	ctx, cancel := context.WithCancel(ctx)
	r.cancel = cancel
	defer cancel()

	// Write config JSON to temp file so scripts can read it
	cfgArg := r.cfg.ConfigPath

	var results []Result
	for _, step := range steps {
		if !step.Enabled {
			res := Result{Step: step, Status: "SKIPPED"}
			r.onResult(res)
			results = append(results, res)
			continue
		}

		if r.onStepStart != nil {
			r.onStepStart(step)
		}
		start := time.Now()
		err := r.runScript(ctx, step, cfgArg)
		elapsed := time.Since(start)

		status := "OK"
		if err != nil {
			if ctx.Err() != nil {
				status = "CANCELLED"
			} else {
				status = "ERROR"
			}
		}

		res := Result{Step: step, Status: status, Elapsed: elapsed}
		r.onResult(res)
		results = append(results, res)

		if ctx.Err() != nil {
			break
		}
	}
	return results
}

// Stop cancels the running deployment.
func (r *Runner) Stop() {
	if r.cancel != nil {
		r.cancel()
	}
}

func (r *Runner) runScript(ctx context.Context, step Step, cfgArg string) error {
	scriptPath := filepath.Join(r.cfg.ScriptsDir, step.ScriptName)

	// Build argument list
	args := []string{
		"-NonInteractive",
		"-ExecutionPolicy", "Bypass",
		"-File", scriptPath,
		"-LogFile", r.cfg.LogFile,
	}

	// Pass config object as JSON string (script reads it inline)
	if cfgArg != "" {
		args = append(args, "-Config", fmt.Sprintf("(Get-Content '%s' | ConvertFrom-Json)", cfgArg))
	}

	// ProfileType for step 04
	if step.ID == "defaultProfile" && r.cfg.ProfileType != "" {
		args = append(args, "-ProfileType", r.cfg.ProfileType)
	}

	cmd := exec.CommandContext(ctx, "powershell.exe", args...)
	hideWindow(cmd) // prevent PS console window from appearing over the GUI

	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return err
	}
	cmd.Stderr = cmd.Stdout // merge stderr into stdout

	if err := cmd.Start(); err != nil {
		return err
	}

	scanner := bufio.NewScanner(stdout)
	for scanner.Scan() {
		line := scanner.Text()
		if skipPSNoiseLine(line) {
			continue
		}
		r.onLog(LogLine{
			StepID: step.ID,
			Text:   line,
			Level:  parseLevel(line),
		})
	}

	return cmd.Wait()
}

// skipPSNoiseLine returns true for PowerShell stderr noise that clutters the log:
// multi-line error blocks (At line:N, CategoryInfo, FullyQualifiedErrorId, etc.),
// blank lines, and VERBOSE: prefix lines already handled by Write-Log.
func skipPSNoiseLine(line string) bool {
	trimmed := strings.TrimSpace(line)
	if trimmed == "" {
		return true
	}
	for _, prefix := range []string{
		"At line:",
		"+ CategoryInfo",
		"+ FullyQualifiedErrorId",
		"+ PositionMessage",
		"VERBOSE:",
		"DEBUG:",
	} {
		if strings.HasPrefix(trimmed, prefix) {
			return true
		}
	}
	// PS error continuation lines start with spaces + "+" or "~"
	if len(trimmed) > 0 && (trimmed[0] == '+' || strings.HasPrefix(trimmed, "~")) {
		return true
	}
	return false
}

// parseLevel extracts the log level from lines formatted as "[HH:mm:ss] [LEVEL] message".
func parseLevel(line string) string {
	if strings.Contains(line, "] [OK]") {
		return "OK"
	}
	if strings.Contains(line, "] [ERROR]") {
		return "ERROR"
	}
	if strings.Contains(line, "] [WARN]") {
		return "WARN"
	}
	if strings.Contains(line, "] [STEP]") {
		return "STEP"
	}
	return "INFO"
}

// ExtractScripts unpacks embedded scripts to a temp directory.
// Returns the directory path. Caller is responsible for cleanup.
func ExtractScripts(fs interface{ ReadDir(string) ([]os.DirEntry, error); ReadFile(string) ([]byte, error) }, tmpDir string) error {
	entries, err := fs.ReadDir("scripts")
	if err != nil {
		return fmt.Errorf("read embedded scripts: %w", err)
	}
	scriptsDir := filepath.Join(tmpDir, "scripts")
	if err := os.MkdirAll(scriptsDir, 0755); err != nil {
		return err
	}
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		// embed.FS always uses forward slashes regardless of OS
		data, err := fs.ReadFile(path.Join("scripts", e.Name()))
		if err != nil {
			return err
		}
		if err := os.WriteFile(filepath.Join(scriptsDir, e.Name()), data, 0644); err != nil {
			return err
		}
	}
	return nil
}

// ExtractAssets unpacks embedded assets to tmpDir/assets.
func ExtractAssets(fs interface{ ReadDir(string) ([]os.DirEntry, error); ReadFile(string) ([]byte, error) }, tmpDir string) error {
	return extractDir(fs, "assets", tmpDir)
}

func extractDir(fs interface{ ReadDir(string) ([]os.DirEntry, error); ReadFile(string) ([]byte, error) }, src, dstBase string) error {
	entries, err := fs.ReadDir(src)
	if err != nil {
		return err
	}
	dst := filepath.Join(dstBase, filepath.FromSlash(src))
	if err := os.MkdirAll(dst, 0755); err != nil {
		return err
	}
	for _, e := range entries {
		// embed.FS always uses forward slashes regardless of OS
		srcPath := path.Join(src, e.Name())
		dstPath := filepath.Join(dstBase, filepath.FromSlash(srcPath))
		if e.IsDir() {
			if err := extractDir(fs, srcPath, dstBase); err != nil {
				return err
			}
			continue
		}
		data, err := fs.ReadFile(srcPath)
		if err != nil {
			return err
		}
		if err := os.WriteFile(dstPath, data, 0644); err != nil {
			return err
		}
	}
	return nil
}

// WriteConfig serialises cfg to a temp JSON file and returns its path.
func WriteConfig(cfg interface{}, tmpDir string) (string, error) {
	path := filepath.Join(tmpDir, "config-runtime.json")
	data, err := json.MarshalIndent(cfg, "", "  ")
	if err != nil {
		return "", err
	}
	return path, os.WriteFile(path, data, 0644)
}


1	//go:build windows
2
3	// Package gui implements the Walk-based graphical interface for xetup.
4	//
5	// Three sequential phases, each with its own window:
6	//  1. Config form  – PC name, product key, profile, step checkboxes,
7	//                    load/save config buttons for per-client presets
8	//  2. Live run     – real-time log streamed from PowerShell scripts
9	//  3. Summary      – per-step OK / ERROR / SKIPPED with elapsed time
10	package gui
11
12	import (
13		"context"
14		"encoding/json"
15		"fmt"
16		"os"
17		"os/exec"
18		"strings"
19		"sync"
20		"time"
21
22		"github.com/lxn/walk"
23		. "github.com/lxn/walk/declarative"
24
25		"git.xetup.x9.cz/x9/xetup/internal/config"
26		"git.xetup.x9.cz/x9/xetup/internal/runner"
27	)
28
29	// Run opens the xetup window and blocks until the user closes it.
30	func Run(cfg config.Config, runCfg runner.RunConfig, cfgPath string) {
31		for {
32			res := formPhase(cfg, runCfg, cfgPath)
33			switch res.action {
34			case "quit":
35				return
36			case "reload":
37				cfg = res.cfg
38				cfgPath = res.cfgPath
39			case "run":
40				runCfg.ProfileType = res.cfg.Deployment.ProfileType
41				results := runPhase(runCfg, res.steps)
42				donePhase(results)
43				return
44			}
45		}
46	}
47
48	// --------------------------------------------------------------------------
49	// Phase 1 – Config form
50	// --------------------------------------------------------------------------
51
52	type formResult struct {
53		action  string // "run", "reload", "quit"
54		cfg     config.Config
55		cfgPath string
56		steps   []runner.Step
57	}
58
59	func formPhase(cfg config.Config, runCfg runner.RunConfig, cfgPath string) formResult {
60		var mw *walk.MainWindow
61		result := formResult{action: "quit", cfgPath: cfgPath}
62
63		var (
64			pcNameLE     *walk.LineEdit
65			pcDescLE     *walk.LineEdit
66			productKeyLE *walk.LineEdit
67			profileCB    *walk.ComboBox
68		)
69
70		items := runner.AllSelectableItems()
71		checkPtrs := make([]*walk.CheckBox, len(items))
72		checkWidgets := make([]Widget, len(items))
73		for i, item := range items {
74			i := i // capture for closure
75			checkWidgets[i] = CheckBox{
76				AssignTo: &checkPtrs[i],
77				Text:     item.Label,
78				Checked:  itemEnabled(cfg, item),
79			}
80		}
81
82		collectCfg := func() config.Config {
83			out := cfg
84			out.Deployment.PCName = pcNameLE.Text()
85			out.Deployment.PCDescription = pcDescLE.Text()
86			out.Activation.ProductKey = productKeyLE.Text()
87			profiles := []string{"default", "admin", "user"}
88			if idx := profileCB.CurrentIndex(); idx >= 0 && idx < len(profiles) {
89				out.Deployment.ProfileType = profiles[idx]
90			}
91			selected := make(map[string]bool, len(items))
92			for i, item := range items {
93				selected[item.Key] = checkPtrs[i].Checked()
94			}
95			_, features := buildStepsAndFeatures(selected)
96			out.Features = features
97			return out
98		}
99
100		if err := (MainWindow{
101			AssignTo: &mw,
102			Title:    "xetup \u2014 Windows deployment",
103			Size:     Size{Width: 760, Height: 740},
104			Layout:   VBox{},
105			Children: []Widget{
106				// ── Form fields ──────────────────────────────────────────────────
107				Composite{
108					Layout: Grid{Columns: 2},
109					Children: []Widget{
110						Label{Text: "PC jmeno:"},
111						LineEdit{
112							AssignTo:  &pcNameLE,
113							Text:      cfg.Deployment.PCName,
114							CueBanner: "napr. NB-KLIENT-01  (prazdne = neprejmenovat)",
115						},
116						Label{Text: "Popis PC:"},
117						LineEdit{
118							AssignTo:  &pcDescLE,
119							Text:      cfg.Deployment.PCDescription,
120							CueBanner: "napr. PC recepce",
121						},
122						Label{Text: "Product Key:"},
123						LineEdit{
124							AssignTo:  &productKeyLE,
125							Text:      cfg.Activation.ProductKey,
126							CueBanner: "prazdne = OA3 / GVLK fallback",
127						},
128						Label{Text: "Profil:"},
129						ComboBox{
130							AssignTo:     &profileCB,
131							Model:        []string{"default", "admin", "user"},
132							CurrentIndex: profileIndex(cfg.Deployment.ProfileType),
133						},
134					},
135				},
136				HSeparator{},
137				// ── Step checkboxes ──────────────────────────────────────────────
138				Label{Text: "Kroky a nastaveni (odskrtnete co nechcete spustit):"},
139				ScrollView{
140					MinSize:  Size{Height: 400},
141					Layout:   VBox{MarginsZero: true},
142					Children: checkWidgets,
143				},
144				HSeparator{},
145				// ── Buttons ──────────────────────────────────────────────────────
146				Composite{
147					Layout: HBox{MarginsZero: true},
148					Children: []Widget{
149						PushButton{
150							Text: "Nacist config...",
151							OnClicked: func() {
152								dlg := new(walk.FileDialog)
153								dlg.Filter = "JSON soubory (*.json)|*.json|Vsechny soubory (*.*)|*.*"
154								dlg.Title = "Nacist konfiguraci"
155								ok, err := dlg.ShowOpen(mw)
156								if err != nil || !ok {
157									return
158								}
159								data, err := os.ReadFile(dlg.FilePath)
160								if err != nil {
161									walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
162									return
163								}
164								newCfg := config.DefaultConfig()
165								if err := json.Unmarshal(data, &newCfg); err != nil {
166									walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
167									return
168								}
169								result = formResult{
170									action:  "reload",
171									cfg:     newCfg,
172									cfgPath: dlg.FilePath,
173								}
174								mw.Close()
175							},
176						},
177						PushButton{
178							Text: "Ulozit config...",
179							OnClicked: func() {
180								dlg := new(walk.FileDialog)
181								dlg.Filter = "JSON soubory (*.json)|*.json|Vsechny soubory (*.*)|*.*"
182								dlg.Title = "Ulozit konfiguraci"
183								dlg.FilePath = "config.json"
184								ok, err := dlg.ShowSave(mw)
185								if err != nil || !ok {
186									return
187								}
188								data, err := json.MarshalIndent(collectCfg(), "", "  ")
189								if err != nil {
190									walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
191									return
192								}
193								if err := os.WriteFile(dlg.FilePath, data, 0644); err != nil {
194									walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
195								}
196							},
197						},
198						HSpacer{},
199						PushButton{
200							Text: "  SPUSTIT  ",
201							OnClicked: func() {
202								finalCfg := collectCfg()
203								selected := make(map[string]bool, len(items))
204								for i, item := range items {
205									selected[item.Key] = checkPtrs[i].Checked()
206								}
207								steps, features := buildStepsAndFeatures(selected)
208								finalCfg.Features = features
209								_ = config.Save(finalCfg, cfgPath)
210								result = formResult{
211									action:  "run",
212									cfg:     finalCfg,
213									cfgPath: cfgPath,
214									steps:   steps,
215								}
216								mw.Close()
217							},
218						},
219					},
220				},
221			},
222		}.Create()); err != nil {
223			walk.MsgBox(nil, "Chyba", fmt.Sprintf("Nelze vytvorit okno: %v", err), walk.MsgBoxIconError)
224			return result
225		}
226
227		mw.Run()
228		return result
229	}
230
231	// --------------------------------------------------------------------------
232	// Phase 2 – Live run view
233	// --------------------------------------------------------------------------
234
235	// stepIndicator tracks a step's label and blink state.
236	type stepIndicator struct {
237		label  *walk.Label
238		stepID string
239		status string // "pending", "running", "ok", "error", "skipped", "cancelled"
240	}
241
242	func runPhase(runCfg runner.RunConfig, steps []runner.Step) []runner.Result {
243		var (
244			mw        *walk.MainWindow
245			statusLbl *walk.Label
246			logTE     *walk.TextEdit
247			cancelFn  context.CancelFunc
248		)
249
250		// Build step indicator labels (one per step, in order)
251		indicators := make([]stepIndicator, len(steps))
252		stepIndex  := make(map[string]int, len(steps)) // stepID → index
253		indWidgets := make([]Widget, len(steps))
254		for i, s := range steps {
255			indicators[i] = stepIndicator{stepID: s.ID, status: "pending"}
256			stepIndex[s.ID] = i
257			indWidgets[i] = Label{
258				AssignTo: &indicators[i].label,
259				Text:     s.Num + " \u00b7",
260				Font:     Font{Family: "Consolas", PointSize: 9},
261				MinSize:  Size{Width: 38},
262			}
263		}
264
265		if err := (MainWindow{
266			AssignTo: &mw,
267			Title:    "xetup \u2014 probiha instalace",
268			Size:     Size{Width: 760, Height: 740},
269			Layout:   VBox{},
270			Children: []Widget{
271				Label{AssignTo: &statusLbl, Text: "Spoustim..."},
272				// Step progress strip
273				Composite{
274					Layout:   HBox{MarginsZero: true},
275					Children: indWidgets,
276				},
277				HSeparator{},
278				TextEdit{
279					AssignTo: &logTE,
280					ReadOnly: true,
281					VScroll:  true,
282					Font:     Font{Family: "Consolas", PointSize: 9},
283					MinSize:  Size{Height: 530},
284				},
285				HSeparator{},
286				Composite{
287					Layout: HBox{MarginsZero: true},
288					Children: []Widget{
289						HSpacer{},
290						PushButton{
291							Text: "  ZASTAVIT  ",
292							OnClicked: func() {
293								if cancelFn != nil {
294									cancelFn()
295								}
296							},
297						},
298					},
299				},
300			},
301		}.Create()); err != nil {
302			return nil
303		}
304
305		// updateIndicator refreshes label text and color — call inside Synchronize.
306		updateIndicator := func(idx int) {
307			ind := &indicators[idx]
308			s := steps[idx]
309			switch ind.status {
310			case "running":
311				ind.label.SetText(s.Num + " \u25ba") // ►
312				ind.label.SetTextColor(walk.RGB(30, 144, 255)) // dodger blue
313			case "ok":
314				ind.label.SetText(s.Num + " \u2713") // ✓
315				ind.label.SetTextColor(walk.RGB(0, 180, 0))
316			case "error":
317				ind.label.SetText(s.Num + " \u2717") // ✗
318				ind.label.SetTextColor(walk.RGB(220, 50, 50))
319			case "skipped", "cancelled":
320				ind.label.SetText(s.Num + " \u2013") // –
321				ind.label.SetTextColor(walk.RGB(140, 140, 140))
322			default:
323				ind.label.SetText(s.Num + " \u00b7") // ·
324				ind.label.SetTextColor(walk.RGB(180, 180, 180))
325			}
326		}
327
328		var (
329			mu      sync.Mutex
330			results []runner.Result
331			done    = make(chan struct{})
332		)
333
334		ctx, cancel := context.WithCancel(context.Background())
335		cancelFn = cancel
336
337		mw.Closing().Attach(func(_ *bool, _ walk.CloseReason) {
338			cancelFn()
339		})
340
341		r := runner.New(
342			runCfg,
343			func(l runner.LogLine) {
344				mw.Synchronize(func() {
345					logTE.AppendText(l.Text + "\r\n")
346				})
347			},
348			func(step runner.Step) {
349				mw.Synchronize(func() {
350					statusLbl.SetText(fmt.Sprintf(
351						"Krok %s \u2013 %s...", step.Num, step.Name,
352					))
353					if idx, ok := stepIndex[step.ID]; ok {
354						indicators[idx].status = "running"
355						updateIndicator(idx)
356					}
357				})
358			},
359			func(res runner.Result) {
360				mw.Synchronize(func() {
361					statusLbl.SetText(fmt.Sprintf(
362						"Krok %s \u2013 %s: %s", res.Step.Num, res.Step.Name, res.Status,
363					))
364					if idx, ok := stepIndex[res.Step.ID]; ok {
365						indicators[idx].status = strings.ToLower(res.Status)
366						updateIndicator(idx)
367					}
368				})
369			},
370		)
371
372		go func() {
373			defer close(done)
374			res := r.Run(ctx, steps)
375			mu.Lock()
376			results = res
377			mu.Unlock()
378			mw.Synchronize(func() {
379				mw.Close()
380			})
381		}()
382
383		mw.Run()
384		cancel()
385		<-done
386
387		mu.Lock()
388		defer mu.Unlock()
389		return results
390	}
391
392	// --------------------------------------------------------------------------
393	// Phase 3 – Summary with auto-reboot countdown
394	// --------------------------------------------------------------------------
395
396	const rebootCountdown = 60 // seconds before automatic reboot
397
398	func donePhase(results []runner.Result) {
399		var (
400			mw          *walk.MainWindow
401			countdownLbl *walk.Label
402		)
403
404		ok, errs, skipped := 0, 0, 0
405		rows := make([]Widget, 0, len(results))
406
407		for _, res := range results {
408			var icon string
409			switch res.Status {
410			case "OK":
411				ok++
412				icon = "OK    "
413			case "ERROR":
414				errs++
415				icon = "ERR   "
416			default:
417				skipped++
418				icon = "\u2013     "
419			}
420			text := icon + res.Step.Num + " \u2013 " + res.Step.Name
421			if res.Elapsed > 0 {
422				text += fmt.Sprintf("  (%s)", res.Elapsed.Round(time.Second))
423			}
424			rows = append(rows, Label{
425				Text: text,
426				Font: Font{Family: "Consolas", PointSize: 9},
427			})
428		}
429
430		summaryText := fmt.Sprintf("OK: %d   CHYBY: %d   PRESKOCENO: %d", ok, errs, skipped)
431
432		// cancelled by "Zrusit restart" button
433		cancelReboot := make(chan struct{})
434
435		if err := (MainWindow{
436			AssignTo: &mw,
437			Title:    "xetup \u2014 hotovo",
438			Size:     Size{Width: 760, Height: 740},
439			Layout:   VBox{},
440			Children: []Widget{
441				Label{
442					Text:      "Hotovo",
443					Alignment: AlignHCenterVNear,
444					Font:      Font{Bold: true, PointSize: 12},
445				},
446				HSeparator{},
447				ScrollView{
448					MinSize:  Size{Height: 510},
449					Layout:   VBox{MarginsZero: true},
450					Children: rows,
451				},
452				HSeparator{},
453				Label{
454					Text:      summaryText,
455					Alignment: AlignHCenterVNear,
456					Font:      Font{Bold: true},
457				},
458				Label{
459					AssignTo:  &countdownLbl,
460					Text:      fmt.Sprintf("Restart za %ds...", rebootCountdown),
461					Alignment: AlignHCenterVNear,
462				},
463				Composite{
464					Layout: HBox{MarginsZero: true},
465					Children: []Widget{
466						HSpacer{},
467						PushButton{
468							Text: "  Restartovat ted  ",
469							OnClicked: func() {
470								close(cancelReboot) // signal goroutine to stop ticker
471								reboot()
472								mw.Close()
473							},
474						},
475						PushButton{
476							Text: "  Zrusit restart  ",
477							OnClicked: func() {
478								select {
479								case <-cancelReboot: // already closed
480								default:
481									close(cancelReboot)
482								}
483								countdownLbl.SetText("Restart zrusen.")
484							},
485						},
486						HSpacer{},
487					},
488				},
489			},
490		}.Create()); err != nil {
491			return
492		}
493
494		// Countdown goroutine
495		go func() {
496			ticker := time.NewTicker(time.Second)
497			defer ticker.Stop()
498			remaining := rebootCountdown
499			for {
500				select {
501				case <-cancelReboot:
502					return
503				case <-ticker.C:
504					remaining--
505					r := remaining
506					mw.Synchronize(func() {
507						if r > 0 {
508							countdownLbl.SetText(fmt.Sprintf("Restart za %ds...", r))
509						} else {
510							countdownLbl.SetText("Restartuji...")
511						}
512					})
513					if remaining <= 0 {
514						reboot()
515						mw.Synchronize(func() { mw.Close() })
516						return
517					}
518				}
519			}
520		}()
521
522		mw.Run()
523	}
524
525	// reboot issues an immediate Windows restart.
526	func reboot() {
527		exec.Command("shutdown", "/r", "/t", "0").Run() //nolint:errcheck
528	}
529
530	// --------------------------------------------------------------------------
531	// Helpers
532	// --------------------------------------------------------------------------
533
534	func itemEnabled(cfg config.Config, item runner.SelectableItem) bool {
535		if item.FeatureID == "" {
536			if v, ok := cfg.Steps[item.StepID]; ok {
537				return v
538			}
539			return true
540		}
541		if feats, ok := cfg.Features[item.StepID]; ok {
542			if v, ok2 := feats[item.FeatureID]; ok2 {
543				return v
544			}
545		}
546		return true
547	}
548
549	func buildStepsAndFeatures(selected map[string]bool) ([]runner.Step, config.Features) {
550		items    := runner.AllSelectableItems()
551		features := make(config.Features)
552		stepOn   := make(map[string]bool)
553
554		for _, item := range items {
555			if item.FeatureID == "" {
556				stepOn[item.StepID] = selected[item.Key]
557			} else {
558				if features[item.StepID] == nil {
559					features[item.StepID] = make(map[string]bool)
560				}
561				features[item.StepID][item.FeatureID] = selected[item.Key]
562				if selected[item.Key] {
563					stepOn[item.StepID] = true
564				}
565			}
566		}
567
568		allSteps := runner.AllSteps()
569		steps := make([]runner.Step, len(allSteps))
570		for i, s := range allSteps {
571			s.Enabled = stepOn[s.ID]
572			steps[i] = s
573		}
574		return steps, features
575	}
576
577	func profileIndex(p string) int {
578		switch p {
579		case "admin":
580			return 1
581		case "user":
582			return 2
583		default:
584			return 0
585		}
586	}
587

//go:build windows

// Package gui implements the Walk-based graphical interface for xetup.
//
// Three sequential phases, each with its own window:
//  1. Config form  – PC name, product key, profile, step checkboxes,
//                    load/save config buttons for per-client presets
//  2. Live run     – real-time log streamed from PowerShell scripts
//  3. Summary      – per-step OK / ERROR / SKIPPED with elapsed time
package gui

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"strings"
	"sync"
	"time"

	"github.com/lxn/walk"
	. "github.com/lxn/walk/declarative"

	"git.xetup.x9.cz/x9/xetup/internal/config"
	"git.xetup.x9.cz/x9/xetup/internal/runner"
)

// Run opens the xetup window and blocks until the user closes it.
func Run(cfg config.Config, runCfg runner.RunConfig, cfgPath string) {
	for {
		res := formPhase(cfg, runCfg, cfgPath)
		switch res.action {
		case "quit":
			return
		case "reload":
			cfg = res.cfg
			cfgPath = res.cfgPath
		case "run":
			runCfg.ProfileType = res.cfg.Deployment.ProfileType
			results := runPhase(runCfg, res.steps)
			donePhase(results)
			return
		}
	}
}

// --------------------------------------------------------------------------
// Phase 1 – Config form
// --------------------------------------------------------------------------

type formResult struct {
	action  string // "run", "reload", "quit"
	cfg     config.Config
	cfgPath string
	steps   []runner.Step
}

func formPhase(cfg config.Config, runCfg runner.RunConfig, cfgPath string) formResult {
	var mw *walk.MainWindow
	result := formResult{action: "quit", cfgPath: cfgPath}

	var (
		pcNameLE     *walk.LineEdit
		pcDescLE     *walk.LineEdit
		productKeyLE *walk.LineEdit
		profileCB    *walk.ComboBox
	)

	items := runner.AllSelectableItems()
	checkPtrs := make([]*walk.CheckBox, len(items))
	checkWidgets := make([]Widget, len(items))
	for i, item := range items {
		i := i // capture for closure
		checkWidgets[i] = CheckBox{
			AssignTo: &checkPtrs[i],
			Text:     item.Label,
			Checked:  itemEnabled(cfg, item),
		}
	}

	collectCfg := func() config.Config {
		out := cfg
		out.Deployment.PCName = pcNameLE.Text()
		out.Deployment.PCDescription = pcDescLE.Text()
		out.Activation.ProductKey = productKeyLE.Text()
		profiles := []string{"default", "admin", "user"}
		if idx := profileCB.CurrentIndex(); idx >= 0 && idx < len(profiles) {
			out.Deployment.ProfileType = profiles[idx]
		}
		selected := make(map[string]bool, len(items))
		for i, item := range items {
			selected[item.Key] = checkPtrs[i].Checked()
		}
		_, features := buildStepsAndFeatures(selected)
		out.Features = features
		return out
	}

	if err := (MainWindow{
		AssignTo: &mw,
		Title:    "xetup \u2014 Windows deployment",
		Size:     Size{Width: 760, Height: 740},
		Layout:   VBox{},
		Children: []Widget{
			// ── Form fields ──────────────────────────────────────────────────
			Composite{
				Layout: Grid{Columns: 2},
				Children: []Widget{
					Label{Text: "PC jmeno:"},
					LineEdit{
						AssignTo:  &pcNameLE,
						Text:      cfg.Deployment.PCName,
						CueBanner: "napr. NB-KLIENT-01  (prazdne = neprejmenovat)",
					},
					Label{Text: "Popis PC:"},
					LineEdit{
						AssignTo:  &pcDescLE,
						Text:      cfg.Deployment.PCDescription,
						CueBanner: "napr. PC recepce",
					},
					Label{Text: "Product Key:"},
					LineEdit{
						AssignTo:  &productKeyLE,
						Text:      cfg.Activation.ProductKey,
						CueBanner: "prazdne = OA3 / GVLK fallback",
					},
					Label{Text: "Profil:"},
					ComboBox{
						AssignTo:     &profileCB,
						Model:        []string{"default", "admin", "user"},
						CurrentIndex: profileIndex(cfg.Deployment.ProfileType),
					},
				},
			},
			HSeparator{},
			// ── Step checkboxes ──────────────────────────────────────────────
			Label{Text: "Kroky a nastaveni (odskrtnete co nechcete spustit):"},
			ScrollView{
				MinSize:  Size{Height: 400},
				Layout:   VBox{MarginsZero: true},
				Children: checkWidgets,
			},
			HSeparator{},
			// ── Buttons ──────────────────────────────────────────────────────
			Composite{
				Layout: HBox{MarginsZero: true},
				Children: []Widget{
					PushButton{
						Text: "Nacist config...",
						OnClicked: func() {
							dlg := new(walk.FileDialog)
							dlg.Filter = "JSON soubory (*.json)|*.json|Vsechny soubory (*.*)|*.*"
							dlg.Title = "Nacist konfiguraci"
							ok, err := dlg.ShowOpen(mw)
							if err != nil || !ok {
								return
							}
							data, err := os.ReadFile(dlg.FilePath)
							if err != nil {
								walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
								return
							}
							newCfg := config.DefaultConfig()
							if err := json.Unmarshal(data, &newCfg); err != nil {
								walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
								return
							}
							result = formResult{
								action:  "reload",
								cfg:     newCfg,
								cfgPath: dlg.FilePath,
							}
							mw.Close()
						},
					},
					PushButton{
						Text: "Ulozit config...",
						OnClicked: func() {
							dlg := new(walk.FileDialog)
							dlg.Filter = "JSON soubory (*.json)|*.json|Vsechny soubory (*.*)|*.*"
							dlg.Title = "Ulozit konfiguraci"
							dlg.FilePath = "config.json"
							ok, err := dlg.ShowSave(mw)
							if err != nil || !ok {
								return
							}
							data, err := json.MarshalIndent(collectCfg(), "", "  ")
							if err != nil {
								walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
								return
							}
							if err := os.WriteFile(dlg.FilePath, data, 0644); err != nil {
								walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
							}
						},
					},
					HSpacer{},
					PushButton{
						Text: "  SPUSTIT  ",
						OnClicked: func() {
							finalCfg := collectCfg()
							selected := make(map[string]bool, len(items))
							for i, item := range items {
								selected[item.Key] = checkPtrs[i].Checked()
							}
							steps, features := buildStepsAndFeatures(selected)
							finalCfg.Features = features
							_ = config.Save(finalCfg, cfgPath)
							result = formResult{
								action:  "run",
								cfg:     finalCfg,
								cfgPath: cfgPath,
								steps:   steps,
							}
							mw.Close()
						},
					},
				},
			},
		},
	}.Create()); err != nil {
		walk.MsgBox(nil, "Chyba", fmt.Sprintf("Nelze vytvorit okno: %v", err), walk.MsgBoxIconError)
		return result
	}

	mw.Run()
	return result
}

// --------------------------------------------------------------------------
// Phase 2 – Live run view
// --------------------------------------------------------------------------

// stepIndicator tracks a step's label and blink state.
type stepIndicator struct {
	label  *walk.Label
	stepID string
	status string // "pending", "running", "ok", "error", "skipped", "cancelled"
}

func runPhase(runCfg runner.RunConfig, steps []runner.Step) []runner.Result {
	var (
		mw        *walk.MainWindow
		statusLbl *walk.Label
		logTE     *walk.TextEdit
		cancelFn  context.CancelFunc
	)

	// Build step indicator labels (one per step, in order)
	indicators := make([]stepIndicator, len(steps))
	stepIndex  := make(map[string]int, len(steps)) // stepID → index
	indWidgets := make([]Widget, len(steps))
	for i, s := range steps {
		indicators[i] = stepIndicator{stepID: s.ID, status: "pending"}
		stepIndex[s.ID] = i
		indWidgets[i] = Label{
			AssignTo: &indicators[i].label,
			Text:     s.Num + " \u00b7",
			Font:     Font{Family: "Consolas", PointSize: 9},
			MinSize:  Size{Width: 38},
		}
	}

	if err := (MainWindow{
		AssignTo: &mw,
		Title:    "xetup \u2014 probiha instalace",
		Size:     Size{Width: 760, Height: 740},
		Layout:   VBox{},
		Children: []Widget{
			Label{AssignTo: &statusLbl, Text: "Spoustim..."},
			// Step progress strip
			Composite{
				Layout:   HBox{MarginsZero: true},
				Children: indWidgets,
			},
			HSeparator{},
			TextEdit{
				AssignTo: &logTE,
				ReadOnly: true,
				VScroll:  true,
				Font:     Font{Family: "Consolas", PointSize: 9},
				MinSize:  Size{Height: 530},
			},
			HSeparator{},
			Composite{
				Layout: HBox{MarginsZero: true},
				Children: []Widget{
					HSpacer{},
					PushButton{
						Text: "  ZASTAVIT  ",
						OnClicked: func() {
							if cancelFn != nil {
								cancelFn()
							}
						},
					},
				},
			},
		},
	}.Create()); err != nil {
		return nil
	}

	// updateIndicator refreshes label text and color — call inside Synchronize.
	updateIndicator := func(idx int) {
		ind := &indicators[idx]
		s := steps[idx]
		switch ind.status {
		case "running":
			ind.label.SetText(s.Num + " \u25ba") // ►
			ind.label.SetTextColor(walk.RGB(30, 144, 255)) // dodger blue
		case "ok":
			ind.label.SetText(s.Num + " \u2713") // ✓
			ind.label.SetTextColor(walk.RGB(0, 180, 0))
		case "error":
			ind.label.SetText(s.Num + " \u2717") // ✗
			ind.label.SetTextColor(walk.RGB(220, 50, 50))
		case "skipped", "cancelled":
			ind.label.SetText(s.Num + " \u2013") // –
			ind.label.SetTextColor(walk.RGB(140, 140, 140))
		default:
			ind.label.SetText(s.Num + " \u00b7") // ·
			ind.label.SetTextColor(walk.RGB(180, 180, 180))
		}
	}

	var (
		mu      sync.Mutex
		results []runner.Result
		done    = make(chan struct{})
	)

	ctx, cancel := context.WithCancel(context.Background())
	cancelFn = cancel

	mw.Closing().Attach(func(_ *bool, _ walk.CloseReason) {
		cancelFn()
	})

	r := runner.New(
		runCfg,
		func(l runner.LogLine) {
			mw.Synchronize(func() {
				logTE.AppendText(l.Text + "\r\n")
			})
		},
		func(step runner.Step) {
			mw.Synchronize(func() {
				statusLbl.SetText(fmt.Sprintf(
					"Krok %s \u2013 %s...", step.Num, step.Name,
				))
				if idx, ok := stepIndex[step.ID]; ok {
					indicators[idx].status = "running"
					updateIndicator(idx)
				}
			})
		},
		func(res runner.Result) {
			mw.Synchronize(func() {
				statusLbl.SetText(fmt.Sprintf(
					"Krok %s \u2013 %s: %s", res.Step.Num, res.Step.Name, res.Status,
				))
				if idx, ok := stepIndex[res.Step.ID]; ok {
					indicators[idx].status = strings.ToLower(res.Status)
					updateIndicator(idx)
				}
			})
		},
	)

	go func() {
		defer close(done)
		res := r.Run(ctx, steps)
		mu.Lock()
		results = res
		mu.Unlock()
		mw.Synchronize(func() {
			mw.Close()
		})
	}()

	mw.Run()
	cancel()
	<-done

	mu.Lock()
	defer mu.Unlock()
	return results
}

// --------------------------------------------------------------------------
// Phase 3 – Summary with auto-reboot countdown
// --------------------------------------------------------------------------

const rebootCountdown = 60 // seconds before automatic reboot

func donePhase(results []runner.Result) {
	var (
		mw          *walk.MainWindow
		countdownLbl *walk.Label
	)

	ok, errs, skipped := 0, 0, 0
	rows := make([]Widget, 0, len(results))

	for _, res := range results {
		var icon string
		switch res.Status {
		case "OK":
			ok++
			icon = "OK    "
		case "ERROR":
			errs++
			icon = "ERR   "
		default:
			skipped++
			icon = "\u2013     "
		}
		text := icon + res.Step.Num + " \u2013 " + res.Step.Name
		if res.Elapsed > 0 {
			text += fmt.Sprintf("  (%s)", res.Elapsed.Round(time.Second))
		}
		rows = append(rows, Label{
			Text: text,
			Font: Font{Family: "Consolas", PointSize: 9},
		})
	}

	summaryText := fmt.Sprintf("OK: %d   CHYBY: %d   PRESKOCENO: %d", ok, errs, skipped)

	// cancelled by "Zrusit restart" button
	cancelReboot := make(chan struct{})

	if err := (MainWindow{
		AssignTo: &mw,
		Title:    "xetup \u2014 hotovo",
		Size:     Size{Width: 760, Height: 740},
		Layout:   VBox{},
		Children: []Widget{
			Label{
				Text:      "Hotovo",
				Alignment: AlignHCenterVNear,
				Font:      Font{Bold: true, PointSize: 12},
			},
			HSeparator{},
			ScrollView{
				MinSize:  Size{Height: 510},
				Layout:   VBox{MarginsZero: true},
				Children: rows,
			},
			HSeparator{},
			Label{
				Text:      summaryText,
				Alignment: AlignHCenterVNear,
				Font:      Font{Bold: true},
			},
			Label{
				AssignTo:  &countdownLbl,
				Text:      fmt.Sprintf("Restart za %ds...", rebootCountdown),
				Alignment: AlignHCenterVNear,
			},
			Composite{
				Layout: HBox{MarginsZero: true},
				Children: []Widget{
					HSpacer{},
					PushButton{
						Text: "  Restartovat ted  ",
						OnClicked: func() {
							close(cancelReboot) // signal goroutine to stop ticker
							reboot()
							mw.Close()
						},
					},
					PushButton{
						Text: "  Zrusit restart  ",
						OnClicked: func() {
							select {
							case <-cancelReboot: // already closed
							default:
								close(cancelReboot)
							}
							countdownLbl.SetText("Restart zrusen.")
						},
					},
					HSpacer{},
				},
			},
		},
	}.Create()); err != nil {
		return
	}

	// Countdown goroutine
	go func() {
		ticker := time.NewTicker(time.Second)
		defer ticker.Stop()
		remaining := rebootCountdown
		for {
			select {
			case <-cancelReboot:
				return
			case <-ticker.C:
				remaining--
				r := remaining
				mw.Synchronize(func() {
					if r > 0 {
						countdownLbl.SetText(fmt.Sprintf("Restart za %ds...", r))
					} else {
						countdownLbl.SetText("Restartuji...")
					}
				})
				if remaining <= 0 {
					reboot()
					mw.Synchronize(func() { mw.Close() })
					return
				}
			}
		}
	}()

	mw.Run()
}

// reboot issues an immediate Windows restart.
func reboot() {
	exec.Command("shutdown", "/r", "/t", "0").Run() //nolint:errcheck
}

// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------

func itemEnabled(cfg config.Config, item runner.SelectableItem) bool {
	if item.FeatureID == "" {
		if v, ok := cfg.Steps[item.StepID]; ok {
			return v
		}
		return true
	}
	if feats, ok := cfg.Features[item.StepID]; ok {
		if v, ok2 := feats[item.FeatureID]; ok2 {
			return v
		}
	}
	return true
}

func buildStepsAndFeatures(selected map[string]bool) ([]runner.Step, config.Features) {
	items    := runner.AllSelectableItems()
	features := make(config.Features)
	stepOn   := make(map[string]bool)

	for _, item := range items {
		if item.FeatureID == "" {
			stepOn[item.StepID] = selected[item.Key]
		} else {
			if features[item.StepID] == nil {
				features[item.StepID] = make(map[string]bool)
			}
			features[item.StepID][item.FeatureID] = selected[item.Key]
			if selected[item.Key] {
				stepOn[item.StepID] = true
			}
		}
	}

	allSteps := runner.AllSteps()
	steps := make([]runner.Step, len(allSteps))
	for i, s := range allSteps {
		s.Enabled = stepOn[s.ID]
		steps[i] = s
	}
	return steps, features
}

func profileIndex(p string) int {
	switch p {
	case "admin":
		return 1
	case "user":
		return 2
	default:
		return 0
	}
}


1	package config
2
3	import (
4		"encoding/json"
5		"os"
6		"path/filepath"
7	)
8
9	// Config mirrors config.json structure.
10	type Config struct {
11		Deployment    Deployment             `json:"deployment"`
12		AdminAccount  AdminAccount           `json:"adminAccount"`
13		Activation    Activation             `json:"activation"`
14		Software      Software               `json:"software"`
15		Steps         map[string]bool        `json:"steps"`
16		Features      Features               `json:"features"`
17	}
18
19	type Deployment struct {
20		PCName        string `json:"pcName"`
21		PCDescription string `json:"pcDescription"`
22		Timezone      string `json:"timezone"`
23		ProfileType   string `json:"profileType"` // default | admin | user
24	}
25
26	type AdminAccount struct {
27		Username string `json:"username"`
28	}
29
30	type Activation struct {
31		ProductKey string `json:"productKey"`
32		KMSServer  string `json:"kmsServer"`
33	}
34
35	type SoftwareItem struct {
36		Name     string `json:"name"`
37		WingetID string `json:"wingetId"`
38	}
39
40	type Software struct {
41		Install []SoftwareItem `json:"install"`
42	}
43
44	// Features holds per-step, per-feature toggle flags.
45	// Keys: stepID -> featureID -> enabled.
46	// A missing key defaults to true (feature enabled).
47	type Features map[string]map[string]bool
48
49	// DefaultConfig returns a config with sensible defaults.
50	func DefaultConfig() Config {
51		return Config{
52			Deployment: Deployment{
53				Timezone:    "Central Europe Standard Time",
54				ProfileType: "default",
55			},
56			AdminAccount: AdminAccount{
57				Username: "adminx9",
58			},
59			Activation: Activation{
60				ProductKey: "",
61			},
62			Software: Software{
63				Install: []SoftwareItem{
64					{Name: "7-Zip", WingetID: "7zip.7zip"},
65					{Name: "Adobe Acrobat Reader 64-bit", WingetID: "Adobe.Acrobat.Reader.64-bit"},
66					{Name: "OpenVPN Connect", WingetID: "OpenVPNTechnologies.OpenVPNConnect"},
67				},
68			},
69			Steps: map[string]bool{
70				"adminAccount":    true,
71				"bloatware":       true,
72				"software":        true,
73				"systemRegistry":  true,
74				"defaultProfile":  true,
75				"personalization": true,
76				"scheduledTasks":  true,
77				"backinfo":        true,
78				"activation":      true,
79				"dellUpdate":      true,
80				"windowsUpdate":   true,
81				"network":         true,
82				"pcIdentity":      true,
83			},
84			Features: Features{
85				"software": {
86					"wingetInstalls": true,
87					"pdfDefault":     true,
88					"ateraAgent":     true,
89				},
90				"systemRegistry": {
91					"systemTweaks":      true,
92					"edgePolicies":      true,
93					"oneDriveUninstall": true,
94					"powercfg":          true,
95					"proxyDisable":      true,
96				},
97				"defaultProfile": {
98					"taskbarTweaks":   true,
99					"startMenuTweaks": true,
100					"explorerTweaks":  true,
101				},
102				"dellUpdate": {
103					"drivers": true,
104					"bios":    true,
105				},
106			},
107		}
108	}
109
110	// Load reads config.json from the given path.
111	// If the file does not exist, returns DefaultConfig without error.
112	func Load(path string) (Config, error) {
113		cfg := DefaultConfig()
114		data, err := os.ReadFile(path)
115		if os.IsNotExist(err) {
116			return cfg, nil
117		}
118		if err != nil {
119			return cfg, err
120		}
121		if err := json.Unmarshal(data, &cfg); err != nil {
122			return cfg, err
123		}
124		return cfg, nil
125	}
126
127	// Save writes config to the given path (creates directories if needed).
128	func Save(cfg Config, path string) error {
129		if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
130			return err
131		}
132		data, err := json.MarshalIndent(cfg, "", "  ")
133		if err != nil {
134			return err
135		}
136		return os.WriteFile(path, data, 0644)
137	}
138
139	// ConfigPath returns the default config.json path (next to the executable).
140	func ConfigPath() string {
141		exe, err := os.Executable()
142		if err != nil {
143			return "config.json"
144		}
145		return filepath.Join(filepath.Dir(exe), "config.json")
146	}
147

package config

import (
	"encoding/json"
	"os"
	"path/filepath"
)

// Config mirrors config.json structure.
type Config struct {
	Deployment    Deployment             `json:"deployment"`
	AdminAccount  AdminAccount           `json:"adminAccount"`
	Activation    Activation             `json:"activation"`
	Software      Software               `json:"software"`
	Steps         map[string]bool        `json:"steps"`
	Features      Features               `json:"features"`
}

type Deployment struct {
	PCName        string `json:"pcName"`
	PCDescription string `json:"pcDescription"`
	Timezone      string `json:"timezone"`
	ProfileType   string `json:"profileType"` // default | admin | user
}

type AdminAccount struct {
	Username string `json:"username"`
}

type Activation struct {
	ProductKey string `json:"productKey"`
	KMSServer  string `json:"kmsServer"`
}

type SoftwareItem struct {
	Name     string `json:"name"`
	WingetID string `json:"wingetId"`
}

type Software struct {
	Install []SoftwareItem `json:"install"`
}

// Features holds per-step, per-feature toggle flags.
// Keys: stepID -> featureID -> enabled.
// A missing key defaults to true (feature enabled).
type Features map[string]map[string]bool

// DefaultConfig returns a config with sensible defaults.
func DefaultConfig() Config {
	return Config{
		Deployment: Deployment{
			Timezone:    "Central Europe Standard Time",
			ProfileType: "default",
		},
		AdminAccount: AdminAccount{
			Username: "adminx9",
		},
		Activation: Activation{
			ProductKey: "",
		},
		Software: Software{
			Install: []SoftwareItem{
				{Name: "7-Zip", WingetID: "7zip.7zip"},
				{Name: "Adobe Acrobat Reader 64-bit", WingetID: "Adobe.Acrobat.Reader.64-bit"},
				{Name: "OpenVPN Connect", WingetID: "OpenVPNTechnologies.OpenVPNConnect"},
			},
		},
		Steps: map[string]bool{
			"adminAccount":    true,
			"bloatware":       true,
			"software":        true,
			"systemRegistry":  true,
			"defaultProfile":  true,
			"personalization": true,
			"scheduledTasks":  true,
			"backinfo":        true,
			"activation":      true,
			"dellUpdate":      true,
			"windowsUpdate":   true,
			"network":         true,
			"pcIdentity":      true,
		},
		Features: Features{
			"software": {
				"wingetInstalls": true,
				"pdfDefault":     true,
				"ateraAgent":     true,
			},
			"systemRegistry": {
				"systemTweaks":      true,
				"edgePolicies":      true,
				"oneDriveUninstall": true,
				"powercfg":          true,
				"proxyDisable":      true,
			},
			"defaultProfile": {
				"taskbarTweaks":   true,
				"startMenuTweaks": true,
				"explorerTweaks":  true,
			},
			"dellUpdate": {
				"drivers": true,
				"bios":    true,
			},
		},
	}
}

// Load reads config.json from the given path.
// If the file does not exist, returns DefaultConfig without error.
func Load(path string) (Config, error) {
	cfg := DefaultConfig()
	data, err := os.ReadFile(path)
	if os.IsNotExist(err) {
		return cfg, nil
	}
	if err != nil {
		return cfg, err
	}
	if err := json.Unmarshal(data, &cfg); err != nil {
		return cfg, err
	}
	return cfg, nil
}

// Save writes config to the given path (creates directories if needed).
func Save(cfg Config, path string) error {
	if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
		return err
	}
	data, err := json.MarshalIndent(cfg, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(path, data, 0644)
}

// ConfigPath returns the default config.json path (next to the executable).
func ConfigPath() string {
	exe, err := os.Executable()
	if err != nil {
		return "config.json"
	}
	return filepath.Join(filepath.Dir(exe), "config.json")
}


1	<#
2	.SYNOPSIS
3	    Detects Dell hardware, installs Dell Command | Update, and applies all available updates.
4
5	.DESCRIPTION
6	    Checks Win32_ComputerSystem.Manufacturer - if not Dell, the step exits silently without
7	    error so the same deployment script works on any hardware. On Dell machines, installs
8	    Dell Command | Update (Universal) via winget and immediately runs /applyUpdates with
9	    -reboot=disable. This covers drivers, firmware, and BIOS. BIOS and firmware updates are
10	    staged at this point and finalize automatically during the restart that closes the
11	    deployment. The operator does not need to run a separate update pass after setup.
12
13	.ITEMS
14	    detekce-dell-hw-win32-computersystem: Reads Win32_ComputerSystem.Manufacturer. If the string does not contain "Dell", the entire step is skipped without error. The deployment continues normally on HP, Lenovo, or any other brand.
15	    instalace-dell-command-update-via-winget: Installs Dell.CommandUpdate.Universal silently via winget. This is the current DCU generation (v5+) that supports Latitude, OptiPlex, Precision, Vostro, and XPS on Win10 and Win11.
16	    spusteni-vsech-aktualizaci-drivery-firmware-bios: Runs dcu-cli.exe /applyUpdates -silent -reboot=disable. Covers driver, firmware, and BIOS categories in a single pass. The -reboot=disable flag prevents DCU from rebooting mid-deployment.
17	    bios-firmware-staging-reboot: BIOS and firmware updates are staged by DCU and finalize on the next system restart. The deployment already ends with a restart (step 09 - computer rename), so no extra reboot is needed.
18	#>
19	param(
20	    [object]$Config,
21	    [string]$LogFile
22	)
23
24	$ErrorActionPreference = "Continue"
25
26	function Write-Log {
27	    param([string]$Message, [string]$Level = "INFO")
28	    $line = "[$(Get-Date -Format 'HH:mm:ss')] [$Level] $Message"
29	    $null = New-Item -ItemType Directory -Force -Path (Split-Path $LogFile -Parent) -ErrorAction SilentlyContinue
30	    Add-Content -Path $LogFile -Value $line -Encoding UTF8
31	    switch ($Level) {
32	        "OK"    { Write-Host $line -ForegroundColor Green }
33	        "ERROR" { Write-Host $line -ForegroundColor Red }
34	        "WARN"  { Write-Host $line -ForegroundColor Yellow }
35	        "STEP"  { Write-Host $line -ForegroundColor Cyan }
36	        default { Write-Host $line }
37	    }
38	}
39
40	function Get-Feature {
41	    param([object]$Cfg, [string]$StepID, [string]$FeatureID, [bool]$Default = $true)
42	    try {
43	        if ($null -eq $Cfg) { return $Default }
44	        $stepFeatures = $Cfg.features.$StepID
45	        if ($null -eq $stepFeatures) { return $Default }
46	        $val = $stepFeatures.$FeatureID
47	        if ($null -eq $val) { return $Default }
48	        return [bool]$val
49	    } catch { return $Default }
50	}
51
52	# -----------------------------------------------------------------------
53	# Detect Dell hardware
54	# -----------------------------------------------------------------------
55	Write-Log "Checking hardware manufacturer" -Level INFO
56
57	try {
58	    $cs           = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
59	    $manufacturer = $cs.Manufacturer
60	    $model        = $cs.Model
61	    Write-Log "  Manufacturer: $manufacturer  Model: $model" -Level INFO
62	}
63	catch {
64	    Write-Log "  Failed to query Win32_ComputerSystem: $_" -Level ERROR
65	    return
66	}
67
68	if ($manufacturer -notmatch "Dell") {
69	    Write-Log "Not a Dell machine ($manufacturer) - step skipped" -Level WARN
70	    return
71	}
72
73	Write-Log "Dell confirmed: $model" -Level OK
74
75	# -----------------------------------------------------------------------
76	# Install Dell Command | Update via winget
77	# -----------------------------------------------------------------------
78	Write-Log "Installing Dell Command | Update (Universal)..." -Level STEP
79
80	$wingetArgs = @(
81	    "install",
82	    "--id",   "Dell.CommandUpdate.Universal",
83	    "--silent",
84	    "--accept-package-agreements",
85	    "--accept-source-agreements"
86	)
87
88	$wingetOutput = & winget @wingetArgs 2>&1
89	$wingetExit   = $LASTEXITCODE
90	$wingetOutput | ForEach-Object { Write-Log "  [winget] $_" -Level INFO }
91
92	if ($wingetExit -ne 0 -and $wingetExit -ne 1638) {   # 1638 = already installed
93	    Write-Log "  winget exit code $wingetExit - checking if DCU is already present" -Level WARN
94	}
95
96	# Locate dcu-cli.exe (path is the same for x64 and Universal edition)
97	$dcuCandidates = @(
98	    "C:\Program Files\Dell\CommandUpdate\dcu-cli.exe",
99	    "C:\Program Files (x86)\Dell\CommandUpdate\dcu-cli.exe"
100	)
101	$dcuCli = $dcuCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
102
103	if (-not $dcuCli) {
104	    Write-Log "  dcu-cli.exe not found - cannot run updates" -Level ERROR
105	    return
106	}
107
108	Write-Log "  dcu-cli.exe found: $dcuCli" -Level OK
109
110	# -----------------------------------------------------------------------
111	# Run updates - categories controlled by feature flags
112	# -reboot=disable  -> no mid-deployment reboot; BIOS/firmware staged for next restart
113	# -----------------------------------------------------------------------
114	$runDrivers = Get-Feature $Config "dellUpdate" "drivers"
115	$runBios    = Get-Feature $Config "dellUpdate" "bios"
116
117	if (-not $runDrivers -and -not $runBios) {
118	    Write-Log "Both drivers and bios features disabled - skipping update run" -Level INFO
119	} else {
120	    # Build update type list from enabled features
121	    $updateTypes = @()
122	    if ($runDrivers) {
123	        $updateTypes += "driver"
124	        $updateTypes += "firmware"
125	    }
126	    if ($runBios) {
127	        $updateTypes += "bios"
128	    }
129	    $updateTypeArg = $updateTypes -join ","
130
131	    Write-Log "Running Dell Command | Update (updateType=$updateTypeArg, no auto-reboot)..." -Level STEP
132	    Write-Log "  This may take several minutes depending on available updates" -Level INFO
133
134	    $dcuOutput = & $dcuCli /applyUpdates -silent -reboot=disable "-updateType=$updateTypeArg" 2>&1
135	    $exitCode  = $LASTEXITCODE
136	    $dcuOutput | ForEach-Object { Write-Log "  [DCU] $_" -Level INFO }
137
138	    Write-Log "  DCU exit code: $exitCode" -Level INFO
139
140	    # Dell Command | Update exit codes:
141	    #  0 = completed, no updates required or updates applied (no reboot needed)
142	    #  1 = updates applied, reboot required to finalize BIOS/firmware
143	    #  5 = no applicable updates found for this system
144	    #  others = error or partial failure
145	    switch ($exitCode) {
146	        0       { Write-Log "Dell Command | Update: complete (no reboot required)" -Level OK }
147	        1       { Write-Log "Dell Command | Update: updates staged - BIOS/firmware will finalize on restart" -Level OK }
148	        5       { Write-Log "Dell Command | Update: no applicable updates for this model" -Level OK }
149	        default { Write-Log "Dell Command | Update: exit code $exitCode - review DCU log in C:\ProgramData\Dell\UpdateService\Logs" -Level WARN }
150	    }
151	}
152
153	Write-Log "Step 11 complete" -Level OK
154

<#
.SYNOPSIS
    Detects Dell hardware, installs Dell Command | Update, and applies all available updates.

.DESCRIPTION
    Checks Win32_ComputerSystem.Manufacturer - if not Dell, the step exits silently without
    error so the same deployment script works on any hardware. On Dell machines, installs
    Dell Command | Update (Universal) via winget and immediately runs /applyUpdates with
    -reboot=disable. This covers drivers, firmware, and BIOS. BIOS and firmware updates are
    staged at this point and finalize automatically during the restart that closes the
    deployment. The operator does not need to run a separate update pass after setup.

.ITEMS
    detekce-dell-hw-win32-computersystem: Reads Win32_ComputerSystem.Manufacturer. If the string does not contain "Dell", the entire step is skipped without error. The deployment continues normally on HP, Lenovo, or any other brand.
    instalace-dell-command-update-via-winget: Installs Dell.CommandUpdate.Universal silently via winget. This is the current DCU generation (v5+) that supports Latitude, OptiPlex, Precision, Vostro, and XPS on Win10 and Win11.
    spusteni-vsech-aktualizaci-drivery-firmware-bios: Runs dcu-cli.exe /applyUpdates -silent -reboot=disable. Covers driver, firmware, and BIOS categories in a single pass. The -reboot=disable flag prevents DCU from rebooting mid-deployment.
    bios-firmware-staging-reboot: BIOS and firmware updates are staged by DCU and finalize on the next system restart. The deployment already ends with a restart (step 09 - computer rename), so no extra reboot is needed.
#>
param(
    [object]$Config,
    [string]$LogFile
)

$ErrorActionPreference = "Continue"

function Write-Log {
    param([string]$Message, [string]$Level = "INFO")
    $line = "[$(Get-Date -Format 'HH:mm:ss')] [$Level] $Message"
    $null = New-Item -ItemType Directory -Force -Path (Split-Path $LogFile -Parent) -ErrorAction SilentlyContinue
    Add-Content -Path $LogFile -Value $line -Encoding UTF8
    switch ($Level) {
        "OK"    { Write-Host $line -ForegroundColor Green }
        "ERROR" { Write-Host $line -ForegroundColor Red }
        "WARN"  { Write-Host $line -ForegroundColor Yellow }
        "STEP"  { Write-Host $line -ForegroundColor Cyan }
        default { Write-Host $line }
    }
}

function Get-Feature {
    param([object]$Cfg, [string]$StepID, [string]$FeatureID, [bool]$Default = $true)
    try {
        if ($null -eq $Cfg) { return $Default }
        $stepFeatures = $Cfg.features.$StepID
        if ($null -eq $stepFeatures) { return $Default }
        $val = $stepFeatures.$FeatureID
        if ($null -eq $val) { return $Default }
        return [bool]$val
    } catch { return $Default }
}

# -----------------------------------------------------------------------
# Detect Dell hardware
# -----------------------------------------------------------------------
Write-Log "Checking hardware manufacturer" -Level INFO

try {
    $cs           = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
    $manufacturer = $cs.Manufacturer
    $model        = $cs.Model
    Write-Log "  Manufacturer: $manufacturer  Model: $model" -Level INFO
}
catch {
    Write-Log "  Failed to query Win32_ComputerSystem: $_" -Level ERROR
    return
}

if ($manufacturer -notmatch "Dell") {
    Write-Log "Not a Dell machine ($manufacturer) - step skipped" -Level WARN
    return
}

Write-Log "Dell confirmed: $model" -Level OK

# -----------------------------------------------------------------------
# Install Dell Command | Update via winget
# -----------------------------------------------------------------------
Write-Log "Installing Dell Command | Update (Universal)..." -Level STEP

$wingetArgs = @(
    "install",
    "--id",   "Dell.CommandUpdate.Universal",
    "--silent",
    "--accept-package-agreements",
    "--accept-source-agreements"
)

$wingetOutput = & winget @wingetArgs 2>&1
$wingetExit   = $LASTEXITCODE
$wingetOutput | ForEach-Object { Write-Log "  [winget] $_" -Level INFO }

if ($wingetExit -ne 0 -and $wingetExit -ne 1638) {   # 1638 = already installed
    Write-Log "  winget exit code $wingetExit - checking if DCU is already present" -Level WARN
}

# Locate dcu-cli.exe (path is the same for x64 and Universal edition)
$dcuCandidates = @(
    "C:\Program Files\Dell\CommandUpdate\dcu-cli.exe",
    "C:\Program Files (x86)\Dell\CommandUpdate\dcu-cli.exe"
)
$dcuCli = $dcuCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1

if (-not $dcuCli) {
    Write-Log "  dcu-cli.exe not found - cannot run updates" -Level ERROR
    return
}

Write-Log "  dcu-cli.exe found: $dcuCli" -Level OK

# -----------------------------------------------------------------------
# Run updates - categories controlled by feature flags
# -reboot=disable  -> no mid-deployment reboot; BIOS/firmware staged for next restart
# -----------------------------------------------------------------------
$runDrivers = Get-Feature $Config "dellUpdate" "drivers"
$runBios    = Get-Feature $Config "dellUpdate" "bios"

if (-not $runDrivers -and -not $runBios) {
    Write-Log "Both drivers and bios features disabled - skipping update run" -Level INFO
} else {
    # Build update type list from enabled features
    $updateTypes = @()
    if ($runDrivers) {
        $updateTypes += "driver"
        $updateTypes += "firmware"
    }
    if ($runBios) {
        $updateTypes += "bios"
    }
    $updateTypeArg = $updateTypes -join ","

    Write-Log "Running Dell Command | Update (updateType=$updateTypeArg, no auto-reboot)..." -Level STEP
    Write-Log "  This may take several minutes depending on available updates" -Level INFO

    $dcuOutput = & $dcuCli /applyUpdates -silent -reboot=disable "-updateType=$updateTypeArg" 2>&1
    $exitCode  = $LASTEXITCODE
    $dcuOutput | ForEach-Object { Write-Log "  [DCU] $_" -Level INFO }

    Write-Log "  DCU exit code: $exitCode" -Level INFO

    # Dell Command | Update exit codes:
    #  0 = completed, no updates required or updates applied (no reboot needed)
    #  1 = updates applied, reboot required to finalize BIOS/firmware
    #  5 = no applicable updates found for this system
    #  others = error or partial failure
    switch ($exitCode) {
        0       { Write-Log "Dell Command | Update: complete (no reboot required)" -Level OK }
        1       { Write-Log "Dell Command | Update: updates staged - BIOS/firmware will finalize on restart" -Level OK }
        5       { Write-Log "Dell Command | Update: no applicable updates for this model" -Level OK }
        default { Write-Log "Dell Command | Update: exit code $exitCode - review DCU log in C:\ProgramData\Dell\UpdateService\Logs" -Level WARN }
    }
}

Write-Log "Step 11 complete" -Level OK


1	//go:build windows
2
3	package runner
4
5	import (
6		"os/exec"
7		"syscall"
8	)
9
10	// hideWindow prevents the child process from creating a visible console window.
11	// Without this, powershell.exe opens a full-size window on top of the GUI.
12	func hideWindow(cmd *exec.Cmd) {
13		cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
14	}
15

//go:build windows

package runner

import (
	"os/exec"
	"syscall"
)

// hideWindow prevents the child process from creating a visible console window.
// Without this, powershell.exe opens a full-size window on top of the GUI.
func hideWindow(cmd *exec.Cmd) {
	cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
}


1	//go:build !windows
2
3	package runner
4
5	import "os/exec"
6
7	func hideWindow(cmd *exec.Cmd) {}
8

//go:build !windows

package runner

import "os/exec"

func hideWindow(cmd *exec.Cmd) {}


Read embed.go


// Package xetup exposes embedded PowerShell scripts and assets for use by
// cmd/xetup. Placing the embed declarations here (at the module root) gives
// the //go:embed directives a clear, stable relative path to the content.
package xetup

import "embed"

// Scripts holds all PowerShell scripts from the scripts/ directory.
//
//go:embed scripts
var Scripts embed.FS

// Assets holds all deployment assets from the assets/ directory.
//
//go:embed assets
var Assets embed.FS


cat /opt/xetup/go.mod && echo "---" && cat /opt/xetup/go.sum | head -20

Read go.mod and go.sum