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:
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
75
internal/launch/manifest.go
Normal file
75
internal/launch/manifest.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user