mirror of
https://github.com/wailsapp/wails.git
synced 2026-03-14 22:55:48 +01:00
Add comment explaining that pullViaDockerAPI uses API v1.44 (Docker 25.0+) and that older versions gracefully fall back to CLI-based pulling. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1385 lines
36 KiB
Go
1385 lines
36 KiB
Go
package setupwizard
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"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
|
|
|
|
//go:embed assets/apple-sdk-license.pdf
|
|
var appleLicensePDF []byte
|
|
|
|
// 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"`
|
|
ImageBuilt bool `json:"imageBuilt"` // For Docker: whether wails-cross image exists
|
|
}
|
|
|
|
// DockerStatus represents Docker installation and image status
|
|
type PullProgress struct {
|
|
Stage string
|
|
Progress int
|
|
}
|
|
|
|
type pullParser struct {
|
|
layerSizes map[string]float64
|
|
layerDownloaded map[string]float64
|
|
layerComplete map[string]bool
|
|
layersPending map[string]bool
|
|
stage string
|
|
}
|
|
|
|
func newPullParser() *pullParser {
|
|
return &pullParser{
|
|
layerSizes: make(map[string]float64),
|
|
layerDownloaded: make(map[string]float64),
|
|
layerComplete: make(map[string]bool),
|
|
layersPending: make(map[string]bool),
|
|
stage: "Connecting",
|
|
}
|
|
}
|
|
|
|
func parseSize(s string) float64 {
|
|
s = strings.TrimSpace(s)
|
|
var val float64
|
|
var unit string
|
|
fmt.Sscanf(s, "%f%s", &val, &unit)
|
|
switch strings.ToUpper(unit) {
|
|
case "KB", "KIB":
|
|
return val * 1024
|
|
case "MB", "MIB":
|
|
return val * 1024 * 1024
|
|
case "GB", "GIB":
|
|
return val * 1024 * 1024 * 1024
|
|
case "B":
|
|
return val
|
|
}
|
|
return val
|
|
}
|
|
|
|
var sizeRegex = regexp.MustCompile(`(\d+(?:\.\d+)?[KMGT]?i?B)/(\d+(?:\.\d+)?[KMGT]?i?B)`)
|
|
|
|
func (p *pullParser) ParseLine(line string) PullProgress {
|
|
if len(line) == 0 {
|
|
return PullProgress{Stage: p.stage, Progress: p.calculateProgress()}
|
|
}
|
|
|
|
parts := strings.Fields(line)
|
|
if len(parts) == 0 {
|
|
return PullProgress{Stage: p.stage, Progress: p.calculateProgress()}
|
|
}
|
|
|
|
layerID := strings.TrimSuffix(parts[0], ":")
|
|
|
|
switch {
|
|
case strings.Contains(line, "Pulling from"):
|
|
p.stage = "Connecting"
|
|
case strings.Contains(line, "Pulling fs layer"):
|
|
p.stage = "Downloading"
|
|
p.layersPending[layerID] = true
|
|
case strings.Contains(line, "Waiting"):
|
|
p.stage = "Downloading"
|
|
p.layersPending[layerID] = true
|
|
case strings.Contains(line, "Downloading"):
|
|
p.stage = "Downloading"
|
|
p.layersPending[layerID] = true
|
|
if matches := sizeRegex.FindStringSubmatch(line); len(matches) == 3 {
|
|
p.layerDownloaded[layerID] = parseSize(matches[1])
|
|
p.layerSizes[layerID] = parseSize(matches[2])
|
|
}
|
|
case strings.Contains(line, "Verifying"):
|
|
p.stage = "Verifying"
|
|
case strings.Contains(line, "Download complete"):
|
|
p.stage = "Extracting"
|
|
if size, ok := p.layerSizes[layerID]; ok {
|
|
p.layerDownloaded[layerID] = size
|
|
}
|
|
case strings.Contains(line, "Extracting"):
|
|
p.stage = "Extracting"
|
|
if matches := sizeRegex.FindStringSubmatch(line); len(matches) == 3 {
|
|
p.layerDownloaded[layerID] = parseSize(matches[1])
|
|
p.layerSizes[layerID] = parseSize(matches[2])
|
|
}
|
|
case strings.Contains(line, "Pull complete"):
|
|
p.stage = "Extracting"
|
|
p.layerComplete[layerID] = true
|
|
delete(p.layersPending, layerID)
|
|
if size, ok := p.layerSizes[layerID]; ok {
|
|
p.layerDownloaded[layerID] = size
|
|
}
|
|
}
|
|
|
|
return PullProgress{Stage: p.stage, Progress: p.calculateProgress()}
|
|
}
|
|
|
|
func (p *pullParser) calculateProgress() int {
|
|
totalLayers := len(p.layersPending) + len(p.layerComplete)
|
|
if totalLayers == 0 {
|
|
return 0
|
|
}
|
|
|
|
var totalSize, downloaded float64
|
|
hasSizeInfo := len(p.layerSizes) > 0
|
|
|
|
if hasSizeInfo {
|
|
for id, size := range p.layerSizes {
|
|
totalSize += size
|
|
if p.layerComplete[id] {
|
|
downloaded += size
|
|
} else if dl, ok := p.layerDownloaded[id]; ok {
|
|
downloaded += dl
|
|
}
|
|
}
|
|
if totalSize > 0 {
|
|
progress := int((downloaded / totalSize) * 100)
|
|
if progress > 95 && len(p.layerComplete) < len(p.layerSizes) {
|
|
progress = 95
|
|
}
|
|
return progress
|
|
}
|
|
}
|
|
|
|
progress := (len(p.layerComplete) * 100) / totalLayers
|
|
if progress > 95 && len(p.layersPending) > 0 {
|
|
progress = 95
|
|
}
|
|
return progress
|
|
}
|
|
|
|
type DockerStatus struct {
|
|
Installed bool `json:"installed"`
|
|
Running bool `json:"running"`
|
|
Version string `json:"version,omitempty"`
|
|
ImageBuilt bool `json:"imageBuilt"`
|
|
ImageName string `json:"imageName"`
|
|
ImageSize string `json:"imageSize,omitempty"`
|
|
ImageVersion string `json:"imageVersion,omitempty"`
|
|
SDKVersion string `json:"sdkVersion,omitempty"`
|
|
UpdateAvail bool `json:"updateAvailable"`
|
|
LatestVersion string `json:"latestVersion,omitempty"`
|
|
PullProgress int `json:"pullProgress"`
|
|
PullMessage string `json:"pullMessage,omitempty"`
|
|
PullStatus string `json:"pullStatus"`
|
|
PullError string `json:"pullError,omitempty"`
|
|
BytesTotal int64 `json:"bytesTotal,omitempty"`
|
|
BytesDone int64 `json:"bytesDone,omitempty"`
|
|
LayerCount int `json:"layerCount,omitempty"`
|
|
LayersDone int `json:"layersDone,omitempty"`
|
|
}
|
|
|
|
const crossImageName = "ghcr.io/wailsapp/wails-cross"
|
|
|
|
// 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
|
|
dockerBuildLogs string
|
|
dockerMu sync.RWMutex
|
|
done chan struct{}
|
|
shutdown chan struct{}
|
|
shutdownOnce sync.Once
|
|
buildWg sync.WaitGroup
|
|
}
|
|
|
|
// 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/status/stream", w.handleDockerStatusStream)
|
|
mux.HandleFunc("/api/docker/build", w.handleDockerBuild)
|
|
mux.HandleFunc("/api/docker/logs", w.handleDockerLogs)
|
|
mux.HandleFunc("/api/docker/start-background", w.handleDockerStartBackground)
|
|
mux.HandleFunc("/api/wails-config", w.handleWailsConfig)
|
|
mux.HandleFunc("/api/defaults", w.handleDefaults)
|
|
mux.HandleFunc("/api/signing", w.handleSigning)
|
|
mux.HandleFunc("/api/signing/status", w.handleSigningStatus)
|
|
mux.HandleFunc("/api/complete", w.handleComplete)
|
|
mux.HandleFunc("/api/close", w.handleClose)
|
|
mux.HandleFunc("/api/report-bug", w.handleReportBug)
|
|
|
|
mux.HandleFunc("/assets/apple-sdk-license.pdf", func(rw http.ResponseWriter, r *http.Request) {
|
|
rw.Header().Set("Content-Type", "application/pdf")
|
|
rw.Write(appleLicensePDF)
|
|
})
|
|
|
|
// 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")
|
|
|
|
// Check if Docker build is in progress
|
|
w.dockerMu.RLock()
|
|
dockerBuilding := w.dockerStatus.PullStatus == "pulling"
|
|
w.dockerMu.RUnlock()
|
|
|
|
response := map[string]interface{}{
|
|
"status": "closing",
|
|
"dockerBuilding": dockerBuilding,
|
|
}
|
|
if dockerBuilding {
|
|
response["message"] = "Docker image build will continue in the background"
|
|
}
|
|
json.NewEncoder(rw).Encode(response)
|
|
|
|
// Wait for any running Docker builds to complete before shutting down
|
|
go func() {
|
|
w.buildWg.Wait()
|
|
w.shutdownOnce.Do(func() { close(w.shutdown) })
|
|
}()
|
|
}
|
|
|
|
func (w *Wizard) handleReportBug(rw http.ResponseWriter, r *http.Request) {
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
|
|
// Get current step from query parameter
|
|
currentStep := r.URL.Query().Get("step")
|
|
if currentStep == "" {
|
|
currentStep = "unknown"
|
|
}
|
|
|
|
// Gather system info
|
|
w.stateMu.RLock()
|
|
system := w.state.System
|
|
w.stateMu.RUnlock()
|
|
|
|
// Build a concise comment body - description first, then details table
|
|
var sb strings.Builder
|
|
sb.WriteString("**What went wrong?**\n\n\n\n")
|
|
sb.WriteString("**What were you doing when the issue occurred?**\n\n\n\n")
|
|
sb.WriteString("---\n\n")
|
|
sb.WriteString("| | |\n")
|
|
sb.WriteString("|--|--|\n")
|
|
sb.WriteString(fmt.Sprintf("| Platform | %s |\n", system.OS))
|
|
sb.WriteString(fmt.Sprintf("| Arch | %s |\n", system.Arch))
|
|
sb.WriteString(fmt.Sprintf("| Wails | %s |\n", system.WailsVersion))
|
|
sb.WriteString(fmt.Sprintf("| Go | %s |\n", system.GoVersion))
|
|
sb.WriteString(fmt.Sprintf("| Step | %s |\n", currentStep))
|
|
|
|
issueURL := "https://github.com/wailsapp/wails/issues/4904#issue-comment-box"
|
|
commentBody := sb.String()
|
|
|
|
// Return the body for the frontend to copy to clipboard
|
|
// Frontend will handle opening the browser after showing the overlay
|
|
json.NewEncoder(rw).Encode(map[string]interface{}{
|
|
"status": "ready",
|
|
"url": issueURL,
|
|
"body": commentBody,
|
|
})
|
|
}
|
|
|
|
// 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) {
|
|
// Get fresh Docker info (installed, running, image status)
|
|
freshStatus := w.checkDocker()
|
|
|
|
w.dockerMu.Lock()
|
|
if w.dockerStatus.PullStatus == "pulling" || w.dockerStatus.PullStatus == "complete" || w.dockerStatus.PullStatus == "error" {
|
|
freshStatus.PullStatus = w.dockerStatus.PullStatus
|
|
freshStatus.PullProgress = w.dockerStatus.PullProgress
|
|
freshStatus.PullMessage = w.dockerStatus.PullMessage
|
|
freshStatus.PullError = w.dockerStatus.PullError
|
|
freshStatus.BytesTotal = w.dockerStatus.BytesTotal
|
|
freshStatus.BytesDone = w.dockerStatus.BytesDone
|
|
freshStatus.LayerCount = w.dockerStatus.LayerCount
|
|
freshStatus.LayersDone = w.dockerStatus.LayersDone
|
|
}
|
|
w.dockerStatus = freshStatus
|
|
status := w.dockerStatus
|
|
w.dockerMu.Unlock()
|
|
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(rw).Encode(status)
|
|
}
|
|
|
|
func (w *Wizard) handleDockerStatusStream(rw http.ResponseWriter, r *http.Request) {
|
|
rw.Header().Set("Content-Type", "text/event-stream")
|
|
rw.Header().Set("Cache-Control", "no-cache")
|
|
rw.Header().Set("Connection", "keep-alive")
|
|
|
|
flusher, ok := rw.(http.Flusher)
|
|
if !ok {
|
|
http.Error(rw, "SSE not supported", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
sendStatus := func() (done bool) {
|
|
w.dockerMu.RLock()
|
|
status := w.dockerStatus
|
|
w.dockerMu.RUnlock()
|
|
|
|
data, _ := json.Marshal(status)
|
|
fmt.Fprintf(rw, "data: %s\n\n", data)
|
|
flusher.Flush()
|
|
|
|
return status.PullStatus == "complete" || status.PullStatus == "error"
|
|
}
|
|
|
|
if sendStatus() {
|
|
return
|
|
}
|
|
|
|
ticker := time.NewTicker(100 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
case <-ticker.C:
|
|
if sendStatus() {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (w *Wizard) checkDocker() DockerStatus {
|
|
status := DockerStatus{
|
|
ImageName: crossImageName,
|
|
PullStatus: "idle",
|
|
}
|
|
|
|
output, err := execCommand("docker", "--version")
|
|
if err != nil {
|
|
status.Installed = false
|
|
return status
|
|
}
|
|
|
|
status.Installed = true
|
|
parts := strings.Split(output, ",")
|
|
if len(parts) > 0 {
|
|
status.Version = strings.TrimPrefix(strings.TrimSpace(parts[0]), "Docker version ")
|
|
}
|
|
|
|
if _, err := execCommand("docker", "info"); err != nil {
|
|
status.Running = false
|
|
return status
|
|
}
|
|
status.Running = true
|
|
|
|
imageOutput, err := execCommand("docker", "image", "inspect", crossImageName)
|
|
status.ImageBuilt = err == nil && len(imageOutput) > 0
|
|
|
|
if status.ImageBuilt {
|
|
sizeOutput, err := execCommand("docker", "images", crossImageName, "--format", "{{.Size}}")
|
|
if err == nil && len(sizeOutput) > 0 {
|
|
status.ImageSize = strings.TrimSpace(sizeOutput)
|
|
}
|
|
versionOutput, err := execCommand("docker", "inspect", crossImageName, "--format", "{{index .Config.Labels \"org.opencontainers.image.version\"}}")
|
|
if err == nil && len(versionOutput) > 0 {
|
|
status.ImageVersion = strings.TrimSpace(versionOutput)
|
|
}
|
|
sdkOutput, err := execCommand("docker", "inspect", crossImageName, "--format", "{{index .Config.Labels \"io.wails.sdk.version\"}}")
|
|
if err == nil && len(sdkOutput) > 0 {
|
|
status.SDKVersion = strings.TrimSpace(sdkOutput)
|
|
}
|
|
}
|
|
|
|
return status
|
|
}
|
|
|
|
type dockerPullEvent struct {
|
|
Status string `json:"status"`
|
|
ID string `json:"id"`
|
|
Progress string `json:"progress"`
|
|
ProgressDetail struct {
|
|
Current int64 `json:"current"`
|
|
Total int64 `json:"total"`
|
|
} `json:"progressDetail"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
type layerProgress struct {
|
|
dlTotal int64
|
|
dlCurrent int64
|
|
dlDone bool
|
|
exTotal int64
|
|
exCurrent int64
|
|
exDone bool
|
|
}
|
|
|
|
func (w *Wizard) startDockerPull() {
|
|
w.dockerMu.Lock()
|
|
if w.dockerStatus.PullStatus == "pulling" {
|
|
w.dockerMu.Unlock()
|
|
return
|
|
}
|
|
w.dockerStatus.PullStatus = "pulling"
|
|
w.dockerStatus.PullProgress = 0
|
|
w.dockerStatus.PullMessage = "Connecting"
|
|
// Reset stale state from previous attempts
|
|
w.dockerStatus.PullError = ""
|
|
w.dockerStatus.BytesTotal = 0
|
|
w.dockerStatus.BytesDone = 0
|
|
w.dockerStatus.LayerCount = 0
|
|
w.dockerStatus.LayersDone = 0
|
|
w.dockerBuildLogs = ""
|
|
w.dockerMu.Unlock()
|
|
|
|
w.buildWg.Add(1)
|
|
go func() {
|
|
defer w.buildWg.Done()
|
|
|
|
if err := w.pullViaDockerAPI(); err != nil {
|
|
w.pullViaDockerCLI()
|
|
}
|
|
}()
|
|
}
|
|
|
|
// pullViaDockerAPI attempts to pull the image using Docker's HTTP API directly.
|
|
// This provides detailed progress tracking with layer-by-layer download status.
|
|
// Uses API v1.44 (Docker 25.0+). If this fails for any reason (older Docker version,
|
|
// permission issues, etc.), the caller falls back to pullViaDockerCLI which works
|
|
// with any Docker version but provides less detailed progress.
|
|
func (w *Wizard) pullViaDockerAPI() error {
|
|
socketPath := "/var/run/docker.sock"
|
|
if runtime.GOOS == "windows" {
|
|
socketPath = "//./pipe/docker_engine"
|
|
}
|
|
|
|
client := &http.Client{
|
|
Transport: &http.Transport{
|
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
if runtime.GOOS == "windows" {
|
|
return nil, fmt.Errorf("windows named pipes not supported, falling back to CLI")
|
|
}
|
|
return net.Dial("unix", socketPath)
|
|
},
|
|
},
|
|
Timeout: 30 * time.Minute,
|
|
}
|
|
|
|
imageParts := strings.SplitN(crossImageName, ":", 2)
|
|
imageName := imageParts[0]
|
|
tag := "latest"
|
|
if len(imageParts) > 1 {
|
|
tag = imageParts[1]
|
|
}
|
|
|
|
url := fmt.Sprintf("http://localhost/v1.44/images/create?fromImage=%s&tag=%s", imageName, tag)
|
|
req, err := http.NewRequest("POST", url, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to connect to Docker API: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("Docker API returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
layers := make(map[string]*layerProgress)
|
|
var logs strings.Builder
|
|
var maxTotal int64
|
|
decoder := json.NewDecoder(resp.Body)
|
|
|
|
for {
|
|
var event dockerPullEvent
|
|
if err := decoder.Decode(&event); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
return fmt.Errorf("docker API stream error: %w", err)
|
|
}
|
|
|
|
logs.WriteString(fmt.Sprintf("%s %s %s\n", event.ID, event.Status, event.Progress))
|
|
|
|
if event.Error != "" {
|
|
w.dockerMu.Lock()
|
|
w.dockerStatus.PullStatus = "error"
|
|
w.dockerStatus.PullError = event.Error
|
|
w.dockerStatus.PullMessage = "Failed"
|
|
w.dockerBuildLogs = logs.String()
|
|
w.dockerMu.Unlock()
|
|
return fmt.Errorf("docker pull error: %s", event.Error)
|
|
}
|
|
|
|
if event.ID != "" {
|
|
if layers[event.ID] == nil {
|
|
layers[event.ID] = &layerProgress{}
|
|
}
|
|
lp := layers[event.ID]
|
|
|
|
switch event.Status {
|
|
case "Downloading":
|
|
lp.dlCurrent = event.ProgressDetail.Current
|
|
if event.ProgressDetail.Total > 0 {
|
|
lp.dlTotal = event.ProgressDetail.Total
|
|
}
|
|
case "Download complete":
|
|
lp.dlDone = true
|
|
lp.dlCurrent = lp.dlTotal
|
|
case "Extracting":
|
|
lp.exCurrent = event.ProgressDetail.Current
|
|
if event.ProgressDetail.Total > 0 {
|
|
lp.exTotal = event.ProgressDetail.Total
|
|
}
|
|
case "Pull complete":
|
|
lp.dlDone = true
|
|
lp.exDone = true
|
|
lp.dlCurrent = lp.dlTotal
|
|
lp.exCurrent = lp.exTotal
|
|
case "Already exists":
|
|
lp.dlDone = true
|
|
lp.exDone = true
|
|
}
|
|
}
|
|
|
|
var dlTotal, dlDone, exTotal, exDone int64
|
|
var layerCount, dlComplete, exComplete int
|
|
for _, lp := range layers {
|
|
layerCount++
|
|
if lp.dlTotal > 0 {
|
|
dlTotal += lp.dlTotal
|
|
dlDone += lp.dlCurrent
|
|
}
|
|
if lp.exTotal > 0 {
|
|
exTotal += lp.exTotal
|
|
exDone += lp.exCurrent
|
|
}
|
|
if lp.dlDone {
|
|
dlComplete++
|
|
}
|
|
if lp.exDone {
|
|
exComplete++
|
|
}
|
|
}
|
|
|
|
if dlTotal > maxTotal {
|
|
maxTotal = dlTotal
|
|
}
|
|
|
|
var progress int
|
|
var message string
|
|
downloadDone := maxTotal > 0 && dlDone >= maxTotal
|
|
allExtracted := layerCount > 0 && exComplete == layerCount
|
|
|
|
if allExtracted {
|
|
progress = 100
|
|
message = "Finalizing"
|
|
} else if downloadDone {
|
|
if exTotal > 0 {
|
|
progress = 90 + int(exDone*10/exTotal)
|
|
} else if layerCount > 0 {
|
|
progress = 90 + (exComplete * 10 / layerCount)
|
|
} else {
|
|
progress = 95
|
|
}
|
|
message = "Extracting"
|
|
} else if maxTotal > 0 {
|
|
progress = int(dlDone * 90 / maxTotal)
|
|
message = fmt.Sprintf("%s/%s", formatBytesMB(dlDone), formatBytesMB(maxTotal))
|
|
} else if layerCount > 0 {
|
|
message = fmt.Sprintf("Preparing %d layers", layerCount)
|
|
} else {
|
|
message = "Connecting"
|
|
}
|
|
|
|
w.dockerMu.Lock()
|
|
w.dockerStatus.PullProgress = progress
|
|
w.dockerStatus.PullMessage = message
|
|
w.dockerStatus.BytesTotal = maxTotal
|
|
w.dockerStatus.BytesDone = dlDone
|
|
w.dockerStatus.LayerCount = layerCount
|
|
w.dockerStatus.LayersDone = exComplete
|
|
w.dockerMu.Unlock()
|
|
}
|
|
|
|
w.dockerMu.Lock()
|
|
w.dockerBuildLogs = logs.String()
|
|
w.dockerStatus.PullStatus = "complete"
|
|
w.dockerStatus.ImageBuilt = true
|
|
w.dockerStatus.PullProgress = 100
|
|
w.dockerStatus.PullMessage = "Complete"
|
|
if sizeOutput, sizeErr := execCommand("docker", "images", crossImageName, "--format", "{{.Size}}"); sizeErr == nil && len(sizeOutput) > 0 {
|
|
w.dockerStatus.ImageSize = strings.TrimSpace(sizeOutput)
|
|
}
|
|
w.dockerMu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (w *Wizard) pullViaDockerCLI() {
|
|
cmd := exec.Command("docker", "pull", crossImageName)
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
w.dockerMu.Lock()
|
|
w.dockerStatus.PullStatus = "error"
|
|
w.dockerStatus.PullError = fmt.Sprintf("Failed to create pipe: %v", err)
|
|
w.dockerMu.Unlock()
|
|
return
|
|
}
|
|
cmd.Stderr = cmd.Stdout
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
w.dockerMu.Lock()
|
|
w.dockerStatus.PullStatus = "error"
|
|
w.dockerStatus.PullError = fmt.Sprintf("Failed to start: %v", err)
|
|
w.dockerMu.Unlock()
|
|
return
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
var downloadDetected atomic.Bool
|
|
|
|
go func() {
|
|
ticker := time.NewTicker(300 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
progress := 0
|
|
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case <-ticker.C:
|
|
if downloadDetected.Load() && progress < 80 {
|
|
progress++
|
|
w.dockerMu.Lock()
|
|
w.dockerStatus.PullProgress = progress
|
|
w.dockerMu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
var lastOutput strings.Builder
|
|
buf := make([]byte, 4096)
|
|
|
|
for {
|
|
n, readErr := stdout.Read(buf)
|
|
if n > 0 {
|
|
chunk := string(buf[:n])
|
|
lastOutput.WriteString(chunk)
|
|
|
|
if !downloadDetected.Load() && (strings.Contains(chunk, "Pulling") || strings.Contains(chunk, "Downloading")) {
|
|
downloadDetected.Store(true)
|
|
w.dockerMu.Lock()
|
|
w.dockerStatus.PullMessage = "Downloading"
|
|
w.dockerMu.Unlock()
|
|
}
|
|
}
|
|
if readErr != nil {
|
|
break
|
|
}
|
|
}
|
|
|
|
close(done)
|
|
|
|
err = cmd.Wait()
|
|
w.dockerMu.Lock()
|
|
w.dockerBuildLogs = lastOutput.String()
|
|
if err != nil {
|
|
w.dockerStatus.PullStatus = "error"
|
|
w.dockerStatus.PullError = fmt.Sprintf("Pull failed: %v", err)
|
|
w.dockerStatus.PullMessage = "Failed"
|
|
} else {
|
|
w.dockerStatus.PullStatus = "complete"
|
|
w.dockerStatus.ImageBuilt = true
|
|
w.dockerStatus.PullProgress = 100
|
|
w.dockerStatus.PullMessage = "Complete"
|
|
if sizeOutput, sizeErr := execCommand("docker", "images", crossImageName, "--format", "{{.Size}}"); sizeErr == nil && len(sizeOutput) > 0 {
|
|
w.dockerStatus.ImageSize = strings.TrimSpace(sizeOutput)
|
|
}
|
|
}
|
|
w.dockerMu.Unlock()
|
|
}
|
|
|
|
func formatBytesMB(b int64) string {
|
|
mb := float64(b) / (1024 * 1024)
|
|
if mb < 1 {
|
|
return fmt.Sprintf("%.1f MB", mb)
|
|
}
|
|
return fmt.Sprintf("%.0f MB", mb)
|
|
}
|
|
|
|
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.startDockerPull()
|
|
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(rw).Encode(map[string]string{"status": "started"})
|
|
}
|
|
|
|
func (w *Wizard) handleDockerLogs(rw http.ResponseWriter, r *http.Request) {
|
|
w.dockerMu.RLock()
|
|
logs := w.dockerBuildLogs
|
|
w.dockerMu.RUnlock()
|
|
|
|
rw.Header().Set("Content-Type", "text/plain")
|
|
rw.Write([]byte(logs))
|
|
}
|
|
|
|
func (w *Wizard) handleDockerStartBackground(rw http.ResponseWriter, r *http.Request) {
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
|
|
status := w.checkDocker()
|
|
|
|
w.dockerMu.Lock()
|
|
w.dockerStatus = status
|
|
w.dockerMu.Unlock()
|
|
|
|
if !status.Installed || !status.Running || status.ImageBuilt {
|
|
json.NewEncoder(rw).Encode(map[string]interface{}{
|
|
"started": false,
|
|
"reason": getDockerNotStartedReason(status),
|
|
"status": status,
|
|
})
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
w.startDockerPull()
|
|
|
|
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"`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
cmd := exec.Command(parts[0], parts[1:]...)
|
|
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)
|
|
}
|
|
}
|
|
|
|
func (w *Wizard) handleSigningStatus(rw http.ResponseWriter, r *http.Request) {
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
|
|
status := checkSigningStatus()
|
|
json.NewEncoder(rw).Encode(status)
|
|
}
|
|
|
|
func (w *Wizard) handleSigning(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
|
|
}
|
|
json.NewEncoder(rw).Encode(defaults.Signing)
|
|
|
|
case http.MethodPost:
|
|
var signing SigningDefaults
|
|
if err := json.NewDecoder(r.Body).Decode(&signing); err != nil {
|
|
http.Error(rw, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
defaults, err := LoadGlobalDefaults()
|
|
if err != nil {
|
|
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
defaults.Signing = signing
|
|
|
|
if err := SaveGlobalDefaults(defaults); err != nil {
|
|
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
json.NewEncoder(rw).Encode(map[string]string{"status": "saved"})
|
|
|
|
default:
|
|
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
type signingStatusResponse struct {
|
|
Darwin darwinSigningStatus `json:"darwin"`
|
|
Windows windowsSigningStatus `json:"windows"`
|
|
Linux linuxSigningStatus `json:"linux"`
|
|
ConfigError string `json:"configError,omitempty"`
|
|
}
|
|
|
|
type darwinSigningStatus struct {
|
|
HasIdentity bool `json:"hasIdentity"`
|
|
Identity string `json:"identity,omitempty"`
|
|
Identities []string `json:"identities,omitempty"`
|
|
HasNotarization bool `json:"hasNotarization"`
|
|
TeamID string `json:"teamID,omitempty"`
|
|
ConfigSource string `json:"configSource,omitempty"`
|
|
}
|
|
|
|
type windowsSigningStatus struct {
|
|
HasCertificate bool `json:"hasCertificate"`
|
|
CertificateType string `json:"certificateType,omitempty"`
|
|
HasSignTool bool `json:"hasSignTool"`
|
|
TimestampServer string `json:"timestampServer,omitempty"`
|
|
ConfigSource string `json:"configSource,omitempty"`
|
|
}
|
|
|
|
type linuxSigningStatus struct {
|
|
HasGPGKey bool `json:"hasGpgKey"`
|
|
GPGKeyID string `json:"gpgKeyID,omitempty"`
|
|
ConfigSource string `json:"configSource,omitempty"`
|
|
}
|
|
|
|
func checkSigningStatus() signingStatusResponse {
|
|
globalDefaults, err := LoadGlobalDefaults()
|
|
|
|
resp := signingStatusResponse{
|
|
Darwin: checkDarwinSigningStatus(globalDefaults),
|
|
Windows: checkWindowsSigningStatus(globalDefaults),
|
|
Linux: checkLinuxSigningStatus(globalDefaults),
|
|
}
|
|
|
|
if err != nil {
|
|
resp.ConfigError = err.Error()
|
|
}
|
|
|
|
return resp
|
|
}
|
|
|
|
func checkDarwinSigningStatus(cfg GlobalDefaults) darwinSigningStatus {
|
|
status := darwinSigningStatus{}
|
|
|
|
if cfg.Signing.Darwin.Identity != "" {
|
|
status.HasIdentity = true
|
|
status.Identity = cfg.Signing.Darwin.Identity
|
|
status.TeamID = cfg.Signing.Darwin.TeamID
|
|
status.ConfigSource = "defaults.yaml"
|
|
}
|
|
|
|
if cfg.Signing.Darwin.KeychainProfile != "" || cfg.Signing.Darwin.APIKeyID != "" {
|
|
status.HasNotarization = true
|
|
}
|
|
|
|
if runtime.GOOS == "darwin" {
|
|
identities := getMacOSSigningIdentities()
|
|
status.Identities = identities
|
|
if len(identities) > 0 && !status.HasIdentity {
|
|
status.HasIdentity = true
|
|
status.Identity = identities[0]
|
|
status.ConfigSource = "keychain"
|
|
}
|
|
}
|
|
|
|
return status
|
|
}
|
|
|
|
func checkWindowsSigningStatus(cfg GlobalDefaults) windowsSigningStatus {
|
|
status := windowsSigningStatus{}
|
|
|
|
if cfg.Signing.Windows.CertificatePath != "" {
|
|
status.HasCertificate = true
|
|
status.CertificateType = "file"
|
|
status.ConfigSource = "defaults.yaml"
|
|
} else if cfg.Signing.Windows.Thumbprint != "" {
|
|
status.HasCertificate = true
|
|
status.CertificateType = "store"
|
|
status.ConfigSource = "defaults.yaml"
|
|
} else if cfg.Signing.Windows.CloudProvider != "" {
|
|
status.HasCertificate = true
|
|
status.CertificateType = "cloud:" + cfg.Signing.Windows.CloudProvider
|
|
status.ConfigSource = "defaults.yaml"
|
|
}
|
|
|
|
status.TimestampServer = cfg.Signing.Windows.TimestampServer
|
|
if status.TimestampServer == "" {
|
|
status.TimestampServer = "http://timestamp.digicert.com"
|
|
}
|
|
|
|
if runtime.GOOS == "windows" {
|
|
_, err := exec.LookPath("signtool.exe")
|
|
status.HasSignTool = err == nil
|
|
}
|
|
|
|
return status
|
|
}
|
|
|
|
func checkLinuxSigningStatus(cfg GlobalDefaults) linuxSigningStatus {
|
|
status := linuxSigningStatus{}
|
|
|
|
if cfg.Signing.Linux.GPGKeyPath != "" {
|
|
status.HasGPGKey = true
|
|
status.GPGKeyID = cfg.Signing.Linux.GPGKeyID
|
|
status.ConfigSource = "defaults.yaml"
|
|
}
|
|
|
|
if !status.HasGPGKey {
|
|
keyID := getDefaultGPGKey()
|
|
if keyID != "" {
|
|
status.HasGPGKey = true
|
|
status.GPGKeyID = keyID
|
|
status.ConfigSource = "gpg"
|
|
}
|
|
}
|
|
|
|
return status
|
|
}
|
|
|
|
func getMacOSSigningIdentities() []string {
|
|
if runtime.GOOS != "darwin" {
|
|
return nil
|
|
}
|
|
|
|
cmd := exec.Command("security", "find-identity", "-v", "-p", "codesigning")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
var identities []string
|
|
lines := strings.Split(string(output), "\n")
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "\"") && strings.Contains(line, "Developer ID") {
|
|
start := strings.Index(line, "\"")
|
|
end := strings.LastIndex(line, "\"")
|
|
if start != -1 && end > start {
|
|
identity := line[start+1 : end]
|
|
identities = append(identities, identity)
|
|
}
|
|
}
|
|
}
|
|
|
|
return identities
|
|
}
|
|
|
|
func getDefaultGPGKey() string {
|
|
cmd := exec.Command("gpg", "--list-secret-keys", "--keyid-format", "long")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
lines := strings.Split(string(output), "\n")
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "sec") {
|
|
parts := strings.Fields(line)
|
|
for _, part := range parts {
|
|
if strings.Contains(part, "/") {
|
|
keyParts := strings.Split(part, "/")
|
|
if len(keyParts) > 1 {
|
|
return keyParts[1]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|