package main import ( "encoding/json" "fmt" "io" "log" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "time" ) const updateCheckInterval = 15 * time.Minute // AgentVersionInfo matches the server's /api/agent/version response. type AgentVersionInfo struct { Version string `json:"version"` DownloadUrls struct { Windows string `json:"windows"` WindowsZip string `json:"windowsZip"` Linux string `json:"linux"` Mac string `json:"mac"` } `json:"downloadUrls"` } // checkForUpdate fetches the latest agent version from the server and compares // it with the running binary's version. func checkForUpdate(serverBaseURL string) (*AgentVersionInfo, bool, error) { if serverBaseURL == "" { return nil, false, fmt.Errorf("no server URL configured") } wsURL := strings.TrimSuffix(serverBaseURL, "/api/websocket") url := wsURL + "/api/agent/version" client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Get(url) if err != nil { return nil, false, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, false, fmt.Errorf("server returned %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, false, err } var info AgentVersionInfo if err := json.Unmarshal(body, &info); err != nil { return nil, false, err } if info.Version == "" { return nil, false, fmt.Errorf("server returned empty version") } available := info.Version != version return &info, available, nil } // downloadUpdate downloads the new agent binary to the update directory. func downloadUpdate(dataDir, downloadURL string) (string, error) { updateDir := filepath.Join(dataDir, "update") if err := os.MkdirAll(updateDir, 0755); err != nil { return "", err } ext := "" if runtime.GOOS == "windows" { ext = ".exe" } dest := filepath.Join(updateDir, "studioE5-agent-new"+ext) log.Printf("Downloading update from %s to %s", downloadURL, dest) resp, err := http.Get(downloadURL) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("download returned %d", resp.StatusCode) } out, err := os.Create(dest) if err != nil { return "", err } defer out.Close() if _, err := io.Copy(out, resp.Body); err != nil { return "", err } if err := out.Close(); err != nil { return "", err } if runtime.GOOS != "windows" { if err := os.Chmod(dest, 0755); err != nil { return "", err } } return dest, nil } // applyUpdate replaces the running binary with the downloaded one using an // external helper script, then exits the current process. func applyUpdate(currentPath, newPath, dataDir string) error { pid := os.Getpid() switch runtime.GOOS { case "windows": return applyUpdateWindows(currentPath, newPath, dataDir, pid) default: return applyUpdateUnix(currentPath, newPath, dataDir, pid) } } func applyUpdateWindows(currentPath, newPath, dataDir string, pid int) error { scriptPath := filepath.Join(dataDir, "update", "apply-update.ps1") script := fmt.Sprintf(`$old = "%s" $new = "%s" $pid = %d $args = '-no-tray', '-data-dir', '%s' Wait-Process -Id $pid -ErrorAction SilentlyContinue Start-Sleep -Seconds 2 Move-Item -Path $new -Destination $old -Force Start-Process -FilePath $old -ArgumentList $args -WindowStyle Hidden `, currentPath, newPath, pid, dataDir) if err := os.WriteFile(scriptPath, []byte(script), 0644); err != nil { return err } cmd := exec.Command("powershell.exe", "-ExecutionPolicy", "Bypass", "-File", scriptPath) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { return err } log.Printf("Update helper started, exiting current process") os.Exit(0) return nil } func applyUpdateUnix(currentPath, newPath, dataDir string, pid int) error { scriptPath := filepath.Join(dataDir, "update", "apply-update.sh") script := fmt.Sprintf(`#!/bin/bash set -e old="%s" new="%s" pid=%d while kill -0 "$pid" 2>/dev/null; do sleep 1; done sleep 2 mv "$new" "$old" chmod +x "$old" nohup "$old" -no-tray -data-dir "%s" >/dev/null 2>&1 & `, currentPath, newPath, pid, dataDir) if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { return err } cmd := exec.Command("/bin/bash", scriptPath) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { return err } log.Printf("Update helper started, exiting current process") os.Exit(0) return nil } // startAgentUpdate performs the full update flow: download + replace + restart. func startAgentUpdate(dataDir, serverBaseURL string) error { info, available, err := checkForUpdate(serverBaseURL) if err != nil { return fmt.Errorf("update check failed: %w", err) } if !available { return fmt.Errorf("no update available") } currentPath, err := os.Executable() if err != nil { return err } currentPath, err = filepath.Abs(currentPath) if err != nil { return err } var downloadURL string switch runtime.GOOS { case "windows": downloadURL = info.DownloadUrls.Windows case "darwin": downloadURL = info.DownloadUrls.Mac default: downloadURL = info.DownloadUrls.Linux } if downloadURL == "" { return fmt.Errorf("no download URL for %s", runtime.GOOS) } newPath, err := downloadUpdate(dataDir, downloadURL) if err != nil { return fmt.Errorf("download failed: %w", err) } log.Printf("Applying update to version %s", info.Version) broadcastUI(map[string]interface{}{ "action": "update_progress", "percent": "90", "message": "Redémarrage de l'agent...", }) return applyUpdate(currentPath, newPath, dataDir) } // updateCheckerLoop periodically checks for agent updates and notifies the UI. func updateCheckerLoop(dataDir, serverBaseURL string) { for { info, available, err := checkForUpdate(serverBaseURL) if err == nil && available && info != nil { log.Printf("Agent update available: %s (current: %s)", info.Version, version) setServerAgentVersion(info.Version) broadcastUI(map[string]interface{}{ "action": "update_available", "version": info.Version, "update_available": true, }) } time.Sleep(updateCheckInterval) } }