Files
MrixsCraft-launcher/internal/launch/launch.go

239 lines
6.3 KiB
Go

// package launch handles Minecraft process launching (argument interpolation, classpath, exec).
package launch
import (
"crypto/sha1"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/auth"
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/config"
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/fetcher"
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/java"
)
// Game holds all parameters needed to launch a modpack instance.
type Game struct {
ServerBaseURL string
Session *auth.Session
Manifest *Manifest
InstanceDir string
JavaBin string
MemoryMB int
ExtraJVMArgs string
}
// Prepare downloads the manifest, syncs files, and resolves the Java binary.
func (g *Game) Prepare(onProgress func(downloaded, total int64)) error {
if err := os.MkdirAll(g.InstanceDir, 0o755); err != nil {
return fmt.Errorf("creating instance dir: %w", err)
}
// Download manifest.
manifestURL := fmt.Sprintf("%s/api/instances/%s/manifest.json",
g.ServerBaseURL, filepath.Base(g.InstanceDir))
manifestPath := filepath.Join(g.InstanceDir, "manifest.json")
if err := fetcher.Download(manifestURL, manifestPath, "", nil); err != nil {
return fmt.Errorf("downloading manifest: %w", err)
}
m, err := LoadManifest(manifestPath)
if err != nil {
return err
}
g.Manifest = m
// Resolve Java.
bin, err := java.Find(m.JavaVersion)
if err != nil {
return err
}
g.JavaBin = bin
// Sync files.
var totalBytes int64
for _, f := range m.Files {
totalBytes += f.Size
}
var downloadedBytes int64
pool := fetcher.NewWorkerPool(4)
for _, f := range m.Files {
dest := filepath.Join(g.InstanceDir, f.Path)
// Skip existing verified files.
if existing, err := os.Stat(dest); err == nil && existing.Size() == f.Size {
if got, err := sha1File(dest); err == nil && got == f.Hash {
atomic.AddInt64(&downloadedBytes, f.Size)
if onProgress != nil {
onProgress(atomic.LoadInt64(&downloadedBytes), totalBytes)
}
continue
}
}
pool.Submit(func() {
url := f.URL
if url == "" {
url = fmt.Sprintf("%s/files/%s", g.ServerBaseURL, f.Hash)
}
_ = fetcher.Download(url, dest, f.Hash, func(n, _ int64) {
atomic.AddInt64(&downloadedBytes, n)
if onProgress != nil {
onProgress(atomic.LoadInt64(&downloadedBytes), totalBytes)
}
})
})
}
pool.Wait()
return g.cleanupUnknownMods()
}
// cleanupUnknownMods moves untracked files from mods/ to mods_backup/.
func (g *Game) cleanupUnknownMods() error {
modsDir := filepath.Join(g.InstanceDir, "mods")
entries, err := os.ReadDir(modsDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
known := make(map[string]bool)
for _, f := range g.Manifest.ModFiles() {
known[filepath.Base(f.Path)] = true
}
backupDir := filepath.Join(g.InstanceDir, "mods_backup")
for _, entry := range entries {
if entry.IsDir() || known[entry.Name()] {
continue
}
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return err
}
src := filepath.Join(modsDir, entry.Name())
dst := filepath.Join(backupDir, entry.Name())
if err := os.Rename(src, dst); err != nil {
return fmt.Errorf("backing up %s: %w", entry.Name(), err)
}
}
return nil
}
// BuildCommand constructs the full exec.Command for launching the game.
func (g *Game) BuildCommand() (*exec.Cmd, error) {
if g.JavaBin == "" {
return nil, fmt.Errorf("java binary not resolved")
}
if g.Manifest == nil {
return nil, fmt.Errorf("manifest not loaded")
}
if g.Session == nil {
return nil, fmt.Errorf("no active session — login required")
}
var classpathEntries []string
for _, lib := range g.Manifest.LibraryFiles() {
classpathEntries = append(classpathEntries, filepath.Join(g.InstanceDir, lib.Path))
}
classpathEntries = append(classpathEntries, filepath.Join(g.InstanceDir, "libraries", "*"))
classpathStr := strings.Join(classpathEntries, string(os.PathListSeparator))
vars := map[string]string{
"player_name": g.Session.Username,
"auth_uuid": g.Session.UUID,
"auth_access_token": g.Session.AccessToken,
"classpath": classpathStr,
"java_path": g.JavaBin,
"game_directory": g.InstanceDir,
"assets_root": filepath.Join(g.InstanceDir, "assets"),
"assets_index_name": g.Manifest.MinecraftVersion,
"version_name": g.Manifest.MinecraftVersion,
"natives_directory": filepath.Join(g.InstanceDir, "natives"),
"launcher_name": config.AppName,
"launcher_version": "dev",
"auth_player_name": g.Session.Username,
"auth_session": g.Session.AccessToken,
"user_type": "mojang",
"version_type": "release",
"resolution_width": "1280",
"resolution_height": "720",
}
var args []string
// JVM memory + flags.
args = append(args, fmt.Sprintf("-Xms%dM", g.MemoryMB))
args = append(args, fmt.Sprintf("-Xmx%dM", g.MemoryMB))
for _, a := range g.Manifest.Launch.JVMArgs {
args = append(args, interpolate(a, vars))
}
if g.ExtraJVMArgs != "" {
args = append(args, strings.Fields(g.ExtraJVMArgs)...)
}
// Authlib-injector.
if g.Manifest.Launch.AuthLibInjector != "" {
authlibPath := filepath.Join(g.InstanceDir, "authlib-injector.jar")
args = append(args, fmt.Sprintf("-javaagent:%s=%s",
authlibPath, g.Manifest.Launch.AuthLibInjector))
}
// Classpath + main class.
args = append(args, "-cp", classpathStr)
args = append(args, g.Manifest.Launch.MainClass)
// Game args.
for _, a := range g.Manifest.Launch.GameArgs {
args = append(args, interpolate(a, vars))
}
cmd := exec.Command(g.JavaBin, args...)
cmd.Dir = g.InstanceDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd, nil
}
// Start launches the game process and waits for it to finish.
func (g *Game) Start() error {
cmd, err := g.BuildCommand()
if err != nil {
return err
}
return cmd.Run()
}
// interpolate replaces ${var} placeholders in s with values from vars.
func interpolate(s string, vars map[string]string) string {
for k, v := range vars {
s = strings.ReplaceAll(s, "${"+k+"}", v)
}
return s
}
// sha1File computes the SHA-1 hex digest of a file.
func sha1File(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha1.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}