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 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
32
go.mod
32
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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user