From c06c205b7bce7fde8d2d04d98262bf491208a27a Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Tue, 26 May 2026 11:49:51 +0300 Subject: [PATCH] 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 --- internal/launch/launch.go | 268 +++++++++++++++++++++++++++++++++++- internal/launch/manifest.go | 75 ++++++++++ 2 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 internal/launch/manifest.go diff --git a/internal/launch/launch.go b/internal/launch/launch.go index 5757bd5..4986e73 100644 --- a/internal/launch/launch.go +++ b/internal/launch/launch.go @@ -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 + +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 +} diff --git a/internal/launch/manifest.go b/internal/launch/manifest.go new file mode 100644 index 0000000..de28542 --- /dev/null +++ b/internal/launch/manifest.go @@ -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 +}