feat: initial commit for G-Launcher based on Firefly with Horoyoi enhancements

This commit is contained in:
CAtlas0784 committed 2026-10-30 20:56:35 +07:00
commit a99470a69f
155 files changed
+19061

No files matched your search

+271
View File
@@ -0,0 +1,271 @@
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
}
+57
View File
@@ -0,0 +1,57 @@
package gitService
import (
"encoding/json"
"g-launcher/pkg/constant"
"g-launcher/pkg/models"
"io"
"net/http"
"github.com/minio/selfupdate"
)
type GitService struct{}
func (g *GitService) GetLatestLauncherVersion() (bool, string, string) {
resp, err := http.Get(constant.LauncherGitUrl)
if err != nil {
return false, "", err.Error()
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var releases []models.ReleaseType
err = json.Unmarshal(body, &releases)
if err != nil {
return false, "", err.Error()
}
if len(releases) == 0 {
return false, "", "no releases found"
}
return true, releases[0].TagName, ""
}
func (g *GitService) UpdateLauncherProgress(version string) (bool, string) {
asset, ok := g.getReleaseAsset(version, constant.LauncherGitUrl, constant.LauncherFile)
if !ok {
return false, "no release found"
}
resp, err := http.Get(asset.BrowserDownloadURL)
if err != nil {
return false, err.Error()
}
defer resp.Body.Close()
err = selfupdate.Apply(resp.Body, selfupdate.Options{})
if err != nil {
return false, err.Error()
}
return true, ""
}
+67
View File
@@ -0,0 +1,67 @@
package gitService
import (
"encoding/json"
"g-launcher/pkg/constant"
"g-launcher/pkg/models"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
)
func (g *GitService) GetLatestProxyVersion() (bool, string, string) {
resp, err := http.Get(constant.ProxyGitUrl)
if err != nil {
return false, "", err.Error()
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var releases []models.ReleaseType
err = json.Unmarshal(body, &releases)
if err != nil {
return false, "", err.Error()
}
if len(releases) == 0 {
return false, "", "no releases found"
}
return true, releases[0].TagName, ""
}
func (g *GitService) DownloadProxyProgress(version string) (bool, string) {
asset, ok := g.getReleaseAsset(version, constant.ProxyGitUrl, constant.ProxyFile)
if !ok {
return false, "no release found"
}
if err := os.MkdirAll(constant.ProxyStorageUrl, 0755); err != nil {
return false, err.Error()
}
saveFile := filepath.Join(constant.ProxyStorageUrl, asset.Name)
tmpPath, err := g.downloadFileParallel(saveFile, asset.BrowserDownloadURL, 4, func(percent float64, speed string) {
application.Get().Event.Emit("download:proxy", map[string]interface{}{
"percent": fmt.Sprintf("%.2f", percent),
"speed": speed,
})
})
if err != nil {
return false, err.Error()
}
for i := 0; i < 3; i++ {
if err := os.Rename(tmpPath, saveFile); err == nil {
return true, ""
}
time.Sleep(300 * time.Millisecond)
}
return false, "failed to rename tmp file after retries"
}
+71
View File
@@ -0,0 +1,71 @@
package gitService
import (
"encoding/json"
"g-launcher/pkg/constant"
"g-launcher/pkg/models"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
)
func (g *GitService) GetLatestServerVersion() (bool, string, string) {
resp, err := http.Get(constant.ServerGitUrl)
if err != nil {
return false, "", err.Error()
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var releases []models.ReleaseType
err = json.Unmarshal(body, &releases)
if err != nil {
return false, "", err.Error()
}
if len(releases) == 0 {
return false, "", "no releases found"
}
return true, releases[0].TagName, ""
}
func (g *GitService) DownloadServerProgress(version string) (bool, string) {
asset, ok := g.getReleaseAsset(version, constant.ServerGitUrl, constant.ServerZipFile)
if !ok {
return false, "no release found"
}
if err := os.MkdirAll(constant.ServerStorageUrl, 0755); err != nil {
return false, err.Error()
}
saveFile := filepath.Join(constant.ServerStorageUrl, asset.Name)
tmpPath, err := g.downloadFileParallel(saveFile, asset.BrowserDownloadURL, 4, func(percent float64, speed string) {
application.Get().Event.Emit("download:server", map[string]interface{}{
"percent": fmt.Sprintf("%.2f", percent),
"speed": speed,
})
})
if err != nil {
return false, err.Error()
}
for i := 0; i < 3; i++ {
if err := os.Rename(tmpPath, saveFile); err == nil {
return true, ""
}
time.Sleep(300 * time.Millisecond)
}
return false, "failed to rename tmp file after retries"
}
func (g *GitService) UnzipServer() {
g.unzipParallel(filepath.Join(constant.ServerStorageUrl, constant.ServerZipFile), constant.ServerStorageUrl)
os.Remove(filepath.Join(constant.ServerStorageUrl, constant.ServerZipFile))
}
+251
View File
@@ -0,0 +1,251 @@
package gitService
import (
"archive/zip"
"encoding/json"
"g-launcher/pkg/models"
"fmt"
"io"
"math"
"net/http"
"os"
"path/filepath"
"runtime"
"sync"
"time"
)
func HumanFormat(bytes float64) string {
if math.IsNaN(bytes) || math.IsInf(bytes, 0) {
return fmt.Sprintf("%v", bytes)
}
if bytes == 0 {
return "0B"
}
neg := bytes < 0
if neg {
bytes = -bytes
}
units := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
i := 0
for bytes >= 1024 && i < len(units)-1 {
bytes /= 1024
i++
}
res := fmt.Sprintf("%.1f%s", bytes, units[i])
if neg {
res = "-" + res
}
return res
}
type WriteCounter struct {
Total uint64
StartTime time.Time
OnEmit func(percent float64, speed string)
TotalSize int64
mu sync.Mutex
}
func NewWriteCounter(total int64, onEmit func(percent float64, speed string)) *WriteCounter {
return &WriteCounter{
StartTime: time.Now(),
TotalSize: total,
OnEmit: onEmit,
}
}
func (wc *WriteCounter) Add(n int) {
wc.mu.Lock()
defer wc.mu.Unlock()
wc.Total += uint64(n)
wc.PrintProgress()
}
func (wc *WriteCounter) PrintProgress() {
elapsed := time.Since(wc.StartTime).Seconds()
if elapsed < 0.001 {
elapsed = 0.001
}
speed := float64(wc.Total) / elapsed
percent := float64(wc.Total) / float64(wc.TotalSize) * 100
if wc.OnEmit != nil {
wc.OnEmit(percent, fmt.Sprintf("%s/s", HumanFormat(speed)))
}
}
func (g *GitService) downloadFileParallel(filePath, url string, numParts int, onEmit func(percent float64, speed string)) (tmpPath string, err error) {
resp, err := http.Head(url)
if err != nil {
return "", fmt.Errorf("failed to get head: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("bad status: %s", resp.Status)
}
size := resp.ContentLength
tmpPath = filePath + ".tmp"
out, err := os.Create(tmpPath)
if err != nil {
return "", fmt.Errorf("failed to create tmp file: %w", err)
}
defer out.Close()
counter := NewWriteCounter(size, onEmit)
partSize := size / int64(numParts)
var wg sync.WaitGroup
var mu sync.Mutex
for i := 0; i < numParts; i++ {
start := int64(i) * partSize
end := start + partSize - 1
if i == numParts-1 {
end = size - 1
}
_ = start
_ = end
wg.Go(func() {
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
buf := make([]byte, 32*1024)
var written int64
for {
n, err := resp.Body.Read(buf)
if n > 0 {
mu.Lock()
out.Seek(start+written, 0)
out.Write(buf[:n])
mu.Unlock()
written += int64(n)
counter.Add(n)
}
if err == io.EOF {
break
}
if err != nil {
break
}
}
})
}
wg.Wait()
return tmpPath, nil
}
// --- Helper getReleaseAsset ---
func (g *GitService) getReleaseAsset(version, url, fileName string) (models.AssetType, bool) {
resp, err := http.Get(url)
if err != nil {
return models.AssetType{}, false
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var releases []*models.ReleaseType
if err := json.Unmarshal(body, &releases); err != nil || len(releases) == 0 {
return models.AssetType{}, false
}
for _, release := range releases {
if release.TagName == version {
for _, asset := range release.Assets {
if asset.Name == fileName {
return asset, true
}
}
}
}
return models.AssetType{}, false
}
func (g *GitService) unzipParallel(src string, dest string) error {
numCPU := runtime.NumCPU()
reserved := 1
if numCPU > 4 {
reserved = 2
}
maxWorkers := numCPU - reserved
if maxWorkers < 1 {
maxWorkers = 1
}
r, err := zip.OpenReader(src)
if err != nil {
return err
}
defer r.Close()
err = os.MkdirAll(dest, 0755)
if err != nil {
return err
}
type job struct {
f *zip.File
}
jobs := make(chan job)
var wg sync.WaitGroup
for i := 0; i < maxWorkers; i++ {
wg.Go(func() {
for j := range jobs {
err := g.extractFile(j.f, dest)
if err != nil {
fmt.Printf("Error extracting %s: %v\n", j.f.Name, err)
}
}
})
}
for _, f := range r.File {
jobs <- job{f}
}
close(jobs)
wg.Wait()
return nil
}
func (g *GitService) extractFile(f *zip.File, dest string) error {
fp := filepath.Join(dest, f.Name)
if f.FileInfo().IsDir() {
return os.MkdirAll(fp, f.Mode())
}
err := os.MkdirAll(filepath.Dir(fp), 0755)
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
out, err := os.OpenFile(fp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, rc)
return err
}