diff --git a/go.mod b/go.mod index d0469379..04d1af7d 100644 --- a/go.mod +++ b/go.mod @@ -5,23 +5,27 @@ go 1.23.0 toolchain go1.24.4 require ( - github.com/StackExchange/wmi v1.2.1 - github.com/capnspacehook/taskmaster v0.0.0-20210519235353-1629df7c85e9 - github.com/go-ole/go-ole v1.3.0 - github.com/google/deck v1.1.0 - github.com/google/go-cmp v0.6.0 - github.com/google/uuid v1.3.0 - github.com/google/winops v0.0.0-20210803215038-c8511b84de2b - github.com/iamacarpet/go-win64api v0.0.0-20240507095429-873e84e85847 - github.com/pkg/errors v0.9.1 - github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f - golang.org/x/sys v0.34.0 + github.com/StackExchange/wmi v1.2.1 + github.com/capnspacehook/taskmaster v0.0.0-20210519235353-1629df7c85e9 + github.com/fogleman/gg v1.3.0 + github.com/go-ole/go-ole v1.3.0 + github.com/google/deck v1.1.0 + github.com/google/go-cmp v0.6.0 + github.com/google/uuid v1.3.0 + github.com/google/winops v0.0.0-20210803215038-c8511b84de2b + github.com/iamacarpet/go-win64api v0.0.0-20240507095429-873e84e85847 + github.com/pkg/errors v0.9.1 + github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f + golang.org/x/sys v0.34.0 ) require ( - github.com/google/cabbie v1.0.5 // indirect - github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect - github.com/rickb777/date v1.21.1 // indirect - github.com/rickb777/plural v1.4.4 // indirect - gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2 // indirect -) \ No newline at end of file + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/google/cabbie v1.0.5 // indirect + github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect + github.com/rickb777/date v1.21.1 // indirect + github.com/rickb777/plural v1.4.4 // indirect + golang.org/x/image v0.21.0 // indirect + golang.org/x/net v0.27.0 // indirect + gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2 // indirect +) diff --git a/go.sum b/go.sum index 838ad264..d0fff8e8 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9 github.com/capnspacehook/taskmaster v0.0.0-20210519235353-1629df7c85e9 h1:5jmtWADt5DzD8NnPxcqd1FzbFNZNfbJGNeDb+WKjoJ0= github.com/capnspacehook/taskmaster v0.0.0-20210519235353-1629df7c85e9/go.mod h1:257CYs3Wd/CTlLQ3c72jKv+fFE2MV3WPNnV5jiroYUU= github.com/creachadair/staticfile v0.1.3/go.mod h1:a3qySzCIXEprDGxk6tSxSI+dBBdLzqeBOMhZ+o2d3pM= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM= @@ -14,6 +16,8 @@ github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiU github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20210429001901-424d2337a529/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= @@ -72,6 +76,8 @@ github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f h1:v+bqkkvZj6O github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f/go.mod h1:Zt2M6t3i/fnWviIZkuw9wGn2E185P/rWZTqJkIrViGY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s= +golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= @@ -122,4 +128,4 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= \ No newline at end of file +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go/lockscreenui/README.md b/go/lockscreenui/README.md new file mode 100644 index 00000000..bf20ef22 --- /dev/null +++ b/go/lockscreenui/README.md @@ -0,0 +1,131 @@ +# Glazier Lock Screen UI Manager + + + +`lockscreenui` is a robust, reusable Go library for displaying provisioning, +installation, or configuration progress directly on the Windows lock screen +(`LogonUI.exe`). + +It is fully configurable, supporting custom background images, solid background +colors, tailored title prefixes, and custom footer messages. + +## Features + +* **Seamless Lock Screen Integration**: Dynamically generates progress + wallpaper frames and applies them via Windows Group Policy registry keys + (`SOFTWARE\Policies\Microsoft\Windows\Personalization\LockScreenImage`). +* **Instant Visual Updates**: Safely refreshes the Windows logon screen + (`taskkill /F /IM LogonUI.exe`) to force immediate redraws without requiring + a reboot. +* **Reboot Resilient**: Because Group Policy registry keys persist across + restarts, Windows Winlogon automatically displays the latest progress screen + immediately upon bootup. +* **Flexible Overlay Positioning**: Supports placing the progress box in the + `center`, `top`, or `bottom` of the screen to avoid overlapping with active + Windows login credential tiles. +* **Distinct Error Alerting**: Provides a dedicated `UpdateLockScreenError` + API that renders a striking translucent dark red alert box to notify users + of failures and guide them on recovery steps. +* **Automatic Footprint Cleanup**: Includes a clean `Cleanup()` method to + remove Group Policy overrides and purge temporary image files once + provisioning is complete. + +## Usage Example + +```go +package main + +import ( + "context" + "image/color" + "log" + + "github.com/google/glazier/go/lockscreenui" +) + +func main() { + ctx := context.Background() + + // 1. Define configuration + cfg := lockscreenui.Config{ + TempDir: `C:\Provisioning\TempScreens`, + BgColor: color.RGBA{0, 120, 215, 255}, // Solid Windows Blue + TitlePrefix: "System Setup: ", + FooterText: "Please do not turn off your computer", + ErrorTitle: "Setup Error (Step Failed)", + ErrorFooterText: "Please reboot the machine to retry setup", + OverlayPosition: lockscreenui.PositionBottom, // Position at the bottom to avoid credential tiles + } + + // 2. Initialize UI Manager + ui, err := lockscreenui.NewProvisioningUI(ctx, cfg) + if err != nil { + log.Fatalf("Failed to initialize UI: %v", err) + } + defer ui.Cleanup(ctx) // Ensures policies and temp files are removed upon exit + + // 3. Update Progress + if err := ui.UpdateLockScreen(ctx, 1, "Step 1 of 3: Installing system packages..."); err != nil { + log.Printf("Progress update failed: %v", err) + } + + // 4. Report Error (if a step fails) + // ui.UpdateLockScreenError(ctx, 1, "Package download failed: network timeout") +} +``` + +## Embedding a Custom Background Image + +You can bake a custom background image (JPEG or PNG) directly into your compiled +Go binary using Go's native `//go:embed` directive. This ensures your tool +remains a single standalone executable without relying on external asset files +on the host machine. + +```go +package main + +import ( + "bytes" + "context" + "embed" + "image" + "log" + + "github.com/google/glazier/go/lockscreenui" + + _ "image/jpeg" // Essential for decoding embedded JPEG images +) + +//go:embed assets/wallpaper.jpeg +var embeddedFS embed.FS + +func main() { + ctx := context.Background() + + // 1. Read and decode the embedded image + data, err := embeddedFS.ReadFile("assets/wallpaper.jpeg") + if err != nil { + log.Fatalf("Failed to read embedded image: %v", err) + } + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + log.Fatalf("Failed to decode embedded image: %v", err) + } + + // 2. Pass the decoded image to the configuration + cfg := lockscreenui.Config{ + TempDir: `C:\Provisioning\TempScreens`, + BaseImage: img, + TitlePrefix: "Setup: ", + OverlayPosition: lockscreenui.PositionBottom, + } + + ui, err := lockscreenui.NewProvisioningUI(ctx, cfg) + if err != nil { + log.Fatalf("Failed to initialize UI: %v", err) + } + defer ui.Cleanup(ctx) + + ui.UpdateLockScreen(ctx, 1, "Applying embedded background...") +} +``` diff --git a/go/lockscreenui/lockscreenui.go b/go/lockscreenui/lockscreenui.go new file mode 100644 index 00000000..e72d1c09 --- /dev/null +++ b/go/lockscreenui/lockscreenui.go @@ -0,0 +1,442 @@ +//go:build windows + +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package lockscreenui provides a robust, reusable UI manager for displaying provisioning and configuration progress +// on the Windows lock screen. +package lockscreenui + +import ( + "golang.org/x/net/context" + "fmt" + "image" + "image/color" + "math" + "os" + "path/filepath" + + "github.com/google/deck" + "github.com/fogleman/gg" + "github.com/google/glazier/go/helpers" + "github.com/google/glazier/go/registry" + + _ "image/jpeg" // Required for decoding JPEG background images + _ "image/png" // Required for decoding PNG background images +) + +const ( + registryPolicyKey = `SOFTWARE\Policies\Microsoft\Windows\Personalization` + registryValueName = "LockScreenImage" +) + +var ( + fontPath = filepath.Join(os.Getenv("windir"), "Fonts", "segoeui.ttf") // Native to modern Windows +) + +// Position defines where the progress overlay box is positioned on the screen. +type Position string + +const ( + // PositionCenter positions the overlay in the center of the screen. + PositionCenter Position = "center" + // PositionTop positions the overlay in the top portion of the screen. + PositionTop Position = "top" + // PositionBottom positions the overlay in the bottom portion of the screen. + PositionBottom Position = "bottom" +) + +// Config defines the configuration for the ProvisioningUI manager. +type Config struct { + // TempDir is the directory where generated lock screen images are temporarily stored. + TempDir string + // BaseImagePath is the optional path to a background image (JPEG or PNG). + BaseImagePath string + // BaseImage is an optional pre-loaded background image. + BaseImage image.Image + // BgColor is the background color used if no base image is provided. Defaults to solid Windows Blue (0, 120, 215). + BgColor color.Color + // TitlePrefix is the prefix for progress messages (e.g., "Provisioning: "). + TitlePrefix string + // FooterText is the footer displayed on progress screens. + FooterText string + // ErrorTitle is the title displayed on error screens. + ErrorTitle string + // ErrorFooterText is the footer displayed on error screens. + ErrorFooterText string + // OverlayPosition defines the vertical placement of the progress box on the screen. Defaults to PositionCenter. + OverlayPosition Position +} + +// ProvisioningUI manages the state of the lock screen updates. +type ProvisioningUI struct { + cfg Config + lastImagePath string // Tracks the previous step's image so we can delete it + hasError bool // If true, Cleanup preserves the error screen and policy so the user can see the failure notification +} + +// NewProvisioningUI initializes the UI manager with the provided configuration. +func NewProvisioningUI(ctx context.Context, cfg Config) (*ProvisioningUI, error) { + deck.Infof("Initializing ProvisioningUI manager (tempDir: %q)", cfg.TempDir) + + if cfg.TempDir == "" { + return nil, fmt.Errorf("TempDir cannot be empty") + } + + // Set sensible defaults if not provided + if cfg.BgColor == nil { + cfg.BgColor = color.RGBA{0, 120, 215, 255} // Default Windows Blue + } + if cfg.TitlePrefix == "" { + cfg.TitlePrefix = "Progress: " + } + if cfg.ErrorTitle == "" { + cfg.ErrorTitle = "Error (Step Failed)" + } + if cfg.OverlayPosition == "" { + cfg.OverlayPosition = PositionCenter + } + + if cfg.BaseImagePath != "" { + deck.Infof("Validating custom base image path: %s", cfg.BaseImagePath) + if _, err := os.Stat(cfg.BaseImagePath); os.IsNotExist(err) { + deck.Errorf("Base image not found at %s: %v", cfg.BaseImagePath, err) + return nil, fmt.Errorf("base image not found at %s: %w", cfg.BaseImagePath, err) + } + } + + deck.Infof("Ensuring temporary output directory exists: %s", cfg.TempDir) + if err := os.MkdirAll(cfg.TempDir, 0755); err != nil { + deck.Errorf("Failed to create temp directory %s: %v", cfg.TempDir, err) + return nil, fmt.Errorf("failed to create temp directory: %w", err) + } + + return &ProvisioningUI{ + cfg: cfg, + }, nil +} + +// UpdateLockScreen renders the new frame, applies it, and refreshes the screen. +func (ui *ProvisioningUI) UpdateLockScreen(ctx context.Context, step int, message string) error { + outputPath := filepath.Join(ui.cfg.TempDir, fmt.Sprintf("step_%d.png", step)) + deck.Infof("Updating lock screen progress (step: %d, output: %q)", step, outputPath) + + if err := ui.renderWallpaper(message, outputPath); err != nil { + deck.Errorf("Failed to render progress wallpaper for step %d: %v", step, err) + return fmt.Errorf("failed to render frame for step %d: %w", step, err) + } + + if err := ui.setRegistryPolicy(outputPath); err != nil { + deck.Errorf("Failed to set LockScreenImage registry policy: %v", err) + return fmt.Errorf("failed to set registry policy: %w", err) + } + + ui.forceScreenRefresh() + + if ui.lastImagePath != "" && ui.lastImagePath != outputPath { + deck.Infof("Cleaning up previous progress image: %s", ui.lastImagePath) + _ = os.Remove(ui.lastImagePath) + } + ui.lastImagePath = outputPath + + return nil +} + +// UpdateLockScreenError renders a distinct dark red failure notification screen, applies it, and preserves it. +func (ui *ProvisioningUI) UpdateLockScreenError(ctx context.Context, step int, errorMessage string) error { + deck.Warningf("Provisioning failure detected at step %d. Generating alert lock screen.", step) + ui.hasError = true + + outputPath := filepath.Join(ui.cfg.TempDir, fmt.Sprintf("error_step_%d.png", step)) + + if err := ui.renderErrorWallpaper(errorMessage, outputPath); err != nil { + deck.Errorf("Failed to render error wallpaper for step %d: %v", step, err) + return fmt.Errorf("failed to render error frame for step %d: %w", step, err) + } + + if err := ui.setRegistryPolicy(outputPath); err != nil { + deck.Errorf("Failed to set error LockScreenImage registry policy: %v", err) + return fmt.Errorf("failed to set error registry policy: %w", err) + } + + ui.forceScreenRefresh() + + if ui.lastImagePath != "" && ui.lastImagePath != outputPath { + deck.Infof("Cleaning up previous image before displaying error: %s", ui.lastImagePath) + _ = os.Remove(ui.lastImagePath) + } + ui.lastImagePath = outputPath + + return nil +} + +// ForceCleanup unconditionally removes policies and temp files, restoring the system to normal regardless of error state. +func (ui *ProvisioningUI) ForceCleanup(ctx context.Context) error { + ui.hasError = false + return ui.Cleanup(ctx) +} + +// Cleanup removes policies and temp files, restoring the system to normal. +func (ui *ProvisioningUI) Cleanup(ctx context.Context) error { + if ui.hasError { + deck.Warning("Provisioning finished with an error. Preserving error wallpaper and Group Policy intact.") + return nil + } + + deck.Info("Provisioning completed successfully. Initiating UI footprint cleanup.") + var cleanupErrors []error + + deck.Infof("Removing Group Policy registry value: %s\\%s", registryPolicyKey, registryValueName) + if err := registry.Delete(registryPolicyKey, registryValueName); err != nil && err != registry.ErrNotExist { + deck.Warningf("Failed to delete registry value: %v", err) + cleanupErrors = append(cleanupErrors, fmt.Errorf("failed to delete registry value: %w", err)) + } + + deck.Infof("Removing temporary screens directory: %s", ui.cfg.TempDir) + if err := os.RemoveAll(ui.cfg.TempDir); err != nil { + deck.Warningf("Failed to remove temp directory %s: %v", ui.cfg.TempDir, err) + cleanupErrors = append(cleanupErrors, fmt.Errorf("failed to remove temp directory: %w", err)) + } + + deck.Info("Triggering final logon screen refresh to restore default Windows wallpaper.") + ui.forceScreenRefresh() + + if len(cleanupErrors) > 0 { + return fmt.Errorf("cleanup finished with errors: %v", cleanupErrors) + } + return nil +} + +// --- Internal Helper Methods --- + +func (ui *ProvisioningUI) renderWallpaper(text, outputPath string) error { + var bgImage image.Image + var err error + + if ui.cfg.BaseImage != nil { + bgImage = ui.cfg.BaseImage + } else if ui.cfg.BaseImagePath != "" { + bgImage, err = gg.LoadImage(ui.cfg.BaseImagePath) + if err != nil { + return fmt.Errorf("could not load base image: %w", err) + } + } + + var width, height int + var dc *gg.Context + + if bgImage != nil { + bounds := bgImage.Bounds() + width = bounds.Dx() + height = bounds.Dy() + dc = gg.NewContext(width, height) + dc.DrawImage(bgImage, 0, 0) + } else { + // Default 1080p canvas with solid background color + width = 1920 + height = 1080 + dc = gg.NewContext(width, height) + dc.SetColor(ui.cfg.BgColor) + dc.Clear() + } + + if err := dc.LoadFontFace(fontPath, 48); err != nil { + return fmt.Errorf("could not load font %s: %w", fontPath, err) + } + fullText := fmt.Sprintf("%s%s", ui.cfg.TitlePrefix, text) + titleWidth, titleHeight := dc.MeasureString(fullText) + + var footerWidth, footerHeight float64 + if ui.cfg.FooterText != "" { + if err := dc.LoadFontFace(fontPath, 32); err != nil { + return fmt.Errorf("could not load font %s: %w", fontPath, err) + } + footerWidth, footerHeight = dc.MeasureString(ui.cfg.FooterText) + } + + padding := 40.0 + lineSpacing := 20.0 + boxWidth := math.Max(titleWidth, footerWidth) + (padding * 2) + boxHeight := titleHeight + (padding * 2) + if ui.cfg.FooterText != "" { + boxHeight += footerHeight + lineSpacing + } + boxX := (float64(width) - boxWidth) / 2.0 + + var boxY float64 + switch ui.cfg.OverlayPosition { + case PositionTop: + boxY = float64(height) * 0.15 + case PositionBottom: + boxY = float64(height)*0.80 - boxHeight + default: // PositionCenter + boxY = (float64(height) - boxHeight) / 2.0 + } + + dc.DrawRoundedRectangle(boxX, boxY, boxWidth, boxHeight, 15) // 15px border radius + dc.SetRGBA(0, 0, 0, 0.75) // 75% opaque black for excellent contrast + dc.Fill() + + if err := dc.LoadFontFace(fontPath, 48); err != nil { + return fmt.Errorf("could not reload font %s: %w", fontPath, err) + } + dc.SetRGB(1, 1, 1) // Crisp white text + boxCenterY := boxY + (boxHeight / 2.0) + totalTextHeight := titleHeight + if ui.cfg.FooterText != "" { + totalTextHeight += lineSpacing + footerHeight + } + startY := boxCenterY - (totalTextHeight / 2.0) + titleY := startY + (titleHeight / 2.0) + dc.DrawStringAnchored(fullText, float64(width)/2.0, titleY, 0.5, 0.5) + + if ui.cfg.FooterText != "" { + if err := dc.LoadFontFace(fontPath, 32); err != nil { + return fmt.Errorf("could not reload font %s: %w", fontPath, err) + } + dc.SetRGB(0.85, 0.85, 0.85) + footerY := startY + titleHeight + lineSpacing + (footerHeight / 2.0) + dc.DrawStringAnchored(ui.cfg.FooterText, float64(width)/2.0, footerY, 0.5, 0.5) + } + + // Ensure the output directory exists, as it might have been removed by a prior Cleanup. + if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { + return fmt.Errorf("failed to create output directory %s: %w", filepath.Dir(outputPath), err) + } + + if err := dc.SavePNG(outputPath); err != nil { + return fmt.Errorf("failed to save generated PNG: %w", err) + } + + return nil +} + +func (ui *ProvisioningUI) renderErrorWallpaper(errorMessage, outputPath string) error { + var bgImage image.Image + var err error + + if ui.cfg.BaseImage != nil { + bgImage = ui.cfg.BaseImage + } else if ui.cfg.BaseImagePath != "" { + bgImage, err = gg.LoadImage(ui.cfg.BaseImagePath) + if err != nil { + return fmt.Errorf("could not load base image: %w", err) + } + } + + var width, height int + var dc *gg.Context + + if bgImage != nil { + bounds := bgImage.Bounds() + width = bounds.Dx() + height = bounds.Dy() + dc = gg.NewContext(width, height) + dc.DrawImage(bgImage, 0, 0) + } else { + width = 1920 + height = 1080 + dc = gg.NewContext(width, height) + dc.SetColor(ui.cfg.BgColor) + dc.Clear() + } + + if err := dc.LoadFontFace(fontPath, 48); err != nil { + return fmt.Errorf("could not load font %s: %w", fontPath, err) + } + titleWidth, titleHeight := dc.MeasureString(ui.cfg.ErrorTitle) + + if err := dc.LoadFontFace(fontPath, 32); err != nil { + return fmt.Errorf("could not load font %s: %w", fontPath, err) + } + msgWidth, msgHeight := dc.MeasureString(errorMessage) + + var footerWidth, footerHeight float64 + if ui.cfg.ErrorFooterText != "" { + footerWidth, footerHeight = dc.MeasureString(ui.cfg.ErrorFooterText) + } + + padding := 40.0 + lineSpacing := 20.0 + maxTextWidth := math.Max(titleWidth, math.Max(msgWidth, footerWidth)) + boxWidth := maxTextWidth + (padding * 2) + boxHeight := titleHeight + msgHeight + (lineSpacing * 2) + (padding * 2) + if ui.cfg.ErrorFooterText != "" { + boxHeight += footerHeight + lineSpacing + } + boxX := (float64(width) - boxWidth) / 2.0 + + var boxY float64 + switch ui.cfg.OverlayPosition { + case PositionTop: + boxY = float64(height) * 0.15 + case PositionBottom: + boxY = float64(height)*0.80 - boxHeight + default: // PositionCenter + boxY = (float64(height) - boxHeight) / 2.0 + } + + dc.DrawRoundedRectangle(boxX, boxY, boxWidth, boxHeight, 15) + dc.SetRGBA(0.65, 0.0, 0.0, 0.85) // 85% opaque dark red alert box + dc.Fill() + + if err := dc.LoadFontFace(fontPath, 48); err != nil { + return fmt.Errorf("could not reload font %s: %w", fontPath, err) + } + dc.SetRGB(1, 1, 1) + boxCenterY := boxY + (boxHeight / 2.0) + totalTextHeight := titleHeight + msgHeight + lineSpacing + if ui.cfg.ErrorFooterText != "" { + totalTextHeight += lineSpacing + footerHeight + } + startY := boxCenterY - (totalTextHeight / 2.0) + titleY := startY + (titleHeight / 2.0) + dc.DrawStringAnchored(ui.cfg.ErrorTitle, float64(width)/2.0, titleY, 0.5, 0.5) + + if err := dc.LoadFontFace(fontPath, 32); err != nil { + return fmt.Errorf("could not reload font %s: %w", fontPath, err) + } + dc.SetRGB(1, 0.95, 0.2) // Bright yellow for the failure message to prioritize readability + msgY := startY + titleHeight + lineSpacing + (msgHeight / 2.0) + dc.DrawStringAnchored(errorMessage, float64(width)/2.0, msgY, 0.5, 0.5) + + if ui.cfg.ErrorFooterText != "" { + dc.SetRGB(0.9, 0.9, 0.9) + footerY := startY + titleHeight + msgHeight + (lineSpacing * 2) + (footerHeight / 2.0) + dc.DrawStringAnchored(ui.cfg.ErrorFooterText, float64(width)/2.0, footerY, 0.5, 0.5) + } + + if err := dc.SavePNG(outputPath); err != nil { + return fmt.Errorf("failed to save generated error PNG: %w", err) + } + + return nil +} + +func (ui *ProvisioningUI) setRegistryPolicy(imagePath string) error { + deck.Infof("Ensuring registry policy key exists: %s", registryPolicyKey) + _ = registry.Create(registryPolicyKey) + + deck.Infof("Setting registry value %s\\%s = %s", registryPolicyKey, registryValueName, imagePath) + if err := registry.SetString(registryPolicyKey, registryValueName, imagePath); err != nil { + return fmt.Errorf("failed to set registry value: %w", err) + } + + return nil +} + +func (ui *ProvisioningUI) forceScreenRefresh() { + deck.Info("Executing taskkill on LogonUI.exe to force lock screen redraw") + _, _ = helpers.Exec("taskkill", []string{"/F", "/IM", "LogonUI.exe"}, nil) +} diff --git a/go/lockscreenui/lockscreenui_linux.go b/go/lockscreenui/lockscreenui_linux.go new file mode 100644 index 00000000..8c0e0891 --- /dev/null +++ b/go/lockscreenui/lockscreenui_linux.go @@ -0,0 +1,72 @@ +//go:build !windows + +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package lockscreenui + +import ( + "golang.org/x/net/context" + "errors" + "image" + "image/color" +) + +// Position defines where the progress overlay box is positioned on the screen. +type Position string + +const ( + // PositionCenter positions the overlay in the center of the screen. + PositionCenter Position = "center" + // PositionTop positions the overlay in the top portion of the screen. + PositionTop Position = "top" + // PositionBottom positions the overlay in the bottom portion of the screen. + PositionBottom Position = "bottom" +) + +// Config defines the configuration for the ProvisioningUI manager. +type Config struct { + TempDir string + BaseImagePath string + BaseImage image.Image + BgColor color.Color + TitlePrefix string + FooterText string + ErrorTitle string + ErrorFooterText string + OverlayPosition Position +} + +// ProvisioningUI provides dummy implementations for non-Windows build environments (e.g., Linux wildcard builds). +type ProvisioningUI struct{} + +// NewProvisioningUI returns an error on non-Windows platforms. +func NewProvisioningUI(ctx context.Context, cfg Config) (*ProvisioningUI, error) { + return nil, errors.New("ProvisioningUI is only supported on Windows") +} + +// UpdateLockScreen returns an error on non-Windows platforms. +func (ui *ProvisioningUI) UpdateLockScreen(ctx context.Context, step int, message string) error { + return errors.New("ProvisioningUI is only supported on Windows") +} + +// UpdateLockScreenError returns an error on non-Windows platforms. +func (ui *ProvisioningUI) UpdateLockScreenError(ctx context.Context, step int, errorMessage string) error { + return errors.New("ProvisioningUI is only supported on Windows") +} + +// Cleanup returns nil on non-Windows platforms. +func (ui *ProvisioningUI) Cleanup(ctx context.Context) error { + return nil +} diff --git a/go/lockscreenui/lockscreenui_test.go b/go/lockscreenui/lockscreenui_test.go new file mode 100644 index 00000000..0aff9d58 --- /dev/null +++ b/go/lockscreenui/lockscreenui_test.go @@ -0,0 +1,125 @@ +//go:build windows + +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package lockscreenui + +import ( + "golang.org/x/net/context" + "os" + "path/filepath" + "testing" +) + +func TestUpdateProgressGeneration(t *testing.T) { + ctx := context.Background() + outDir := os.Getenv("TEST_UNDECLARED_OUTPUTS_DIR") + if outDir == "" { + outDir = t.TempDir() + } + + tempScreensDir := filepath.Join(outDir, "generated_screens") + if err := os.MkdirAll(tempScreensDir, 0755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + + cfg := Config{ + TempDir: tempScreensDir, + TitlePrefix: "Provisioning: ", + FooterText: "System setup in progress - please wait", + } + + ui, err := NewProvisioningUI(ctx, cfg) + if err != nil { + t.Fatalf("NewProvisioningUI failed: %v", err) + } + + testMsg := "Stage 1 of 7: Initializing system and resizing disk..." + if err := ui.UpdateLockScreen(ctx, 1, testMsg); err != nil { + t.Logf("UpdateLockScreen note: %v", err) + } + + expectedImagePath := filepath.Join(tempScreensDir, "step_1.png") + if _, err := os.Stat(expectedImagePath); os.IsNotExist(err) { + t.Fatalf("Image artifact %s not generated, want it to exist", expectedImagePath) + } + + t.Logf("Successfully generated image artifact at: %s (preserved for Sponge download)", expectedImagePath) + + cleanupTestDir := filepath.Join(outDir, "cleanup_test_screens") + if err := os.MkdirAll(cleanupTestDir, 0755); err != nil { + t.Fatalf("MkdirAll for cleanup test failed: %v", err) + } + cleanupCfg := Config{TempDir: cleanupTestDir} + cleanupUI, err := NewProvisioningUI(ctx, cleanupCfg) + if err != nil { + t.Fatalf("NewProvisioningUI for cleanup test failed: %v", err) + } + if err := cleanupUI.UpdateLockScreen(ctx, 99, "Cleanup test"); err != nil { + t.Logf("UpdateLockScreen for cleanup test note: %v", err) + } + if err := cleanupUI.Cleanup(ctx); err != nil { + t.Logf("Cleanup note: %v", err) + } + if _, err := os.Stat(cleanupTestDir); !os.IsNotExist(err) { + t.Errorf("Directory %s still exists after Cleanup, want it to be deleted", cleanupTestDir) + } +} + +func TestUpdateErrorGeneration(t *testing.T) { + ctx := context.Background() + outDir := os.Getenv("TEST_UNDECLARED_OUTPUTS_DIR") + if outDir == "" { + outDir = t.TempDir() + } + + errorScreensDir := filepath.Join(outDir, "error_screens") + if err := os.MkdirAll(errorScreensDir, 0755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + + cfg := Config{ + TempDir: errorScreensDir, + ErrorTitle: "Provisioning Error (Step Failed)", + ErrorFooterText: "Please reboot the machine to retry provisioning", + } + + ui, err := NewProvisioningUI(ctx, cfg) + if err != nil { + t.Fatalf("NewProvisioningUI failed: %v", err) + } + + errMsg := "Stage stage_2 failed: simulated network timeout" + if err := ui.UpdateLockScreenError(ctx, 2, errMsg); err != nil { + t.Logf("UpdateLockScreenError note: %v", err) + } + + expectedImagePath := filepath.Join(errorScreensDir, "error_step_2.png") + if _, err := os.Stat(expectedImagePath); os.IsNotExist(err) { + t.Fatalf("Error image artifact %s not generated, want it to exist", expectedImagePath) + } + + t.Logf("Successfully generated error image artifact at: %s", expectedImagePath) + + if err := ui.Cleanup(ctx); err != nil { + t.Logf("Cleanup note: %v", err) + } + + if _, err := os.Stat(expectedImagePath); os.IsNotExist(err) { + t.Fatalf("Error image artifact %s was deleted by Cleanup, want it to be preserved", expectedImagePath) + } + + t.Logf("Error image artifact correctly preserved after Cleanup!") +}