Compare commits
5 Commits
320f009658
...
e927fff02f
| Author | SHA1 | Date | |
|---|---|---|---|
| e927fff02f | |||
| 5f4ff47ce7 | |||
| c06c205b7b | |||
| 1487360215 | |||
| 070b5c0262 |
@@ -1,23 +1,45 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"log"
|
||||||
|
|
||||||
"fyne.io/fyne/v2/app"
|
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/auth"
|
||||||
"fyne.io/fyne/v2/container"
|
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/config"
|
||||||
"fyne.io/fyne/v2/widget"
|
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// version is set via -ldflags at build time.
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
fmt.Println("MrixsCraft Launcher starting...")
|
log.Printf("MrixsCraft Launcher %s", version)
|
||||||
|
|
||||||
a := app.New()
|
root, err := config.EnsureRoot()
|
||||||
w := a.NewWindow("MrixsCraft Launcher")
|
if err != nil {
|
||||||
w.Resize(fyne.NewSize(800, 600))
|
log.Fatalf("Failed to initialise data directory: %v", err)
|
||||||
|
}
|
||||||
w.SetContent(container.NewVBox(
|
log.Printf("Data directory: %s", root)
|
||||||
widget.NewLabel("MrixsCraft Launcher"),
|
|
||||||
))
|
settings, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
w.ShowAndRun()
|
log.Fatalf("Failed to load settings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.Launch(client, sess, settings)
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
go 1.22
|
||||||
|
|
||||||
require fyne.io/fyne/v2 v2.4.5
|
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
|
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
|
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 handles HTTP downloads and SHA-1 verification.
|
||||||
package fetcher
|
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 manages portable JRE downloads and detection.
|
||||||
package java
|
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,238 @@
|
|||||||
// 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"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"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.
|
||||||
|
func (g *Game) Prepare(onProgress func(downloaded, total int64)) error {
|
||||||
|
if err := os.MkdirAll(g.InstanceDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("creating instance dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download manifest.
|
||||||
|
manifestURL := fmt.Sprintf("%s/api/instances/%s/manifest.json",
|
||||||
|
g.ServerBaseURL, filepath.Base(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
|
||||||
|
|
||||||
|
// Resolve Java.
|
||||||
|
bin, err := java.Find(m.JavaVersion)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.JavaBin = bin
|
||||||
|
|
||||||
|
// Sync files.
|
||||||
|
var totalBytes int64
|
||||||
|
for _, f := range m.Files {
|
||||||
|
totalBytes += f.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
var downloadedBytes int64
|
||||||
|
pool := fetcher.NewWorkerPool(4)
|
||||||
|
|
||||||
|
for _, f := range m.Files {
|
||||||
|
dest := filepath.Join(g.InstanceDir, f.Path)
|
||||||
|
|
||||||
|
// Skip existing verified files.
|
||||||
|
if existing, err := os.Stat(dest); err == nil && existing.Size() == f.Size {
|
||||||
|
if got, err := sha1File(dest); err == nil && got == f.Hash {
|
||||||
|
atomic.AddInt64(&downloadedBytes, f.Size)
|
||||||
|
if onProgress != nil {
|
||||||
|
onProgress(atomic.LoadInt64(&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) {
|
||||||
|
atomic.AddInt64(&downloadedBytes, n)
|
||||||
|
if onProgress != nil {
|
||||||
|
onProgress(atomic.LoadInt64(&downloadedBytes), totalBytes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pool.Wait()
|
||||||
|
|
||||||
|
return g.cleanupUnknownMods()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupUnknownMods moves untracked files from mods/ to mods_backup/.
|
||||||
|
func (g *Game) cleanupUnknownMods() error {
|
||||||
|
modsDir := filepath.Join(g.InstanceDir, "mods")
|
||||||
|
entries, err := os.ReadDir(modsDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
known := make(map[string]bool)
|
||||||
|
for _, f := range g.Manifest.ModFiles() {
|
||||||
|
known[filepath.Base(f.Path)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
backupDir := filepath.Join(g.InstanceDir, "mods_backup")
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || known[entry.Name()] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
var classpathEntries []string
|
||||||
|
for _, lib := range g.Manifest.LibraryFiles() {
|
||||||
|
classpathEntries = append(classpathEntries, filepath.Join(g.InstanceDir, lib.Path))
|
||||||
|
}
|
||||||
|
classpathEntries = append(classpathEntries, filepath.Join(g.InstanceDir, "libraries", "*"))
|
||||||
|
classpathStr := strings.Join(classpathEntries, string(os.PathListSeparator))
|
||||||
|
|
||||||
|
vars := map[string]string{
|
||||||
|
"player_name": g.Session.Username,
|
||||||
|
"auth_uuid": g.Session.UUID,
|
||||||
|
"auth_access_token": g.Session.AccessToken,
|
||||||
|
"classpath": classpathStr,
|
||||||
|
"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",
|
||||||
|
}
|
||||||
|
|
||||||
|
var args []string
|
||||||
|
|
||||||
|
// JVM memory + flags.
|
||||||
|
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 != "" {
|
||||||
|
authlibPath := filepath.Join(g.InstanceDir, "authlib-injector.jar")
|
||||||
|
args = append(args, fmt.Sprintf("-javaagent:%s=%s",
|
||||||
|
authlibPath, g.Manifest.Launch.AuthLibInjector))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classpath + main class.
|
||||||
|
args = append(args, "-cp", classpathStr)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// sha1File computes the SHA-1 hex digest of a file.
|
||||||
|
func sha1File(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
|
||||||
|
}
|
||||||
@@ -1,2 +1,145 @@
|
|||||||
// package selfupdate handles launcher auto-updates via go-selfupdate.
|
// package selfupdate handles launcher auto-updates.
|
||||||
package selfupdate
|
package selfupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpdateInfo describes the latest launcher version from the server.
|
||||||
|
type UpdateInfo struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check queries /api/launcher/latest to see if a newer version is available.
|
||||||
|
func Check(currentVersion string) (*UpdateInfo, error) {
|
||||||
|
s, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("loading config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
latestURL := fmt.Sprintf("%s/api/launcher/latest?os=%s&arch=%s",
|
||||||
|
s.ServerURL, runtime.GOOS, runtime.GOARCH)
|
||||||
|
|
||||||
|
resp, err := http.Get(latestURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying latest version: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var info UpdateInfo
|
||||||
|
if err := json.Unmarshal(data, &info); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing update response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Version == currentVersion {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply downloads the new binary and replaces the current executable.
|
||||||
|
func Apply(info *UpdateInfo) error {
|
||||||
|
execPath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolving executable path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath := execPath + ".new"
|
||||||
|
if err := downloadFile(info.URL, tmpPath); err != nil {
|
||||||
|
return fmt.Errorf("downloading update: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.SHA256 != "" {
|
||||||
|
got, err := sha256File(tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("hashing download: %w", err)
|
||||||
|
}
|
||||||
|
if got != info.SHA256 {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("SHA-256 mismatch: expected %s, got %s", info.SHA256, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Chmod(tmpPath, 0o755); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("chmod %s: %w", tmpPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
oldPath := execPath + ".old"
|
||||||
|
_ = os.Remove(oldPath)
|
||||||
|
if err := os.Rename(execPath, oldPath); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("renaming current binary: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.Rename(tmpPath, execPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart re-launches the current executable and exits.
|
||||||
|
func Restart() error {
|
||||||
|
execPath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
proc, err := os.StartProcess(execPath, os.Args, &os.ProcAttr{
|
||||||
|
Dir: filepath.Dir(execPath),
|
||||||
|
Env: os.Environ(),
|
||||||
|
Files: []*os.File{os.Stdin, os.Stdout, os.Stderr},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("restarting: %w", err)
|
||||||
|
}
|
||||||
|
_ = proc.Release()
|
||||||
|
os.Exit(0)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFile(url, dest string) error {
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
f, err := os.Create(dest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(f, resp.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func sha256File(path string) (string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,94 @@
|
|||||||
// package components provides reusable Fyne widgets (avatar, progress bar).
|
// package components provides reusable Fyne widgets for the launcher UI.
|
||||||
package components
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/canvas"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServerCard displays a single modpack entry in the sidebar.
|
||||||
|
func ServerCard(name string, selected bool, onTap func()) fyne.CanvasObject {
|
||||||
|
bg := canvas.NewRectangle(color.Transparent)
|
||||||
|
bg.SetMinSize(fyne.NewSize(180, 48))
|
||||||
|
|
||||||
|
label := widget.NewLabel(name)
|
||||||
|
label.TextStyle.Bold = selected
|
||||||
|
|
||||||
|
content := container.NewBorder(nil, nil,
|
||||||
|
nil, nil,
|
||||||
|
label,
|
||||||
|
)
|
||||||
|
|
||||||
|
card := container.NewStack(bg, content)
|
||||||
|
|
||||||
|
btn := widget.NewButton("", onTap)
|
||||||
|
btn.Importance = widget.LowImportance
|
||||||
|
|
||||||
|
return container.NewStack(card, btn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProgressBar wraps widget.ProgressBar with show/hide helpers.
|
||||||
|
type ProgressBar struct {
|
||||||
|
*widget.ProgressBar
|
||||||
|
hidden bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProgressBar creates a new progress bar initialized to 0 and hidden.
|
||||||
|
func NewProgressBar() *ProgressBar {
|
||||||
|
pb := widget.NewProgressBar()
|
||||||
|
pb.Hide()
|
||||||
|
return &ProgressBar{
|
||||||
|
ProgressBar: pb,
|
||||||
|
hidden: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetProgress sets the value [0…1] and shows the bar.
|
||||||
|
func (pb *ProgressBar) SetProgress(v float64) {
|
||||||
|
pb.SetValue(v)
|
||||||
|
if pb.hidden {
|
||||||
|
pb.Show()
|
||||||
|
pb.hidden = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done hides the progress bar and resets it.
|
||||||
|
func (pb *ProgressBar) Done() {
|
||||||
|
pb.SetValue(0)
|
||||||
|
pb.Hide()
|
||||||
|
pb.hidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayButton creates the accent "PLAY" button.
|
||||||
|
func PlayButton(onTap func()) *widget.Button {
|
||||||
|
btn := widget.NewButton("▶ PLAY", onTap)
|
||||||
|
btn.Importance = widget.HighImportance
|
||||||
|
return btn
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingsButton creates the small settings (gear) button.
|
||||||
|
func SettingsButton(onTap func()) *widget.Button {
|
||||||
|
btn := widget.NewButton("⚙", onTap)
|
||||||
|
btn.Importance = widget.LowImportance
|
||||||
|
return btn
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogoutButton creates the logout button.
|
||||||
|
func LogoutButton(onTap func()) *widget.Button {
|
||||||
|
btn := widget.NewButton("Выйти", onTap)
|
||||||
|
btn.Importance = widget.LowImportance
|
||||||
|
return btn
|
||||||
|
}
|
||||||
|
|
||||||
|
// AvatarImage returns a placeholder avatar (64×64 colored square).
|
||||||
|
// TODO: render 8×64 face from skin PNG.
|
||||||
|
func AvatarImage() *canvas.Image {
|
||||||
|
img := canvas.NewImageFromResource(nil)
|
||||||
|
img.SetMinSize(fyne.NewSize(64, 64))
|
||||||
|
img.FillMode = canvas.ImageFillContain
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,193 @@
|
|||||||
// package screens implements application screens (login, main menu, settings).
|
// package screens implements application screens (login, main menu, settings).
|
||||||
package screens
|
package screens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/dialog"
|
||||||
|
"fyne.io/fyne/v2/layout"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/auth"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/ui/components"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Modpack represents a single server/modpack entry from the backend.
|
||||||
|
type Modpack struct {
|
||||||
|
Slug string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MainScreen builds the primary launcher window content.
|
||||||
|
func MainScreen(
|
||||||
|
w fyne.Window,
|
||||||
|
client *auth.Client,
|
||||||
|
session *auth.Session,
|
||||||
|
serverList []Modpack,
|
||||||
|
onPlay func(),
|
||||||
|
onSettings func(),
|
||||||
|
) fyne.CanvasObject {
|
||||||
|
|
||||||
|
// ── Sidebar (Left) ──────────────────────────────────────
|
||||||
|
serverLabels := make([]*widget.Label, len(serverList))
|
||||||
|
serverContainer := container.NewVBox()
|
||||||
|
|
||||||
|
for i, sp := range serverList {
|
||||||
|
label := widget.NewLabel(sp.Name)
|
||||||
|
serverLabels[i] = label
|
||||||
|
index := i
|
||||||
|
card := components.ServerCard(sp.Name, index == 0, func() {
|
||||||
|
for j, l := range serverLabels {
|
||||||
|
l.TextStyle.Bold = (j == index)
|
||||||
|
l.Refresh()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
serverContainer.Add(card)
|
||||||
|
}
|
||||||
|
|
||||||
|
sidebar := container.NewVScroll(serverContainer)
|
||||||
|
|
||||||
|
// ── Center (background + info) ──────────────────────────
|
||||||
|
bg := widget.NewRichTextFromMarkdown(
|
||||||
|
"# Welcome to MrixsCraft\n\n" +
|
||||||
|
"Select a server from the left panel and press **PLAY**.\n\n" +
|
||||||
|
"---\n\n" +
|
||||||
|
"*Version: dev*",
|
||||||
|
)
|
||||||
|
center := container.NewMax(widget.NewCard("", "", bg))
|
||||||
|
|
||||||
|
// ── Bottom bar ──────────────────────────────────────────
|
||||||
|
avatar := components.AvatarImage()
|
||||||
|
|
||||||
|
displayName := "Not logged in"
|
||||||
|
if session != nil && session.Username != "" {
|
||||||
|
displayName = session.Username
|
||||||
|
}
|
||||||
|
usernameLabel := widget.NewLabel(displayName)
|
||||||
|
|
||||||
|
logoutBtn := components.LogoutButton(func() {
|
||||||
|
dialog.ShowConfirm(
|
||||||
|
"Logout",
|
||||||
|
"Are you sure you want to log out?",
|
||||||
|
func(confirmed bool) {
|
||||||
|
if confirmed {
|
||||||
|
_ = auth.Delete()
|
||||||
|
w.Close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
w,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
bottomLeft := container.NewHBox(
|
||||||
|
avatar,
|
||||||
|
usernameLabel,
|
||||||
|
layout.NewSpacer(),
|
||||||
|
logoutBtn,
|
||||||
|
)
|
||||||
|
|
||||||
|
progress := components.NewProgressBar()
|
||||||
|
|
||||||
|
playBtn := components.PlayButton(onPlay)
|
||||||
|
settingsBtn := components.SettingsButton(onSettings)
|
||||||
|
|
||||||
|
bottomRight := container.NewHBox(
|
||||||
|
settingsBtn,
|
||||||
|
widget.NewSeparator(),
|
||||||
|
playBtn,
|
||||||
|
)
|
||||||
|
|
||||||
|
bottomCenter := container.NewMax(progress)
|
||||||
|
|
||||||
|
bottom := container.NewBorder(
|
||||||
|
nil, nil,
|
||||||
|
bottomLeft,
|
||||||
|
bottomRight,
|
||||||
|
bottomCenter,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Assemble ────────────────────────────────────────────
|
||||||
|
return container.NewBorder(
|
||||||
|
nil,
|
||||||
|
bottom,
|
||||||
|
sidebar,
|
||||||
|
nil,
|
||||||
|
center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginScreen builds a modal login form.
|
||||||
|
func LoginScreen(w fyne.Window, client *auth.Client, onLogin func(*auth.Session)) {
|
||||||
|
username := widget.NewEntry()
|
||||||
|
username.SetPlaceHolder("Username or email")
|
||||||
|
|
||||||
|
password := widget.NewPasswordEntry()
|
||||||
|
password.SetPlaceHolder("Password")
|
||||||
|
|
||||||
|
form := widget.NewForm(
|
||||||
|
widget.NewFormItem("Username", username),
|
||||||
|
widget.NewFormItem("Password", password),
|
||||||
|
)
|
||||||
|
|
||||||
|
dialog.ShowCustomConfirm(
|
||||||
|
"Login — MrixsCraft",
|
||||||
|
"Login",
|
||||||
|
"Cancel",
|
||||||
|
form,
|
||||||
|
func(confirmed bool) {
|
||||||
|
if !confirmed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess, err := client.Authenticate(username.Text, password.Text)
|
||||||
|
if err != nil {
|
||||||
|
dialog.ShowError(err, w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := sess.Save(); err != nil {
|
||||||
|
dialog.ShowError(err, w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onLogin(sess)
|
||||||
|
},
|
||||||
|
w,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingsScreen builds the settings modal.
|
||||||
|
func SettingsScreen(w fyne.Window, onSave func(memoryMB int, extraArgs string)) {
|
||||||
|
memorySlider := widget.NewSlider(1024, 16384)
|
||||||
|
memorySlider.Step = 512
|
||||||
|
memorySlider.Value = 4096
|
||||||
|
|
||||||
|
memLabel := widget.NewLabel("4096 MB")
|
||||||
|
memorySlider.OnChanged = func(v float64) {
|
||||||
|
memLabel.SetText(fmt.Sprintf("%.0f MB", v))
|
||||||
|
}
|
||||||
|
|
||||||
|
extraArgs := widget.NewMultiLineEntry()
|
||||||
|
extraArgs.SetPlaceHolder("e.g. -XX:+UseG1GC -XX:MaxGCPauseMillis=50")
|
||||||
|
|
||||||
|
content := container.NewVBox(
|
||||||
|
widget.NewLabelWithStyle("Memory (RAM)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
memLabel,
|
||||||
|
memorySlider,
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabelWithStyle("Extra JVM arguments", fyne.TextAlignLeading, fyne.TextStyle{}),
|
||||||
|
extraArgs,
|
||||||
|
)
|
||||||
|
|
||||||
|
dialog.ShowCustomConfirm(
|
||||||
|
"Settings",
|
||||||
|
"Save",
|
||||||
|
"Cancel",
|
||||||
|
content,
|
||||||
|
func(confirmed bool) {
|
||||||
|
if confirmed {
|
||||||
|
onSave(int(memorySlider.Value), extraArgs.Text)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
w,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,92 @@
|
|||||||
// package theme defines the custom Fyne theme (Minecraft-style colors, fonts).
|
// package theme defines a custom Fyne theme with Minecraft-inspired colors.
|
||||||
package theme
|
package theme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MinecraftTheme is a dark theme inspired by Minecraft's UI palette.
|
||||||
|
type MinecraftTheme struct{}
|
||||||
|
|
||||||
|
var _ fyne.Theme = (*MinecraftTheme)(nil)
|
||||||
|
|
||||||
|
// Color returns the named color from the Minecraft palette.
|
||||||
|
func (t *MinecraftTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
|
||||||
|
switch name {
|
||||||
|
case theme.ColorNameBackground:
|
||||||
|
return color.RGBA{R: 30, G: 30, B: 30, A: 255} // dark stone
|
||||||
|
case theme.ColorNameButton:
|
||||||
|
return color.RGBA{R: 100, G: 100, B: 100, A: 255} // stone gray
|
||||||
|
case theme.ColorNameDisabled:
|
||||||
|
return color.RGBA{R: 80, G: 80, B: 80, A: 255}
|
||||||
|
case theme.ColorNameDisabledButton:
|
||||||
|
return color.RGBA{R: 60, G: 60, B: 60, A: 255}
|
||||||
|
case theme.ColorNameError:
|
||||||
|
return color.RGBA{R: 200, G: 50, B: 50, A: 255}
|
||||||
|
case theme.ColorNameFocus:
|
||||||
|
return color.RGBA{R: 76, G: 175, B: 80, A: 255} // green accent
|
||||||
|
case theme.ColorNameForeground:
|
||||||
|
return color.RGBA{R: 220, G: 220, B: 220, A: 255} // light gray
|
||||||
|
case theme.ColorNameHover:
|
||||||
|
return color.RGBA{R: 76, G: 175, B: 80, A: 255} // green
|
||||||
|
case theme.ColorNameInputBackground:
|
||||||
|
return color.RGBA{R: 50, G: 50, B: 50, A: 255}
|
||||||
|
case theme.ColorNameInputBorder:
|
||||||
|
return color.RGBA{R: 80, G: 80, B: 80, A: 255}
|
||||||
|
case theme.ColorNameMenuBackground:
|
||||||
|
return color.RGBA{R: 40, G: 40, B: 40, A: 255}
|
||||||
|
case theme.ColorNameOverlayBackground:
|
||||||
|
return color.RGBA{R: 0, G: 0, B: 0, A: 180}
|
||||||
|
case theme.ColorNamePlaceHolder:
|
||||||
|
return color.RGBA{R: 150, G: 150, B: 150, A: 255}
|
||||||
|
case theme.ColorNamePressed:
|
||||||
|
return color.RGBA{R: 56, G: 142, B: 60, A: 255} // dark green
|
||||||
|
case theme.ColorNamePrimary:
|
||||||
|
return color.RGBA{R: 76, G: 175, B: 80, A: 255} // green
|
||||||
|
case theme.ColorNameScrollBar:
|
||||||
|
return color.RGBA{R: 80, G: 80, B: 80, A: 200}
|
||||||
|
case theme.ColorNameSeparator:
|
||||||
|
return color.RGBA{R: 60, G: 60, B: 60, A: 255}
|
||||||
|
case theme.ColorNameShadow:
|
||||||
|
return color.RGBA{R: 0, G: 0, B: 0, A: 128}
|
||||||
|
default:
|
||||||
|
return theme.DefaultTheme().Color(name, variant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Font returns the default font (custom TTF can be added later).
|
||||||
|
func (t *MinecraftTheme) Font(style fyne.TextStyle) fyne.Resource {
|
||||||
|
return theme.DefaultTheme().Font(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon returns the named icon resource.
|
||||||
|
func (t *MinecraftTheme) Icon(name fyne.ThemeIconName) fyne.Resource {
|
||||||
|
return theme.DefaultTheme().Icon(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size returns the named size value.
|
||||||
|
func (t *MinecraftTheme) Size(name fyne.ThemeSizeName) float32 {
|
||||||
|
switch name {
|
||||||
|
case theme.SizeNameCaptionText:
|
||||||
|
return 11
|
||||||
|
case theme.SizeNameInlineIcon:
|
||||||
|
return 20
|
||||||
|
case theme.SizeNamePadding:
|
||||||
|
return 4
|
||||||
|
case theme.SizeNameScrollBar:
|
||||||
|
return 12
|
||||||
|
case theme.SizeNameScrollBarSmall:
|
||||||
|
return 4
|
||||||
|
case theme.SizeNameText:
|
||||||
|
return 14
|
||||||
|
case theme.SizeNameHeadingText:
|
||||||
|
return 24
|
||||||
|
case theme.SizeNameSubHeadingText:
|
||||||
|
return 18
|
||||||
|
default:
|
||||||
|
return theme.DefaultTheme().Size(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,55 @@
|
|||||||
// package ui contains Fyne GUI code (main window, theme, navigation).
|
// package ui contains the Fyne GUI bootstrap and wiring.
|
||||||
package ui
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/app"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/auth"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/config"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/ui/screens"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/ui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Launch initialises the Fyne application with the Minecraft theme and shows the main window.
|
||||||
|
func Launch(client *auth.Client, session *auth.Session, settings config.Settings) {
|
||||||
|
a := app.New()
|
||||||
|
a.Settings().SetTheme(&theme.MinecraftTheme{})
|
||||||
|
|
||||||
|
w := a.NewWindow("MrixsCraft")
|
||||||
|
w.Resize(fyne.NewSize(float32(settings.Width), float32(settings.Height)))
|
||||||
|
w.CenterOnScreen()
|
||||||
|
|
||||||
|
// TODO: fetch from /api/servers.json
|
||||||
|
serverList := []screens.Modpack{
|
||||||
|
{Slug: "hitech", Name: "HiTech 1.21"},
|
||||||
|
{Slug: "vanilla", Name: "Vanilla 1.20"},
|
||||||
|
}
|
||||||
|
|
||||||
|
onPlay := func() {
|
||||||
|
// TODO: launch selected modpack
|
||||||
|
}
|
||||||
|
|
||||||
|
onSettings := func() {
|
||||||
|
screens.SettingsScreen(w, func(memoryMB int, extraArgs string) {
|
||||||
|
settings.MemoryMB = memoryMB
|
||||||
|
settings.ExtraArgs = extraArgs
|
||||||
|
_ = config.Save(settings)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
content := screens.MainScreen(w, client, session, serverList, onPlay, onSettings)
|
||||||
|
w.SetContent(content)
|
||||||
|
|
||||||
|
// If no session, show login modal after the window is rendered.
|
||||||
|
if session == nil {
|
||||||
|
w.Show()
|
||||||
|
screens.LoginScreen(w, client, func(sess *auth.Session) {
|
||||||
|
// Refresh UI with logged-in state.
|
||||||
|
content := screens.MainScreen(w, client, sess, serverList, onPlay, onSettings)
|
||||||
|
w.SetContent(content)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
w.ShowAndRun()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,81 @@
|
|||||||
// package utils provides shared utility functions (SHA-1, ZIP, etc.).
|
// package utils provides shared utility functions.
|
||||||
package utils
|
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