- manifest: Manifest struct (files, launch config, server info), LoadManifest, LibraryFiles/ModFiles filters - launch: Game struct (Prepare/BuildCommand/Start lifecycle) - Prepare: download manifest, resolve Java, sync files with SHA-1, soft-delete unknown mods - BuildCommand: classpath assembly, variable interpolation (, , etc.), authlib-injector injection - Start: execute the assembled command - interpolate: template replacement - cleanupUnknownMods: moves untracked mods to mods_backup/ Co-Authored-By: OWL <noreply@anthropic.com>
269 lines
7.0 KiB
Go
269 lines
7.0 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"
|
|
|
|
"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.
|
|
// onProgress is called with (downloadedBytes, totalBytes) during file sync.
|
|
func (g *Game) Prepare(onProgress func(downloaded, total int64)) error {
|
|
// 1. Ensure instance directory exists.
|
|
if err := os.MkdirAll(g.InstanceDir, 0o755); err != nil {
|
|
return fmt.Errorf("creating instance dir: %w", err)
|
|
}
|
|
|
|
// 2. Download manifest.
|
|
manifestURL := fmt.Sprintf("%s/api/instances/%s/manifest.json",
|
|
g.ServerBaseURL, slugFromDir(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
|
|
|
|
// 3. Resolve Java.
|
|
bin, err := java.Find(m.JavaVersion)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
g.JavaBin = bin
|
|
|
|
// 4. Sync files (download missing / verify SHA-1).
|
|
var totalBytes int64
|
|
for _, f := range m.Files {
|
|
totalBytes += f.Size
|
|
}
|
|
|
|
var downloadedBytes int64
|
|
pool := fetcher.NewWorkerPool(4)
|
|
|
|
for i := range m.Files {
|
|
f := m.Files[i]
|
|
dest := filepath.Join(g.InstanceDir, f.Path)
|
|
|
|
// Skip if file exists and hash matches.
|
|
if existing, err := os.Stat(dest); err == nil {
|
|
if existing.Size() == f.Size {
|
|
if got, err := fileHash(dest); err == nil && got == f.Hash {
|
|
downloadedBytes += f.Size
|
|
if onProgress != nil {
|
|
onProgress(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) {
|
|
downloadedBytes += n
|
|
if onProgress != nil {
|
|
onProgress(downloadedBytes, totalBytes)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
pool.Wait()
|
|
|
|
// 5. Soft-delete unknown mods.
|
|
if err := g.cleanupUnknownMods(); err != nil {
|
|
return fmt.Errorf("cleaning up unknown mods: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// cleanupUnknownMods moves files from mods/ that are not in the manifest
|
|
// into mods_backup/ (soft deletion / self-healing).
|
|
func (g *Game) cleanupUnknownMods() error {
|
|
modsDir := filepath.Join(g.InstanceDir, "mods")
|
|
backupDir := filepath.Join(g.InstanceDir, "mods_backup")
|
|
|
|
entries, err := os.ReadDir(modsDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Build set of known mod filenames.
|
|
known := make(map[string]bool)
|
|
for _, f := range g.Manifest.ModFiles() {
|
|
known[filepath.Base(f.Path)] = true
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
if known[entry.Name()] {
|
|
continue
|
|
}
|
|
// Move to backup.
|
|
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")
|
|
}
|
|
|
|
// Build classpath.
|
|
var classpathEntries []string
|
|
for _, lib := range g.Manifest.LibraryFiles() {
|
|
classpathEntries = append(classpathEntries, filepath.Join(g.InstanceDir, lib.Path))
|
|
}
|
|
// Add instance libraries dir.
|
|
classpathEntries = append(classpathEntries, filepath.Join(g.InstanceDir, "libraries", "*"))
|
|
classpath := strings.Join(classpathEntries, string(os.PathListSeparator))
|
|
|
|
// Authlib-injector path.
|
|
authlibPath := filepath.Join(g.InstanceDir, "authlib-injector.jar")
|
|
|
|
// Interpolation context.
|
|
vars := map[string]string{
|
|
"player_name": g.Session.Username,
|
|
"auth_uuid": g.Session.UUID,
|
|
"auth_access_token": g.Session.AccessToken,
|
|
"classpath": classpath,
|
|
"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",
|
|
}
|
|
|
|
// Assemble arguments.
|
|
var args []string
|
|
|
|
// JVM args.
|
|
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 != "" {
|
|
args = append(args, fmt.Sprintf("-javaagent:%s=%s",
|
|
authlibPath, g.Manifest.Launch.AuthLibInjector))
|
|
}
|
|
|
|
// Classpath.
|
|
args = append(args, "-cp", classpath)
|
|
|
|
// Main class.
|
|
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
|
|
}
|
|
|
|
// slugFromDir extracts the modpack slug from the instance directory path.
|
|
func slugFromDir(dir string) string {
|
|
return filepath.Base(dir)
|
|
}
|
|
|
|
// fileHash is a local alias to avoid import cycle — computes SHA-1 hex of a file.
|
|
func fileHash(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
|
|
}
|