xetup/internal/runner/runner.go
X9 Dev fc35450eb0
All checks were successful
release / build-and-release (push) Successful in 40s
feat(bloatware): toggleable Outlook/Snipping removal, keep Snipping by default
Add three GUI feature toggles to the bloatware step:
- standardBloatware (default on)  - the bulk AppX/capability/feature list
- removeNewOutlook   (default on)  - new Outlook for Windows (Microsoft.OutlookForWindows)
- removeSnippingTool (default OFF) - Snipping Tool across all three lists

Each toggle is independent via Test-RemovalAllowed in 01-bloatware.ps1.
Snipping Tool (ScreenSketch + legacy capability/feature) is now kept by
default as a common productivity tool, like Calculator. Classic Outlook from
M365 is a Win32 app and was never touched; only the bundled new Outlook is.

Also fix a latent bug: the Go Config struct had no Bloatware field, so the
GUI's runtime-config regeneration silently dropped bloatware.keepPackages.
Added the field so the keep-list survives to the script.

Docs: SPEC.md, CHANGELOG.md, web/data/descriptions.json, web/spec/index.html.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:27:34 +02:00

442 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package runner executes PowerShell deployment scripts and streams log output.
package runner
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"time"
)
// ErrNeedsReboot is returned by runScript when a PowerShell script exits with
// code 9, signalling that it completed successfully but requires a system
// reboot before deployment can continue.
var ErrNeedsReboot = errors.New("reboot required")
// 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.
// Order matters: activation early (unlocks features), pcIdentity late (rename
// needs reboot), windowsUpdate last (reboot cycle).
func AllSteps() []Step {
return []Step{
{ID: "adminAccount", Num: "00", Name: "Admin ucet", ScriptName: "00-admin-account.ps1"},
{ID: "activation", Num: "08", Name: "Windows aktivace", ScriptName: "08-activation.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: "Profil + personalizace", ScriptName: "04-default-profile.ps1"},
{ID: "backinfo", Num: "07", Name: "BackInfo", ScriptName: "07-backinfo.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: "pcIdentity", Num: "09", Name: "PC identita", ScriptName: "09-pc-identity.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{
"bloatware": {
{ID: "standardBloatware", Label: "Standardni bloatware (AppX, capabilities, features)"},
{ID: "removeNewOutlook", Label: "Novy Outlook for Windows"},
{ID: "removeSnippingTool", Label: "Vystrizky / Snipping Tool"},
},
"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", "CANCELLED"
Elapsed time.Duration
NeedsReboot bool // true when the script exited with code 9
}
// 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"
needsReboot := false
if err != nil {
if errors.Is(err, ErrNeedsReboot) {
needsReboot = true
// status remains "OK": step completed, reboot is just required to continue
} else if ctx.Err() != nil {
status = "CANCELLED"
} else {
status = "ERROR"
}
}
res := Result{Step: step, Status: status, Elapsed: elapsed, NeedsReboot: needsReboot}
r.onResult(res)
results = append(results, res)
if ctx.Err() != nil || needsReboot {
break
}
}
return results
}
// Stop cancels the running deployment.
func (r *Runner) Stop() {
if r.cancel != nil {
r.cancel()
}
}
// silenceTimeout is how long a script may produce no output before the
// watchdog kills it. Active scripts (producing output) are never killed.
const silenceTimeout = 30 * time.Minute
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 path - script loads JSON itself via common.ps1 Load-Config
if cfgArg != "" {
args = append(args, "-ConfigPath", 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
}
// Watchdog: kill script if it produces no output for silenceTimeout.
// The timer is reset on every output line, so active scripts run
// indefinitely (e.g. Dell BIOS download producing progress dots).
watchdog := time.AfterFunc(silenceTimeout, func() {
r.onLog(LogLine{
StepID: step.ID,
Text: fmt.Sprintf("[WATCHDOG] No output for %v - killing script", silenceTimeout),
Level: "ERROR",
})
if cmd.Process != nil {
cmd.Process.Kill()
}
})
defer watchdog.Stop()
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
watchdog.Reset(silenceTimeout)
line := scanner.Text()
if skipPSNoiseLine(line) {
continue
}
r.onLog(LogLine{
StepID: step.ID,
Text: line,
Level: parseLevel(line),
})
}
waitErr := cmd.Wait()
if waitErr != nil {
var exitErr *exec.ExitError
if errors.As(waitErr, &exitErr) && exitErr.ExitCode() == 9 {
return ErrNeedsReboot
}
}
return waitErr
}
// 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
}
// StepsByIDs returns steps from AllSteps() whose IDs are in the given list,
// preserving the canonical AllSteps() order.
func StepsByIDs(ids []string) []Step {
want := make(map[string]bool, len(ids))
for _, id := range ids {
want[id] = true
}
var result []Step
for _, s := range AllSteps() {
if want[s.ID] {
result = append(result, s)
}
}
return result
}
// StepByID returns the Step with the given ID, and true if found.
func StepByID(id string) (Step, bool) {
for _, s := range AllSteps() {
if s.ID == id {
return s, true
}
}
return Step{}, false
}
// 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)
}