feat: add launch module (manifest, interpolation, process exec)

- 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>
This commit is contained in:
2026-05-26 11:49:51 +03:00
parent 1487360215
commit c06c205b7b
2 changed files with 342 additions and 1 deletions

View File

@@ -1,2 +1,268 @@
// package launch handles Minecraft process launching (argument interpolation, classpath). // package launch handles Minecraft process launching (argument interpolation, classpath, exec).
package launch 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
}

View File

@@ -0,0 +1,75 @@
// package launch handles Minecraft process launching.
package launch
import (
"encoding/json"
"fmt"
"os"
)
// Manifest describes the contents of a modpack as provided by the backend.
type Manifest struct {
MinecraftVersion string `json:"minecraft_version"`
JavaVersion int `json:"java_version"`
ServerInfo ServerInfo `json:"server_info"`
Files []ManifestFile `json:"files"`
Launch LaunchConfig `json:"launch"`
}
// ServerInfo is the game server address injected into servers.dat.
type ServerInfo struct {
Name string `json:"name"`
IP string `json:"ip"`
}
// ManifestFile is a single file entry with SHA-1 verification.
type ManifestFile struct {
Path string `json:"path"`
Hash string `json:"hash"`
Size int64 `json:"size"`
URL string `json:"url"`
}
// LaunchConfig contains the JVM and game arguments template.
type LaunchConfig struct {
MainClass string `json:"mainClass"`
JVMArgs []string `json:"jvmArgs"`
GameArgs []string `json:"gameArgs"`
NativeLibs []string `json:"nativeLibs"`
AuthLibInjector string `json:"authLibInjector"` // URL to authlib-injector.jar
}
// LoadManifest reads and parses a manifest.json from disk.
func LoadManifest(path string) (*Manifest, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading manifest %s: %w", path, err)
}
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("parsing manifest %s: %w", path, err)
}
return &m, nil
}
// LibraryFiles returns the subset of Files that are .jar libraries (classpath).
func (m *Manifest) LibraryFiles() []ManifestFile {
var libs []ManifestFile
for _, f := range m.Files {
if len(f.Path) > 4 && f.Path[len(f.Path)-4:] == ".jar" {
libs = append(libs, f)
}
}
return libs
}
// ModFiles returns the subset of Files that go into the mods/ directory.
func (m *Manifest) ModFiles() []ManifestFile {
var mods []ManifestFile
for _, f := range m.Files {
if len(f.Path) > 5 && f.Path[:5] == "mods/" {
mods = append(mods, f)
}
}
return mods
}