Files
G-Launcher/internal/git-service/git_runtime.go
T

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
}