272 lines
8.5 KiB
Go
272 lines
8.5 KiB
Go
package gitService
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/wailsapp/wails/v3/pkg/application"
|
|
)
|
|
|
|
const (
|
|
minGitReleaseAPI = "https://api.github.com/repos/git-for-windows/git/releases/latest"
|
|
minGitDirectory = "./server/tools/mingit"
|
|
)
|
|
|
|
type gitRelease struct {
|
|
Assets []struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"browser_download_url"`
|
|
} `json:"assets"`
|
|
}
|
|
|
|
// ensureGit provides a private, portable Git runtime when the user has not
|
|
// installed Git system-wide. It requires no administrator privileges.
|
|
func ensureGit() (string, error) {
|
|
if git, err := exec.LookPath("git"); err == nil {
|
|
return git, nil
|
|
}
|
|
|
|
gitPath := filepath.Join(minGitDirectory, "cmd", "git.exe")
|
|
if _, err := os.Stat(gitPath); err == nil {
|
|
return gitPath, nil
|
|
}
|
|
if runtime.GOOS != "windows" || runtime.GOARCH != "amd64" {
|
|
return "", fmt.Errorf("Git was not found on PATH, and automatic portable Git setup is currently supported on Windows x64 only")
|
|
}
|
|
|
|
client := &http.Client{Timeout: 2 * time.Minute}
|
|
request, err := http.NewRequest(http.MethodGet, minGitReleaseAPI, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("create Git download request: %w", err)
|
|
}
|
|
request.Header.Set("Accept", "application/vnd.github+json")
|
|
request.Header.Set("User-Agent", "G-Launcher")
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
return "", fmt.Errorf("find a portable Git release: %w", err)
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("find a portable Git release: GitHub returned %s", response.Status)
|
|
}
|
|
var release gitRelease
|
|
if err := json.NewDecoder(io.LimitReader(response.Body, 4<<20)).Decode(&release); err != nil {
|
|
return "", fmt.Errorf("read portable Git release metadata: %w", err)
|
|
}
|
|
var archiveURL string
|
|
for _, asset := range release.Assets {
|
|
if strings.HasPrefix(asset.Name, "MinGit-") && strings.HasSuffix(asset.Name, "-64-bit.zip") {
|
|
archiveURL = asset.URL
|
|
break
|
|
}
|
|
}
|
|
if archiveURL == "" {
|
|
return "", fmt.Errorf("the latest Git for Windows release does not include a MinGit x64 package")
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(minGitDirectory), 0755); err != nil {
|
|
return "", fmt.Errorf("create portable Git directory: %w", err)
|
|
}
|
|
archive, err := os.CreateTemp(filepath.Dir(minGitDirectory), "mingit-*.zip")
|
|
if err != nil {
|
|
return "", fmt.Errorf("create portable Git archive: %w", err)
|
|
}
|
|
archivePath := archive.Name()
|
|
defer os.Remove(archivePath)
|
|
downloadRequest, err := http.NewRequest(http.MethodGet, archiveURL, nil)
|
|
if err != nil {
|
|
archive.Close()
|
|
return "", fmt.Errorf("create portable Git download request: %w", err)
|
|
}
|
|
downloadRequest.Header.Set("User-Agent", "G-Launcher")
|
|
downloadResponse, err := client.Do(downloadRequest)
|
|
if err != nil {
|
|
archive.Close()
|
|
return "", fmt.Errorf("download portable Git: %w", err)
|
|
}
|
|
if downloadResponse.StatusCode != http.StatusOK {
|
|
downloadResponse.Body.Close()
|
|
archive.Close()
|
|
return "", fmt.Errorf("download portable Git: GitHub returned %s", downloadResponse.Status)
|
|
}
|
|
_, copyErr := io.Copy(archive, io.LimitReader(downloadResponse.Body, 300<<20))
|
|
responseErr := downloadResponse.Body.Close()
|
|
closeErr := archive.Close()
|
|
if copyErr != nil {
|
|
return "", fmt.Errorf("save portable Git: %w", copyErr)
|
|
}
|
|
if responseErr != nil {
|
|
return "", fmt.Errorf("finish portable Git download: %w", responseErr)
|
|
}
|
|
if closeErr != nil {
|
|
return "", fmt.Errorf("save portable Git: %w", closeErr)
|
|
}
|
|
|
|
stagingDir, err := os.MkdirTemp(filepath.Dir(minGitDirectory), "mingit-install-*")
|
|
if err != nil {
|
|
return "", fmt.Errorf("create portable Git staging directory: %w", err)
|
|
}
|
|
defer os.RemoveAll(stagingDir)
|
|
if err := extractMinGit(archivePath, stagingDir); err != nil {
|
|
return "", err
|
|
}
|
|
if _, err := os.Stat(filepath.Join(stagingDir, "cmd", "git.exe")); err != nil {
|
|
return "", fmt.Errorf("portable Git archive is missing cmd/git.exe")
|
|
}
|
|
if err := os.RemoveAll(minGitDirectory); err != nil {
|
|
return "", fmt.Errorf("replace incomplete portable Git setup: %w", err)
|
|
}
|
|
if err := os.Rename(stagingDir, minGitDirectory); err != nil {
|
|
return "", fmt.Errorf("install portable Git: %w", err)
|
|
}
|
|
return gitPath, nil
|
|
}
|
|
|
|
func gitCommand(gitPath string, args ...string) *exec.Cmd {
|
|
command := exec.Command(gitPath, args...)
|
|
if filepath.IsAbs(gitPath) && strings.EqualFold(filepath.Base(filepath.Dir(gitPath)), "cmd") {
|
|
root := filepath.Dir(filepath.Dir(gitPath))
|
|
command.Env = append(os.Environ(), "PATH="+filepath.Join(root, "mingw64", "bin")+";"+filepath.Join(root, "usr", "bin")+";"+os.Getenv("PATH"))
|
|
}
|
|
return command
|
|
}
|
|
|
|
var gitProgressPattern = regexp.MustCompile(`(?i)(receiving objects|resolving deltas):\s*(\d+)%`)
|
|
|
|
func runGitWithProgress(command *exec.Cmd, action string) ([]byte, error) {
|
|
stdout, err := command.StdoutPipe()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read Git output: %w", err)
|
|
}
|
|
stderr, err := command.StderrPipe()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read Git progress: %w", err)
|
|
}
|
|
if err := command.Start(); err != nil {
|
|
return nil, err
|
|
}
|
|
var stdoutOutput bytes.Buffer
|
|
var stderrOutput bytes.Buffer
|
|
stdoutDone := make(chan error, 1)
|
|
go func() {
|
|
_, copyErr := io.Copy(&stdoutOutput, stdout)
|
|
stdoutDone <- copyErr
|
|
}()
|
|
application.Get().Event.Emit("download:server", map[string]interface{}{
|
|
"percent": "0",
|
|
"speed": "",
|
|
"message": action,
|
|
})
|
|
|
|
scanner := bufio.NewScanner(stderr)
|
|
scanner.Buffer(make([]byte, 4096), 1024*1024)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
stderrOutput.WriteString(line)
|
|
stderrOutput.WriteByte('\n')
|
|
match := gitProgressPattern.FindStringSubmatch(line)
|
|
if len(match) != 3 {
|
|
continue
|
|
}
|
|
phasePercent, parseErr := strconv.Atoi(match[2])
|
|
if parseErr != nil {
|
|
continue
|
|
}
|
|
percent := phasePercent * 90 / 100
|
|
if strings.EqualFold(match[1], "resolving deltas") {
|
|
percent = 90 + phasePercent/10
|
|
}
|
|
application.Get().Event.Emit("download:server", map[string]interface{}{
|
|
"percent": fmt.Sprintf("%d", percent),
|
|
"speed": "",
|
|
"message": action + " · " + strings.ToLower(match[1]),
|
|
})
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
command.Process.Kill()
|
|
_ = command.Wait()
|
|
<-stdoutDone
|
|
return append(stdoutOutput.Bytes(), stderrOutput.Bytes()...), fmt.Errorf("read Git progress: %w", err)
|
|
}
|
|
stdoutErr := <-stdoutDone
|
|
waitErr := command.Wait()
|
|
if stdoutErr != nil {
|
|
return append(stdoutOutput.Bytes(), stderrOutput.Bytes()...), fmt.Errorf("read Git output: %w", stdoutErr)
|
|
}
|
|
if waitErr != nil {
|
|
return append(stdoutOutput.Bytes(), stderrOutput.Bytes()...), waitErr
|
|
}
|
|
application.Get().Event.Emit("download:server", map[string]interface{}{
|
|
"percent": "100",
|
|
"speed": "",
|
|
"message": action + " · complete",
|
|
})
|
|
return append(stdoutOutput.Bytes(), stderrOutput.Bytes()...), nil
|
|
}
|
|
|
|
func extractMinGit(archivePath, destination string) error {
|
|
reader, err := zip.OpenReader(archivePath)
|
|
if err != nil {
|
|
return fmt.Errorf("open portable Git archive: %w", err)
|
|
}
|
|
defer reader.Close()
|
|
root, err := filepath.Abs(destination)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve portable Git staging directory: %w", err)
|
|
}
|
|
for _, file := range reader.File {
|
|
name := filepath.Clean(filepath.FromSlash(file.Name))
|
|
if filepath.IsAbs(name) || name == "." || name == ".." || strings.HasPrefix(name, ".."+string(filepath.Separator)) {
|
|
return fmt.Errorf("portable Git archive contains an unsafe path")
|
|
}
|
|
path := filepath.Join(root, name)
|
|
if !strings.HasPrefix(path, root+string(filepath.Separator)) {
|
|
return fmt.Errorf("portable Git archive contains an unsafe path")
|
|
}
|
|
if file.FileInfo().IsDir() {
|
|
if err := os.MkdirAll(path, 0755); err != nil {
|
|
return fmt.Errorf("extract portable Git directory: %w", err)
|
|
}
|
|
continue
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
return fmt.Errorf("create portable Git directory: %w", err)
|
|
}
|
|
source, err := file.Open()
|
|
if err != nil {
|
|
return fmt.Errorf("read portable Git archive: %w", err)
|
|
}
|
|
output, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
|
|
if err != nil {
|
|
source.Close()
|
|
return fmt.Errorf("extract portable Git file: %w", err)
|
|
}
|
|
_, copyErr := io.Copy(output, source)
|
|
sourceErr := source.Close()
|
|
outputErr := output.Close()
|
|
if copyErr != nil {
|
|
return fmt.Errorf("extract portable Git file: %w", copyErr)
|
|
}
|
|
if sourceErr != nil {
|
|
return fmt.Errorf("read portable Git archive: %w", sourceErr)
|
|
}
|
|
if outputErr != nil {
|
|
return fmt.Errorf("write portable Git file: %w", outputErr)
|
|
}
|
|
}
|
|
return nil
|
|
}
|