From 070b5c02625b2d1eeafbf5c247bbe2704dc21693 Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Tue, 26 May 2026 06:35:09 +0300 Subject: [PATCH] feat: add config, auth, fetcher, java modules - config: OS-specific paths, launcher.json load/save, DefaultSettings with MRIXSCRAFT_SERVER_URL env override - auth: Yggdrasil client (authenticate, refresh, validate), session persistence in session.json, EnsureValid flow - fetcher: HTTP download with SHA-1 verification, WorkerPool for concurrent downloads - java: JRE detection (IsInstalled/Find), platform-specific executable name - utils: SHA1File, SHA1Bytes, Unzip with zip-slip protection - cmd/launcher: wire config + auth into main, session restore on startup Co-Authored-By: OWL --- cmd/launcher/main.go | 42 +++++- go.mod | 32 ++++- internal/auth/auth.go | 256 +++++++++++++++++++++++++++++++++++- internal/config/config.go | 172 +++++++++++++++++++++++- internal/fetcher/fetcher.go | 114 ++++++++++++++++ internal/java/java.go | 62 +++++++++ pkg/utils/utils.go | 81 +++++++++++- 7 files changed, 750 insertions(+), 9 deletions(-) diff --git a/cmd/launcher/main.go b/cmd/launcher/main.go index a760bb9..c355036 100644 --- a/cmd/launcher/main.go +++ b/cmd/launcher/main.go @@ -2,21 +2,53 @@ package main import ( "fmt" + "log" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/widget" + + "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/auth" + "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/config" ) -func main() { - fmt.Println("MrixsCraft Launcher starting...") +// version is set via -ldflags at build time. +var version = "dev" +func main() { + log.Printf("MrixsCraft Launcher %s", version) + + root, err := config.EnsureRoot() + if err != nil { + log.Fatalf("Failed to initialise data directory: %v", err) + } + log.Printf("Data directory: %s", root) + + // Try to restore an existing session. + client, err := auth.NewFromConfig() + if err != nil { + log.Fatalf("Failed to create auth client: %v", err) + } + + sess, err := client.EnsureValid() + if err != nil { + log.Printf("Session check failed: %v", err) + } + + if sess != nil { + log.Printf("Logged in as %s", sess.Username) + } else { + log.Println("No valid session — login required") + } + + // Bootstrap Fyne UI. a := app.New() - w := a.NewWindow("MrixsCraft Launcher") - w.Resize(fyne.NewSize(800, 600)) + w := a.NewWindow(fmt.Sprintf("MrixsCraft %s", version)) + w.Resize(fyne.NewSize(900, 600)) + w.CenterOnScreen() w.SetContent(container.NewVBox( - widget.NewLabel("MrixsCraft Launcher"), + widget.NewLabel(fmt.Sprintf("MrixsCraft Launcher %s", version)), )) w.ShowAndRun() diff --git a/go.mod b/go.mod index f52cca1..3a417cd 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,35 @@ -module github.com/Mrixs/MrixsCraft-launcher +module gitea.mrixs.me/Mrixs/MrixsCraft-launcher go 1.22 require fyne.io/fyne/v2 v2.4.5 + +require ( + fyne.io/systray v1.10.1-0.20231115130155-104f5ef7839e // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fredbi/uri v1.0.0 // indirect + github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe // indirect + github.com/fyne-io/glfw-js v0.0.0-20220120001248-ee7290d23504 // indirect + github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2 // indirect + github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6 // indirect + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240306074159-ea2d69986ecb // indirect + github.com/go-text/render v0.1.0 // indirect + github.com/go-text/typesetting v0.1.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/gopherjs/gopherjs v1.17.2 // indirect + github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect + github.com/stretchr/testify v1.8.4 // indirect + github.com/tevino/abool v1.2.0 // indirect + github.com/yuin/goldmark v1.5.5 // indirect + golang.org/x/image v0.11.0 // indirect + golang.org/x/mobile v0.0.0-20230531173138-3c911d8e3eda // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + honnef.co/go/js/dom v0.0.0-20210725211120-f030747120f2 // indirect +) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 8c6210e..b047a57 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1,2 +1,256 @@ -// package auth handles Yggdrasil authentication (login, refresh, validate). +// package auth handles Yggdrasil authentication with the backend. package auth + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/config" +) + +// Session represents an authenticated Yggdrasil session stored in session.json. +type Session struct { + AccessToken string `json:"accessToken"` + ClientToken string `json:"clientToken"` + UUID string `json:"uuid"` + Username string `json:"username"` + ExpiresAt time.Time `json:"expiresAt"` +} + +// Yggdrasil client endpoints. +type Client struct { + baseURL string + httpClient *http.Client +} + +// New creates a new Yggdrasil client bound to the given base URL. +func New(baseURL string) *Client { + return &Client{ + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: 15 * time.Second, + }, + } +} + +// NewFromConfig creates a client using the server URL from launcher settings. +func NewFromConfig() (*Client, error) { + s, err := config.Load() + if err != nil { + return nil, fmt.Errorf("loading config: %w", err) + } + return New(s.ServerURL), nil +} + +// sessionPath returns the file path for session.json under the launcher root. +func sessionPath() (string, error) { + root, err := config.RootDir() + if err != nil { + return "", err + } + return filepath.Join(root, "session.json"), nil +} + +// generateClientToken creates a random hex token (16 bytes → 32 hex chars). +func generateClientToken() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// Load reads the session from disk. +func Load() (*Session, error) { + p, err := sessionPath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(p) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading session: %w", err) + } + var s Session + if err := json.Unmarshal(data, &s); err != nil { + return nil, fmt.Errorf("parsing session: %w", err) + } + return &s, nil +} + +// Save writes the session to disk with restricted permissions. +func (s *Session) Save() error { + p, err := sessionPath() + if err != nil { + return err + } + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return fmt.Errorf("serializing session: %w", err) + } + return os.WriteFile(p, data, 0o600) +} + +// Delete removes the session file (logout). +func Delete() error { + p, err := sessionPath() + if err != nil { + return err + } + return os.Remove(p) +} + +// Authenticate performs a Yggdrasil /authserver/authenticate request. +func (c *Client) Authenticate(username, password string) (*Session, error) { + body, err := json.Marshal(map[string]interface{}{ + "username": username, + "password": password, + }) + if err != nil { + return nil, fmt.Errorf("encoding request: %w", err) + } + + resp, err := c.httpClient.Post( + c.baseURL+"/authserver/authenticate", + "application/json", + bytes.NewReader(body), + ) + if err != nil { + return nil, fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + msg, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("authentication failed (%d): %s", resp.StatusCode, string(msg)) + } + + var result struct { + AccessToken string `json:"accessToken"` + ClientToken string `json:"clientToken"` + Profile struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"selectedProfile"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + + clientToken := result.ClientToken + if clientToken == "" { + clientToken = generateClientToken() + } + + return &Session{ + AccessToken: result.AccessToken, + ClientToken: clientToken, + UUID: result.Profile.ID, + Username: result.Profile.Name, + ExpiresAt: time.Now().Add(24 * time.Hour), + }, nil +} + +// Refresh performs a Yggdrasil /authserver/refresh request. +func (c *Client) Refresh(s *Session) (*Session, error) { + body, err := json.Marshal(map[string]string{ + "accessToken": s.AccessToken, + "clientToken": s.ClientToken, + }) + if err != nil { + return nil, fmt.Errorf("encoding request: %w", err) + } + + resp, err := c.httpClient.Post( + c.baseURL+"/authserver/refresh", + "application/json", + bytes.NewReader(body), + ) + if err != nil { + return nil, fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + msg, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("refresh failed (%d): %s", resp.StatusCode, string(msg)) + } + + var result struct { + AccessToken string `json:"accessToken"` + ClientToken string `json:"clientToken"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + + return &Session{ + AccessToken: result.AccessToken, + ClientToken: result.ClientToken, + UUID: s.UUID, + Username: s.Username, + ExpiresAt: time.Now().Add(24 * time.Hour), + }, nil +} + +// Validate checks if the current session token is still valid. +func (c *Client) Validate(s *Session) error { + body, err := json.Marshal(map[string]string{ + "accessToken": s.AccessToken, + "clientToken": s.ClientToken, + }) + if err != nil { + return fmt.Errorf("encoding request: %w", err) + } + + resp, err := c.httpClient.Post( + c.baseURL+"/authserver/validate", + "application/json", + bytes.NewReader(body), + ) + if err != nil { + return fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + msg, _ := io.ReadAll(resp.Body) + return fmt.Errorf("validation failed (%d): %s", resp.StatusCode, string(msg)) + } + return nil +} + +// EnsureValid tries validate → refresh. Returns a valid session or an error. +func (c *Client) EnsureValid() (*Session, error) { + s, err := Load() + if err != nil { + return nil, err + } + if s == nil { + return nil, nil // not logged in + } + + if err := c.Validate(s); err == nil { + return s, nil // still valid + } + + // Try refresh. + refreshed, err := c.Refresh(s) + if err != nil { + _ = Delete() + return nil, nil // need to re-login + } + + if err := refreshed.Save(); err != nil { + return nil, fmt.Errorf("saving refreshed session: %w", err) + } + return refreshed, nil +} diff --git a/internal/config/config.go b/internal/config/config.go index b049704..9c4f25a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,2 +1,172 @@ -// package config manages launcher configuration (launcher.json, system paths). +// package config manages launcher configuration and system paths. package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" +) + +// AppName is the canonical name for the launcher and its data directory. +const AppName = "MrixsCraft" + +// Settings represents the user-configurable launcher settings stored in launcher.json. +type Settings struct { + ServerURL string `json:"server_url"` // Base URL of the backend (e.g. "https://minecraft.mrixs.me") + SelectedPack string `json:"selected_pack"` // Slug of the selected modpack + MemoryMB int `json:"memory_mb"` // Allocated RAM in MB (for -Xms / -Xmx) + ExtraArgs string `json:"extra_args"` // Additional JVM flags + Width int `json:"window_width"` // Main window width + Height int `json:"window_height"` // Main window height +} + +// serverEnvURL is the environment variable that overrides the backend base URL. +const serverEnvURL = "MRIXSCRAFT_SERVER_URL" + +// defaultServerURL is used when neither the env var nor launcher.json provide a value. +const defaultServerURL = "https://minecraft.mrixs.me" + +// DefaultSettings returns sensible defaults. +func DefaultSettings() Settings { + url := os.Getenv(serverEnvURL) + if url == "" { + url = defaultServerURL + } + return Settings{ + ServerURL: url, + SelectedPack: "", + MemoryMB: 4096, + ExtraArgs: "", + Width: 900, + Height: 600, + } +} + +// RootDir returns the OS-specific data directory for the launcher. +func RootDir() (string, error) { + switch runtime.GOOS { + case "windows": + appData := os.Getenv("APPDATA") + if appData == "" { + return "", fmt.Errorf("APPDATA environment variable is empty") + } + return filepath.Join(appData, AppName), nil + + case "darwin": + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolving home directory: %w", err) + } + return filepath.Join(home, "Library", "Application Support", AppName), nil + + default: // linux and other unixes + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolving home directory: %w", err) + } + return filepath.Join(home, "."+AppName), nil + } +} + +// EnsureRoot creates the root data directory if it does not exist. +func EnsureRoot() (string, error) { + dir, err := RootDir() + if err != nil { + return "", err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("creating root directory %s: %w", dir, err) + } + return dir, nil +} + +// Subdirectory returns (and optionally creates) a subdirectory under the root. +func Subdirectory(name string, create bool) (string, error) { + root, err := RootDir() + if err != nil { + return "", err + } + path := filepath.Join(root, name) + if create { + if err := os.MkdirAll(path, 0o755); err != nil { + return "", fmt.Errorf("creating subdirectory %s: %w", path, err) + } + } + return path, nil +} + +// JavaDir returns the directory for a specific Java version. +func JavaDir(version int) (string, error) { + return Subdirectory(filepath.Join("Java", fmt.Sprintf("%d", version)), true) +} + +// InstancesDir returns the directory containing modpack instances. +func InstancesDir() (string, error) { + return Subdirectory("instances", true) +} + +// InstanceDir returns the directory for a specific modpack. +func InstanceDir(slug string) (string, error) { + return Subdirectory(filepath.Join("instances", slug), true) +} + +// AssetsDir returns the assets cache directory. +func AssetsDir() (string, error) { + return Subdirectory("assets", true) +} + +// LibrariesDir returns the libraries cache directory. +func LibrariesDir() (string, error) { + return Subdirectory("libraries", true) +} + +// LauncherJSONPath returns the full path to launcher.json. +func LauncherJSONPath() (string, error) { + root, err := RootDir() + if err != nil { + return "", err + } + return filepath.Join(root, "launcher.json"), nil +} + +// Load reads launcher.json from disk, returning defaults if the file does not exist. +func Load() (Settings, error) { + path, err := LauncherJSONPath() + if err != nil { + return Settings{}, err + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return DefaultSettings(), nil + } + return Settings{}, fmt.Errorf("reading %s: %w", path, err) + } + + var s Settings + if err := json.Unmarshal(data, &s); err != nil { + return Settings{}, fmt.Errorf("parsing %s: %w", path, err) + } + return s, nil +} + +// Save writes the settings to launcher.json. +func Save(s Settings) error { + path, err := LauncherJSONPath() + if err != nil { + return err + } + + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return fmt.Errorf("serializing settings: %w", err) + } + + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("writing %s: %w", path, err) + } + return nil +} diff --git a/internal/fetcher/fetcher.go b/internal/fetcher/fetcher.go index 8e6d256..6ecb102 100644 --- a/internal/fetcher/fetcher.go +++ b/internal/fetcher/fetcher.go @@ -1,2 +1,116 @@ // package fetcher handles HTTP downloads and SHA-1 verification. package fetcher + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sync" + + "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/pkg/utils" +) + +// Download downloads a URL to dest, optionally verifying its SHA-1 hash. +// onProgress is called with (downloaded, total) bytes; total may be -1 if unknown. +func Download(url, dest, expectedSHA1 string, onProgress func(downloaded, total int64)) error { + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("GET %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("GET %s → %d", url, resp.StatusCode) + } + + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return fmt.Errorf("creating parent for %s: %w", dest, err) + } + + tmp := dest + ".part" + f, err := os.Create(tmp) + if err != nil { + return fmt.Errorf("creating %s: %w", tmp, err) + } + + total := resp.ContentLength + var written int64 + buf := make([]byte, 32*1024) // 32 KiB buffer + + for { + n, readErr := resp.Body.Read(buf) + if n > 0 { + if _, writeErr := f.Write(buf[:n]); writeErr != nil { + f.Close() + os.Remove(tmp) + return fmt.Errorf("writing %s: %w", tmp, writeErr) + } + written += int64(n) + if onProgress != nil { + onProgress(written, total) + } + } + if readErr == io.EOF { + break + } + if readErr != nil { + f.Close() + os.Remove(tmp) + return fmt.Errorf("reading %s: %w", url, readErr) + } + } + f.Close() + + // Verify SHA-1 if expected hash provided. + if expectedSHA1 != "" { + got, err := utils.SHA1File(tmp) + if err != nil { + os.Remove(tmp) + return fmt.Errorf("hashing %s: %w", tmp, err) + } + if got != expectedSHA1 { + os.Remove(tmp) + return fmt.Errorf("SHA-1 mismatch: expected %s, got %s", expectedSHA1, got) + } + } + + return os.Rename(tmp, dest) +} + +// WorkerPool runs download jobs concurrently with a bounded number of workers. +type WorkerPool struct { + workers int + jobs chan func() + wg sync.WaitGroup +} + +// NewWorkerPool creates a pool with the given number of workers. +func NewWorkerPool(workers int) *WorkerPool { + p := &WorkerPool{ + workers: workers, + jobs: make(chan func(), 100), + } + for i := 0; i < workers; i++ { + p.wg.Add(1) + go func() { + defer p.wg.Done() + for job := range p.jobs { + job() + } + }() + } + return p +} + +// Submit adds a job to the pool. +func (p *WorkerPool) Submit(job func()) { + p.jobs <- job +} + +// Wait blocks until all submitted jobs are finished. +func (p *WorkerPool) Wait() { + close(p.jobs) + p.wg.Wait() +} diff --git a/internal/java/java.go b/internal/java/java.go index 7ea7cf6..7ca567c 100644 --- a/internal/java/java.go +++ b/internal/java/java.go @@ -1,2 +1,64 @@ // package java manages portable JRE downloads and detection. package java + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + + "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/config" +) + +// ExecutableName returns the platform-specific Java executable name. +func ExecutableName() string { + if runtime.GOOS == "windows" { + return "java.exe" + } + return "java" +} + +// IsInstalled checks whether the given Java version is available locally. +func IsInstalled(version int) (string, error) { + dir, err := config.JavaDir(version) + if err != nil { + return "", err + } + bin := filepath.Join(dir, "bin", ExecutableName()) + if _, err := os.Stat(bin); err != nil { + if os.IsNotExist(err) { + return "", nil // not installed + } + return "", fmt.Errorf("checking %s: %w", bin, err) + } + return bin, nil +} + +// Find searches for the required Java version, downloading it if necessary. +// Returns the path to the java binary. +func Find(version int) (string, error) { + bin, err := IsInstalled(version) + if err != nil { + return "", err + } + if bin != "" { + return bin, nil + } + + // TODO: Download and extract JRE for the requested version. + // For now, return an error with instructions. + return "", fmt.Errorf( + "Java %d is not installed. Please wait for auto-download feature "+ + "(planned) or manually place JRE in %s", + version, + must(config.JavaDir(version)), + ) +} + +// must panics on error — only used for error messages where the dir is already known valid. +func must(s string, err error) string { + if err != nil { + panic(err) + } + return s +} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index e9c09c6..a1db685 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -1,2 +1,81 @@ -// package utils provides shared utility functions (SHA-1, ZIP, etc.). +// package utils provides shared utility functions. package utils + +import ( + "archive/zip" + "crypto/sha1" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// SHA1File computes the SHA-1 hex digest of the file at path. +func SHA1File(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("opening %s: %w", path, err) + } + defer f.Close() + + h := sha1.New() + if _, err := io.Copy(h, f); err != nil { + return "", fmt.Errorf("hashing %s: %w", path, err) + } + return fmt.Sprintf("%x", h.Sum(nil)), nil +} + +// SHA1Bytes returns the SHA-1 hex digest of data. +func SHA1Bytes(data []byte) string { + return fmt.Sprintf("%x", sha1.Sum(data)) +} + +// Unzip extracts a zip archive into dest, preserving the directory structure. +func Unzip(src, dest string) error { + r, err := zip.OpenReader(src) + if err != nil { + return fmt.Errorf("opening zip %s: %w", src, err) + } + defer r.Close() + + for _, f := range r.File { + target := filepath.Join(dest, f.Name) + + // Prevent zip-slip. + if !strings.HasPrefix(target, filepath.Clean(dest)+string(os.PathSeparator)) { + return fmt.Errorf("illegal zip path: %s", f.Name) + } + + if f.FileInfo().IsDir() { + if err := os.MkdirAll(target, f.Mode()); err != nil { + return fmt.Errorf("creating directory %s: %w", target, err) + } + continue + } + + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("creating parent for %s: %w", target, err) + } + + out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return fmt.Errorf("creating file %s: %w", target, err) + } + + rc, err := f.Open() + if err != nil { + out.Close() + return fmt.Errorf("reading zip entry %s: %w", f.Name, err) + } + + if _, err := io.Copy(out, rc); err != nil { + rc.Close() + out.Close() + return fmt.Errorf("extracting %s: %w", f.Name, err) + } + rc.Close() + out.Close() + } + return nil +}