package setupwizard import ( "context" "embed" "encoding/json" "fmt" "io/fs" "net" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "sync" "time" "github.com/pkg/browser" "github.com/wailsapp/wails/v3/internal/operatingsystem" "github.com/wailsapp/wails/v3/internal/version" "gopkg.in/yaml.v3" ) //go:embed frontend/dist/* var frontendFS embed.FS // DependencyStatus represents the status of a dependency type DependencyStatus struct { Name string `json:"name"` Installed bool `json:"installed"` Version string `json:"version,omitempty"` Status string `json:"status"` // "installed", "not_installed", "needs_update" Required bool `json:"required"` Message string `json:"message,omitempty"` InstallCommand string `json:"installCommand,omitempty"` HelpURL string `json:"helpUrl,omitempty"` } // DockerStatus represents Docker installation and image status type DockerStatus struct { Installed bool `json:"installed"` Running bool `json:"running"` Version string `json:"version,omitempty"` ImageBuilt bool `json:"imageBuilt"` ImageName string `json:"imageName"` PullProgress int `json:"pullProgress"` PullStatus string `json:"pullStatus"` // "idle", "pulling", "complete", "error" PullError string `json:"pullError,omitempty"` } // WailsConfigInfo represents the info section of wails.yaml type WailsConfigInfo struct { CompanyName string `json:"companyName" yaml:"companyName"` ProductName string `json:"productName" yaml:"productName"` ProductIdentifier string `json:"productIdentifier" yaml:"productIdentifier"` Description string `json:"description" yaml:"description"` Copyright string `json:"copyright" yaml:"copyright"` Comments string `json:"comments,omitempty" yaml:"comments,omitempty"` Version string `json:"version" yaml:"version"` } // WailsConfig represents the wails.yaml configuration type WailsConfig struct { Info WailsConfigInfo `json:"info" yaml:"info"` } // SystemInfo contains detected system information type SystemInfo struct { OS string `json:"os"` Arch string `json:"arch"` WailsVersion string `json:"wailsVersion"` GoVersion string `json:"goVersion"` HomeDir string `json:"homeDir"` OSName string `json:"osName,omitempty"` OSVersion string `json:"osVersion,omitempty"` } // WizardState represents the complete wizard state type WizardState struct { Dependencies []DependencyStatus `json:"dependencies"` System SystemInfo `json:"system"` StartTime time.Time `json:"startTime"` } // Wizard is the setup wizard server type Wizard struct { server *http.Server state WizardState stateMu sync.RWMutex dockerStatus DockerStatus dockerMu sync.RWMutex done chan struct{} shutdown chan struct{} } // New creates a new setup wizard func New() *Wizard { return &Wizard{ done: make(chan struct{}), shutdown: make(chan struct{}), state: WizardState{ StartTime: time.Now(), }, } } // Run starts the wizard and opens it in the browser func (w *Wizard) Run() error { // Initialize system info w.initSystemInfo() // Find an available port listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return fmt.Errorf("failed to find available port: %w", err) } port := listener.Addr().(*net.TCPAddr).Port url := fmt.Sprintf("http://127.0.0.1:%d", port) // Set up HTTP routes mux := http.NewServeMux() w.setupRoutes(mux) w.server = &http.Server{ Handler: mux, } // Start server in goroutine go func() { if err := w.server.Serve(listener); err != nil && err != http.ErrServerClosed { fmt.Fprintf(os.Stderr, "Server error: %v\n", err) } }() fmt.Printf("Setup wizard running at %s\n", url) // Open browser if err := browser.OpenURL(url); err != nil { fmt.Printf("Please open %s in your browser\n", url) } // Wait for completion or shutdown select { case <-w.done: fmt.Println("\nSetup completed successfully!") case <-w.shutdown: fmt.Println("\nSetup wizard closed.") } // Shutdown server ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return w.server.Shutdown(ctx) } func (w *Wizard) setupRoutes(mux *http.ServeMux) { // API routes mux.HandleFunc("/api/state", w.handleState) mux.HandleFunc("/api/dependencies/check", w.handleCheckDependencies) mux.HandleFunc("/api/dependencies/install", w.handleInstallDependency) mux.HandleFunc("/api/docker/status", w.handleDockerStatus) mux.HandleFunc("/api/docker/build", w.handleDockerBuild) mux.HandleFunc("/api/docker/start-background", w.handleDockerStartBackground) mux.HandleFunc("/api/wails-config", w.handleWailsConfig) mux.HandleFunc("/api/defaults", w.handleDefaults) mux.HandleFunc("/api/complete", w.handleComplete) mux.HandleFunc("/api/close", w.handleClose) // Serve frontend frontendDist, err := fs.Sub(frontendFS, "frontend/dist") if err != nil { panic(err) } fileServer := http.FileServer(http.FS(frontendDist)) mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) { // Try to serve the file path := r.URL.Path if path == "/" { path = "/index.html" } // Check if file exists if _, err := fs.Stat(frontendDist, strings.TrimPrefix(path, "/")); err != nil { // Serve index.html for SPA routing r.URL.Path = "/" } fileServer.ServeHTTP(rw, r) }) } func (w *Wizard) initSystemInfo() { w.stateMu.Lock() defer w.stateMu.Unlock() homeDir, _ := os.UserHomeDir() w.state.System = SystemInfo{ OS: runtime.GOOS, Arch: runtime.GOARCH, WailsVersion: version.String(), GoVersion: runtime.Version(), HomeDir: homeDir, } // Get OS details if info, err := operatingsystem.Info(); err == nil { w.state.System.OSName = info.Name w.state.System.OSVersion = info.Version } } func (w *Wizard) handleState(rw http.ResponseWriter, r *http.Request) { w.stateMu.RLock() defer w.stateMu.RUnlock() rw.Header().Set("Content-Type", "application/json") json.NewEncoder(rw).Encode(w.state) } func (w *Wizard) handleCheckDependencies(rw http.ResponseWriter, r *http.Request) { deps := w.checkAllDependencies() w.stateMu.Lock() w.state.Dependencies = deps w.stateMu.Unlock() rw.Header().Set("Content-Type", "application/json") json.NewEncoder(rw).Encode(deps) } func (w *Wizard) handleWailsConfig(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "application/json") // Find wails.yaml in current directory or parent directories configPath := findWailsConfig() switch r.Method { case http.MethodGet: if configPath == "" { json.NewEncoder(rw).Encode(nil) return } data, err := os.ReadFile(configPath) if err != nil { json.NewEncoder(rw).Encode(nil) return } var config WailsConfig if err := yaml.Unmarshal(data, &config); err != nil { json.NewEncoder(rw).Encode(nil) return } json.NewEncoder(rw).Encode(config) case http.MethodPost: var config WailsConfig if err := json.NewDecoder(r.Body).Decode(&config); err != nil { http.Error(rw, err.Error(), http.StatusBadRequest) return } if configPath == "" { configPath = "wails.yaml" } data, err := yaml.Marshal(&config) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } if err := os.WriteFile(configPath, data, 0644); err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } json.NewEncoder(rw).Encode(map[string]string{"status": "saved", "path": configPath}) default: http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed) } } func findWailsConfig() string { dir, err := os.Getwd() if err != nil { return "" } for { configPath := filepath.Join(dir, "wails.yaml") if _, err := os.Stat(configPath); err == nil { return configPath } parent := filepath.Dir(dir) if parent == dir { break } dir = parent } return "" } func (w *Wizard) handleComplete(rw http.ResponseWriter, r *http.Request) { w.stateMu.RLock() state := w.state w.stateMu.RUnlock() duration := time.Since(state.StartTime) response := map[string]interface{}{ "status": "complete", "duration": duration.String(), } rw.Header().Set("Content-Type", "application/json") json.NewEncoder(rw).Encode(response) close(w.done) } func (w *Wizard) handleClose(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "application/json") json.NewEncoder(rw).Encode(map[string]string{"status": "closing"}) close(w.shutdown) } // execCommand runs a command and returns its output func execCommand(name string, args ...string) (string, error) { cmd := exec.Command(name, args...) output, err := cmd.Output() return strings.TrimSpace(string(output)), err } // commandExists checks if a command exists in PATH func commandExists(name string) bool { _, err := exec.LookPath(name) return err == nil } func (w *Wizard) handleDockerStatus(rw http.ResponseWriter, r *http.Request) { status := w.checkDocker() w.dockerMu.Lock() w.dockerStatus = status w.dockerMu.Unlock() rw.Header().Set("Content-Type", "application/json") json.NewEncoder(rw).Encode(status) } func (w *Wizard) checkDocker() DockerStatus { status := DockerStatus{ ImageName: "wails-cross", PullStatus: "idle", } // Check if Docker is installed output, err := execCommand("docker", "--version") if err != nil { status.Installed = false return status } status.Installed = true // Parse version from "Docker version 24.0.7, build afdd53b" parts := strings.Split(output, ",") if len(parts) > 0 { status.Version = strings.TrimPrefix(strings.TrimSpace(parts[0]), "Docker version ") } // Check if Docker daemon is running if _, err := execCommand("docker", "info"); err != nil { status.Running = false return status } status.Running = true // Check if wails-cross image exists imageOutput, err := execCommand("docker", "image", "inspect", "wails-cross") status.ImageBuilt = err == nil && len(imageOutput) > 0 return status } func (w *Wizard) handleDockerBuild(rw http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed) return } w.dockerMu.Lock() w.dockerStatus.PullStatus = "pulling" w.dockerStatus.PullProgress = 0 w.dockerMu.Unlock() // Build the Docker image in background go func() { // Run: wails3 task setup:docker cmd := exec.Command("wails3", "task", "setup:docker") err := cmd.Run() w.dockerMu.Lock() if err != nil { w.dockerStatus.PullStatus = "error" w.dockerStatus.PullError = err.Error() } else { w.dockerStatus.PullStatus = "complete" w.dockerStatus.ImageBuilt = true } w.dockerStatus.PullProgress = 100 w.dockerMu.Unlock() }() // Simulate progress updates while building go func() { for i := 0; i < 90; i += 5 { time.Sleep(2 * time.Second) w.dockerMu.Lock() if w.dockerStatus.PullStatus != "pulling" { w.dockerMu.Unlock() return } w.dockerStatus.PullProgress = i w.dockerMu.Unlock() } }() rw.Header().Set("Content-Type", "application/json") json.NewEncoder(rw).Encode(map[string]string{"status": "started"}) } // handleDockerStartBackground checks if Docker is available and starts building in background // This is called early in the wizard flow to get a head start on the image build func (w *Wizard) handleDockerStartBackground(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "application/json") // Check Docker status first status := w.checkDocker() w.dockerMu.Lock() w.dockerStatus = status w.dockerMu.Unlock() // Only start build if Docker is installed, running, and image not built yet if !status.Installed || !status.Running || status.ImageBuilt { json.NewEncoder(rw).Encode(map[string]interface{}{ "started": false, "reason": getDockerNotStartedReason(status), "status": status, }) return } // Check if already building w.dockerMu.RLock() alreadyBuilding := w.dockerStatus.PullStatus == "pulling" w.dockerMu.RUnlock() if alreadyBuilding { json.NewEncoder(rw).Encode(map[string]interface{}{ "started": false, "reason": "already_building", "status": status, }) return } // Start building in background w.dockerMu.Lock() w.dockerStatus.PullStatus = "pulling" w.dockerStatus.PullProgress = 0 w.dockerMu.Unlock() // Build the Docker image in background go func() { cmd := exec.Command("wails3", "task", "setup:docker") err := cmd.Run() w.dockerMu.Lock() if err != nil { w.dockerStatus.PullStatus = "error" w.dockerStatus.PullError = err.Error() } else { w.dockerStatus.PullStatus = "complete" w.dockerStatus.ImageBuilt = true } w.dockerStatus.PullProgress = 100 w.dockerMu.Unlock() }() // Simulate progress updates while building go func() { for i := 0; i < 90; i += 5 { time.Sleep(2 * time.Second) w.dockerMu.Lock() if w.dockerStatus.PullStatus != "pulling" { w.dockerMu.Unlock() return } w.dockerStatus.PullProgress = i w.dockerMu.Unlock() } }() json.NewEncoder(rw).Encode(map[string]interface{}{ "started": true, "status": status, }) } func getDockerNotStartedReason(status DockerStatus) string { if !status.Installed { return "not_installed" } if !status.Running { return "not_running" } if status.ImageBuilt { return "already_built" } return "unknown" } // InstallRequest represents a request to install a dependency type InstallRequest struct { Command string `json:"command"` } // InstallResponse represents the result of an install attempt type InstallResponse struct { Success bool `json:"success"` Output string `json:"output"` Error string `json:"error,omitempty"` } // allowedCommands is a whitelist of commands that can be executed for dependency installation. // This prevents arbitrary command execution even though commands originate from backend detection. var allowedCommands = map[string]bool{ // Package managers (may be called directly or via sudo) "apt": true, "apt-get": true, "apk": true, // Alpine Linux "dnf": true, "yum": true, "pacman": true, "zypper": true, "emerge": true, "eopkg": true, "nix-env": true, "brew": true, "port": true, // MacPorts "winget": true, "choco": true, "scoop": true, "snap": true, "flatpak": true, "xcode-select": true, // macOS Xcode CLI tools // Privilege escalation (validated separately) "sudo": true, "pkexec": true, "doas": true, } // allowedSudoCommands are commands allowed to be run after sudo/pkexec/doas. // This is a subset of allowedCommands - privilege escalation wrappers are not allowed here. var allowedSudoCommands = map[string]bool{ "apt": true, "apt-get": true, "apk": true, "dnf": true, "yum": true, "pacman": true, "zypper": true, "emerge": true, "eopkg": true, "nix-env": true, "brew": true, "port": true, "snap": true, "flatpak": true, "xcode-select": true, } // safeCommandLookup maps user input command names to safe executable names. // This allows CodeQL to trace that executed commands come from a static whitelist. var safeCommandLookup = map[string]string{ "apt": "apt", "apt-get": "apt-get", "apk": "apk", "dnf": "dnf", "yum": "yum", "pacman": "pacman", "zypper": "zypper", "emerge": "emerge", "eopkg": "eopkg", "nix-env": "nix-env", "brew": "brew", "port": "port", "winget": "winget", "choco": "choco", "scoop": "scoop", "snap": "snap", "flatpak": "flatpak", "xcode-select": "xcode-select", "sudo": "sudo", "pkexec": "pkexec", "doas": "doas", } // getSafeCommand validates and returns a safe command from the whitelist. // Returns the safe command name and true if valid, or empty string and false if not. // For sudo/pkexec/doas, also returns the safe elevated command. func getSafeCommand(parts []string) (safeCmd string, safeElevatedCmd string, args []string, ok bool) { if len(parts) == 0 { return "", "", nil, false } cmd := parts[0] safeCmd, ok = safeCommandLookup[cmd] if !ok || !allowedCommands[cmd] { return "", "", nil, false } // If it's a privilege escalation command, validate the actual command if cmd == "sudo" || cmd == "pkexec" || cmd == "doas" { if len(parts) < 2 { return "", "", nil, false } // Reject any flags before the command to prevent bypass attacks // like "sudo -u apt bash" where -u takes "apt" as its argument actualCmd := parts[1] if strings.HasPrefix(actualCmd, "-") { return "", "", nil, false } safeElevatedCmd, ok = safeCommandLookup[actualCmd] if !ok || !allowedSudoCommands[actualCmd] { return "", "", nil, false } // Return: sudo/pkexec/doas, the elevated command, remaining args return safeCmd, safeElevatedCmd, parts[2:], true } // Return: command, empty elevated cmd, remaining args return safeCmd, "", parts[1:], true } // isCommandAllowed checks if a command is in the whitelist. // For sudo/pkexec/doas, it validates the actual command being elevated. // To avoid bypass attacks via flag parsing (e.g., "sudo -u apt bash"), // we reject any sudo invocation where the second argument starts with "-". func isCommandAllowed(parts []string) bool { _, _, _, ok := getSafeCommand(parts) return ok } // handleInstallDependency executes dependency installation commands. // // Security note: This endpoint executes commands that originate from the backend's // package manager detection (see packagemanager.InstallCommand). The commands are // validated against a whitelist of allowed package managers before execution. func (w *Wizard) handleInstallDependency(rw http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed) return } var req InstallRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(rw, err.Error(), http.StatusBadRequest) return } rw.Header().Set("Content-Type", "application/json") // Execute the install command // Split the command into parts parts := strings.Fields(req.Command) if len(parts) == 0 { json.NewEncoder(rw).Encode(InstallResponse{ Success: false, Error: "Empty command", }) return } // Get safe command from whitelist - this ensures the executable comes from // a static whitelist, not from user input safeCmd, safeElevatedCmd, remainingArgs, ok := getSafeCommand(parts) if !ok { json.NewEncoder(rw).Encode(InstallResponse{ Success: false, Error: "Command not allowed: only package manager commands are permitted", }) return } // Build command arguments using safe values from the whitelist var cmdArgs []string if safeElevatedCmd != "" { // For sudo/pkexec/doas: use safe elevated command from whitelist cmdArgs = append([]string{safeElevatedCmd}, remainingArgs...) } else { cmdArgs = remainingArgs } cmd := exec.Command(safeCmd, cmdArgs...) // #nosec G204 -- safeCmd comes from safeCommandLookup whitelist output, err := cmd.CombinedOutput() if err != nil { json.NewEncoder(rw).Encode(InstallResponse{ Success: false, Output: string(output), Error: err.Error(), }) return } json.NewEncoder(rw).Encode(InstallResponse{ Success: true, Output: string(output), }) } func (w *Wizard) handleDefaults(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "application/json") switch r.Method { case http.MethodGet: defaults, err := LoadGlobalDefaults() if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } // Try to pre-populate author info from git config if empty if defaults.Author.Name == "" { if name, err := execCommand("git", "config", "--global", "user.name"); err == nil && name != "" { defaults.Author.Name = name } } json.NewEncoder(rw).Encode(defaults) case http.MethodPost: var defaults GlobalDefaults if err := json.NewDecoder(r.Body).Decode(&defaults); err != nil { http.Error(rw, err.Error(), http.StatusBadRequest) return } if err := SaveGlobalDefaults(defaults); err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } path, _ := GetDefaultsPath() json.NewEncoder(rw).Encode(map[string]string{"status": "saved", "path": path}) default: http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed) } }