From d60493e1b1cc70b7df7bfbab1b6d93affc512a9f Mon Sep 17 00:00:00 2001 From: "Alexander \"Arav\" Andreev" Date: Mon, 30 Oct 2023 02:18:41 +0400 Subject: [PATCH] I think it's time to init a repo already. xD --- .gitignore | 1 + Makefile | 32 ++ build/PKGBUILD | 26 ++ cmd/mccl/commands/command.go | 5 + cmd/mccl/commands/install_command.go | 325 ++++++++++++++++++ cmd/mccl/commands/list_command.go | 33 ++ cmd/mccl/commands/run_command.go | 163 +++++++++ cmd/mccl/main.go | 232 +++++++++++++ go.mod | 5 + go.sum | 2 + internal/assets/assets.go | 124 +++++++ internal/manifest/argument.go | 60 ++++ internal/manifest/argument_test.go | 16 + internal/manifest/library.go | 136 ++++++++ internal/manifest/manifest.go | 200 +++++++++++ internal/manifest/rule.go | 122 +++++++ internal/manifest/rule_test.go | 49 +++ internal/mccl_profile/profile.go | 47 +++ internal/version_manifest/version_manifest.go | 74 ++++ pkg/retriever/retriever.go | 81 +++++ pkg/util/locatejavahome_unix.go | 8 + pkg/util/locatejavahome_windows.go | 68 ++++ pkg/util/osversion_unix.go | 26 ++ pkg/util/osversion_windows.go | 29 ++ pkg/util/util.go | 145 ++++++++ 25 files changed, 2009 insertions(+) create mode 100644 .gitignore create mode 100755 Makefile create mode 100644 build/PKGBUILD create mode 100755 cmd/mccl/commands/command.go create mode 100755 cmd/mccl/commands/install_command.go create mode 100755 cmd/mccl/commands/list_command.go create mode 100755 cmd/mccl/commands/run_command.go create mode 100755 cmd/mccl/main.go create mode 100755 go.mod create mode 100755 go.sum create mode 100755 internal/assets/assets.go create mode 100755 internal/manifest/argument.go create mode 100755 internal/manifest/argument_test.go create mode 100755 internal/manifest/library.go create mode 100755 internal/manifest/manifest.go create mode 100755 internal/manifest/rule.go create mode 100755 internal/manifest/rule_test.go create mode 100644 internal/mccl_profile/profile.go create mode 100755 internal/version_manifest/version_manifest.go create mode 100755 pkg/retriever/retriever.go create mode 100644 pkg/util/locatejavahome_unix.go create mode 100644 pkg/util/locatejavahome_windows.go create mode 100644 pkg/util/osversion_unix.go create mode 100644 pkg/util/osversion_windows.go create mode 100755 pkg/util/util.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6dd29b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +bin/ \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100755 index 0000000..a05159f --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +TARGET=mccl + +ifeq ($(shell go env GOOS),windows) + EXEC_NAME=${TARGET}.exe +else + EXEC_NAME=${TARGET} +endif +DESTDIR:= +PREFIX:=/usr/local + +VERSION=0.1.0 + +FLAGS:=-buildmode=pie -modcacherw -mod=readonly -trimpath +LDFLAGS:= -ldflags "-s -w -X main.programVersion=${VERSION}" + +.PHONY: ${TARGET} install uninstall clean + +${TARGET}: +ifeq ($(shell go env GOOS),windows) + go build -o bin/$@.exe ${LDFLAGS} ${FLAGS} cmd/$@/main.go +else + go build -o bin/$@ ${LDFLAGS} ${FLAGS} cmd/$@/main.go +endif + +install: + install -Dm 0755 bin/${EXEC_NAME} ${DESTDIR}${PREFIX}/bin/${EXEC_NAME} + +uninstall: + rm ${DESTDIR}${PREFIX}/bin/${EXEC_NAME} + +clean: + rm -f bin/* \ No newline at end of file diff --git a/build/PKGBUILD b/build/PKGBUILD new file mode 100644 index 0000000..521539f --- /dev/null +++ b/build/PKGBUILD @@ -0,0 +1,26 @@ +# Maintainer: Alexander "Arav" Andreev +pkgname=mccl +pkgver=0.1.0 +pkgrel=1 +pkgdesc="Console Minecraft launcher" +arch=('i686' 'x86_64' 'arm' 'armv6h' 'armv7h' 'aarch64') +url="https://git.arav.su/Arav/mccl" +license=('GPL3') +makedepends=('go>=1.17') +source=("${pkgver}.tar.gz::https://git.arav.su/Arav/mccl/archive/v${pkgver}.tar.gz") +md5sums=('SKIP') + +build() { + cd "$srcdir/$pkgname" + export GOPATH="$srcdir"/gopath + export CGO_CPPFLAGS="${CPPFLAGS}" + export CGO_CFLAGS="${CFLAGS}" + export CGO_CXXFLAGS="${CXXFLAGS}" + export CGO_LDFLAGS="${LDFLAGS}" + make VERSION=$pkgver DESTDIR="$pkgdir" PREFIX="/usr" +} + +package() { + cd "$srcdir/$pkgname" + make DESTDIR="$pkgdir" PREFIX="/usr" install +} diff --git a/cmd/mccl/commands/command.go b/cmd/mccl/commands/command.go new file mode 100755 index 0000000..fa17d32 --- /dev/null +++ b/cmd/mccl/commands/command.go @@ -0,0 +1,5 @@ +package commands + +type Command interface { + Run() error +} diff --git a/cmd/mccl/commands/install_command.go b/cmd/mccl/commands/install_command.go new file mode 100755 index 0000000..67d1bc9 --- /dev/null +++ b/cmd/mccl/commands/install_command.go @@ -0,0 +1,325 @@ +package commands + +import ( + "archive/zip" + "errors" + "fmt" + "io" + "mccl/internal/assets" + "mccl/internal/manifest" + "mccl/internal/version_manifest" + "mccl/pkg/retriever" + "mccl/pkg/util" + "os" + "path" + "runtime" + "strings" + "time" +) + +type InstallCommand struct { + Id string + DestDir string +} + +func NewInstallCommand(id, destDir string) *InstallCommand { + return &InstallCommand{Id: id, DestDir: destDir} +} + +func (ic *InstallCommand) Run() error { + if ic.Id == "" { + return errors.New("an empty Minecraft version was provided") + } + if ic.DestDir == "" { + return errors.New("an empty path was provided") + } + return ic.install_vanilla_client() +} + +func (ic *InstallCommand) install_vanilla_client() error { + var err error + var data []byte + + if ic.DestDir == "." { + execPath, _ := os.Executable() + ic.DestDir = path.Dir(execPath) + } + + if _, err := os.Stat(ic.DestDir); err != nil { + if err := os.Mkdir(ic.DestDir, 0777); err != nil { + return err + } + } + + fmt.Print("Retrieving a version_manifest_v2.json... ") + data, err = util.GetFromUrl(version_manifest.VersionManifestUrl, "", "", -1) + if err != nil { + return err + } + + versionManifest, err := version_manifest.New(data) + if err != nil { + return err + } + fmt.Println("Done") + + var version version_manifest.Version + if ic.Id == "release" || ic.Id == "latest-release" { + version, err = versionManifest.GetLatest(version_manifest.VersionTypeRelease) + if err != nil { + return err + } + } else if ic.Id == "snapshot" || ic.Id == "latest-snapshot" { + version, err = versionManifest.GetLatest(version_manifest.VersionTypeSnapshot) + if err != nil { + return err + } + } else { + version, err = versionManifest.Get(ic.Id) + if err != nil { + return err + } + } + fmt.Printf("Version %s (%s) was chosen.\n", version.Id, version.Type) + + fmt.Print("Retrieving a manifest file... ") + manifestPath := path.Join(ic.DestDir, "versions", version.Id, version.Id+".json") + data, err = util.LoadOrDownloadFile(manifestPath, version.Url, version.Sha1, "sha1", -1) + if err != nil { + return err + } + + manifst, err := manifest.New(data) + if err != nil { + return err + } + fmt.Println("Done") + + if !util.IsFileExist(manifestPath) { + fmt.Print("Writing a manifest file to ", manifestPath, " ... ") + if err := util.WriteFile(manifestPath, data); err != nil { + return err + } + fmt.Println("Done") + } else { + fmt.Println(manifestPath, "does exist. Skipping") + } + + clientPath := path.Join(ic.DestDir, "versions", manifst.Id, manifst.Id+".jar") + if !util.IsFileExist(clientPath) { + fmt.Print("Retrieving and writing a client to ", clientPath, " ... ") + data, err = util.LoadOrDownloadFile(clientPath, manifst.Downloads["client"].Url, + manifst.Downloads["client"].Sha1, "sha1", manifst.Downloads["client"].Size) + if err != nil { + return err + } + if err := util.WriteFile(clientPath, data); err != nil { + return err + } + fmt.Println("Done") + } else { + fmt.Println(clientPath, "does exist. Skipping") + } + + fmt.Printf("Retrieving an asset index %s.json... ", manifst.AssetIndex.Id) + assetIndexPath := path.Join(ic.DestDir, "assets", "indexes", manifst.AssetIndex.Id+".json") + + data, err = util.LoadOrDownloadFile(assetIndexPath, manifst.AssetIndex.Url, + manifst.AssetIndex.Sha1, "sha1", manifst.AssetIndex.Size) + if err != nil { + return err + } + + asts, err := assets.New(data) + if err != nil { + return err + } + fmt.Println("Done") + + if !util.IsFileExist(assetIndexPath) { + fmt.Printf("Writing an asset index %s to %s...", manifst.AssetIndex.Id, assetIndexPath) + if err := util.WriteFile(assetIndexPath, data); err != nil { + return err + } + fmt.Println("Done") + } else { + fmt.Printf("%s does exist. Skip writing\n", assetIndexPath) + } + + fmt.Print("Retrieving assets...") + + assetsDir := path.Join(ic.DestDir, "assets", "objects") + ar := retriever.New() + defer ar.Close() + + assetsCount := len(asts.Objects) + var assetItems []retriever.Retrievable + + for _, v := range asts.Objects { + assetItems = append(assetItems, v) + } + + go func() { + if err := ar.Run(assetItems, assets.RetrieveAsset, assetsDir); err != nil { + fmt.Println("\n", err) + os.Exit(1) + } + }() + + for written, cnt := 0, 0; cnt < assetsCount; { + if len(ar.GetErrorChan()) > 0 { + return <-ar.GetErrorChan() + } + + if cnt < assetsCount { + written += <-ar.GetDoneChan() + } + cnt++ + + fmt.Printf("\rRetrieving assets... %d/%d (%d/%d)", written, manifst.AssetIndex.TotalSize, cnt, assetsCount) + } + fmt.Printf("\nRetrieving assets... Done\n") + + if manifst.Logging.Client.Argument != "" { + logConfPath := path.Join(ic.DestDir, "assets", "log_configs", manifst.Logging.Client.File.Id) + if !util.IsFileExist(logConfPath) { + fmt.Printf("Retrieving and writing %s...", logConfPath) + data, err = util.LoadOrDownloadFile(logConfPath, manifst.Logging.Client.File.Url, + manifst.Logging.Client.File.Sha1, "sha1", manifst.Logging.Client.File.Size) + if err != nil { + return err + } + if err := util.WriteFile(logConfPath, data); err != nil { + return err + } + fmt.Println("Done") + } else { + fmt.Printf("%s does exist. Skip writing\n", logConfPath) + } + } + + fmt.Print("Retrieving libraries...") + + librariesDir := path.Join(ic.DestDir, "libraries") + lr := retriever.New() + defer lr.Close() + + var libraryItems []retriever.Retrievable + var totalLibrariesSize int64 = 0 + + for _, lib := range manifst.Libraries { + if lib.CheckRules() { + files := lib.ToFile() + + for _, f := range files { + totalLibrariesSize += f.Size + libraryItems = append(libraryItems, f) + } + } + } + + go func() { + if err := lr.Run(libraryItems, manifest.RetrieveLibrary, librariesDir); err != nil { + fmt.Println("\n", err) + os.Exit(1) + } + }() + + librariesCount := len(libraryItems) + + for written, cnt := 0, 0; cnt < librariesCount; { + if len(lr.GetErrorChan()) > 0 { + return <-lr.GetErrorChan() + } + + if cnt < librariesCount { + written += <-lr.GetDoneChan() + } + cnt++ + + fmt.Printf("\rRetrieving libraries... %d/%d (%d/%d)", + written, totalLibrariesSize, cnt, librariesCount) + } + + fmt.Printf("\nRetrieving libraries... Done\n") + + releaseTime, err := time.Parse(time.RFC3339, manifst.ReleaseTime) + if err != nil { + releaseTime, err = time.Parse("2006-01-02T15:04:05Z0700", manifst.ReleaseTime) + if err != nil { + return err + } + } + + firstVerWithNoExtract, _ := time.Parse(time.DateOnly, "2017-10-25") + if releaseTime.Before(firstVerWithNoExtract) { + nativesDir := path.Join(ic.DestDir, "versions", manifst.Id, "natives") + + if err := os.MkdirAll(nativesDir, 0777); err != nil { + return err + } + + fmt.Printf("Extracting native libraries to %s ... ", nativesDir) + for _, lib := range libraryItems { + l := lib.(util.File) + + if !strings.Contains(l.GetName(), "natives") { + continue + } + + zipFile, err := zip.OpenReader(path.Join(librariesDir, l.Path)) + if err != nil { + return err + } + defer zipFile.Close() + + for _, f := range zipFile.File { + if strings.HasPrefix(f.Name, "META-INF/") { + continue + } + + if strings.Contains(f.Name, "64") && runtime.GOARCH != "amd64" { + continue + } + + switch runtime.GOARCH { + case "386": + if strings.Contains(f.Name, "64") { + continue + } + case "amd64": + if !strings.Contains(f.Name, "64") { + continue + } + } + + if util.IsFileExist(path.Join(nativesDir, f.Name)) { + continue + } + + frc, err := f.Open() + if err != nil { + return err + } + defer frc.Close() + + out, err := os.OpenFile(path.Join(nativesDir, f.Name), os.O_CREATE|os.O_RDWR, 0666) + if err != nil { + return err + } + defer out.Close() + + written, err := io.Copy(out, frc) + if err != nil { + return fmt.Errorf("failed to extract file %s: %s", f.Name, err) + } else if written != int64(f.UncompressedSize64) { + return fmt.Errorf("failed to extract file %s: size is %d, but %d was written", + f.Name, f.UncompressedSize64, written) + } + } + } + fmt.Println("Done") + } + + return nil +} diff --git a/cmd/mccl/commands/list_command.go b/cmd/mccl/commands/list_command.go new file mode 100755 index 0000000..4c25545 --- /dev/null +++ b/cmd/mccl/commands/list_command.go @@ -0,0 +1,33 @@ +package commands + +import ( + "fmt" + "os" + "path" +) + +type ListCommand struct { + GameDir string +} + +func NewListCommand(gameDir string) *ListCommand { + return &ListCommand{GameDir: gameDir} +} + +func (lc *ListCommand) Run() error { + versionsDir := path.Join(lc.GameDir, "versions") + if _, err := os.Stat(versionsDir); err != nil { + return fmt.Errorf("versions directory wasn't found inside %s", lc.GameDir) + } + entries, err := os.ReadDir(path.Join(lc.GameDir, "versions")) + if err != nil { + return err + } + fmt.Println("There are following versions installed:") + for _, entry := range entries { + if entry.IsDir() { + fmt.Println(entry.Name()) + } + } + return nil +} diff --git a/cmd/mccl/commands/run_command.go b/cmd/mccl/commands/run_command.go new file mode 100755 index 0000000..0191bec --- /dev/null +++ b/cmd/mccl/commands/run_command.go @@ -0,0 +1,163 @@ +package commands + +import ( + "errors" + "fmt" + "mccl/internal/manifest" + mcclprofile "mccl/internal/mccl_profile" + "mccl/pkg/util" + "os" + "os/exec" + "path" + "regexp" + "strings" +) + +type RunCommand struct { + Id string + GameDir string + Username string + AuthUuid string + JavaXmx string + UseProfile bool +} + +func NewRunCommand(id, gameDir, user, uuid, jxmx string, useProfile bool) *RunCommand { + return &RunCommand{Id: id, GameDir: gameDir, Username: user, AuthUuid: uuid, JavaXmx: jxmx, UseProfile: useProfile} +} + +func (rc *RunCommand) Run() error { + if rc.Id == "" { + return errors.New("an empty Minecraft version was provided") + } + if err := os.Chdir(rc.GameDir); err != nil { + return err + } + if rc.UseProfile { + if util.IsFileExist(path.Join(rc.GameDir, mcclprofile.ProfileFileName)) { + p, err := mcclprofile.Load(rc.GameDir) + if err != nil { + return err + } + rc.Username = p.Username + rc.AuthUuid = p.Uuid + rc.JavaXmx = p.JavaXmx + } else { + p := mcclprofile.Profile{ + Username: rc.Username, + Uuid: rc.AuthUuid, + JavaXmx: rc.JavaXmx} + if err := p.Store(rc.GameDir); err != nil { + return err + } + } + } + return rc.run_client() +} + +func (rc *RunCommand) run_client() error { + manifestPath := path.Join(rc.GameDir, "versions", rc.Id, rc.Id+".json") + + data, err := util.ReadFile(manifestPath) + if err != nil { + return err + } + + manifst, err := manifest.New(data) + if err != nil { + return err + } + + if manifst.InheritsFrom != "" { + manifestPath := path.Join(rc.GameDir, "versions", manifst.InheritsFrom, manifst.InheritsFrom+".json") + + data, err := util.ReadFile(manifestPath) + if err != nil { + return err + } + + inherit, err := manifest.New(data) + if err != nil { + return err + } + + if err := manifst.InheritFrom(inherit); err != nil { + return err + } + } + + p := make(map[string]string) + + p["auth_player_name"] = rc.Username + p["version_name"] = manifst.Id + p["game_directory"] = rc.GameDir + p["assets_root"] = "assets" + p["game_assets"] = p["assets_root"] + p["assets_index_name"] = manifst.AssetIndex.Id + p["auth_uuid"] = rc.AuthUuid + p["auth_access_token"] = "null" + p["auth_session"] = p["auth_access_token"] + p["clientid"] = manifst.Id + p["auth_xuid"] = "null" + p["user_type"] = "legacy" + p["version_type"] = manifst.Type + p["natives_directory"] = path.Join("", "versions", manifst.Id, "natives") + p["launcher_name"] = "mccl" + p["launcher_version"] = "0.1.0" + p["classpath"] = manifst.BuildClasspath("", "versions") + if manifst.Logging.Client.File.Id != "" { + p["logging_path"] = path.Join("assets", "log_configs", manifst.Logging.Client.File.Id) + } + + if strings.Contains(manifst.Id, "forge") { + p["library_directory"] = "libraries" + p["classpath_separator"] = util.PathSeparator() + } + + cl := manifst.BuildCommandLine(p) + + if rc.JavaXmx != "" { + ok, err := regexp.MatchString("^\\d+(K|k|M|m|G|g|T|t)$", rc.JavaXmx) + if ok && err == nil { + rc.JavaXmx = "-Xmx" + rc.JavaXmx + tcl := []string{} + tcl = append(tcl, rc.JavaXmx) + cl = append(tcl, cl...) + } else if err != nil || !ok { + if err != nil { + fmt.Printf("An error occured while parsing Java -Xmx parameter: %s. Game's default will be used. ", err) + } else { + fmt.Printf("Mismatch occured while parsing Java -Xmx parameter. Game's default will be used. ") + } + fmt.Printf("Right form is Kk|Mm|Gg|Tt, e.g. 4G.") + } + } + + javaHome, err := util.LocateJavaHome(manifst.JavaVersion.Component, manifst.JavaVersion.MajorVersion) + if err != nil { + return err + } + + cmd := exec.Command(path.Join(javaHome, "bin", "java"), cl...) + // stdout, err := cmd.StdoutPipe() + // cmd.Stderr = cmd.Stdout + // if err != nil { + // return err + // } + // defer stdout.Close() + + // scanner := bufio.NewScanner(stdout) + // go func() { + // for scanner.Scan() { + // fmt.Println(scanner.Text()) + // } + // }() + + fmt.Printf("Minecraft version %s is started with usename %s and player's UUID %s.\n", + manifst.Id, rc.Username, rc.AuthUuid) + if err := cmd.Run(); err != nil && (err.Error() != "exec: already started") { + return err + } + + return nil +} diff --git a/cmd/mccl/main.go b/cmd/mccl/main.go new file mode 100755 index 0000000..ddd911a --- /dev/null +++ b/cmd/mccl/main.go @@ -0,0 +1,232 @@ +package main + +import ( + "fmt" + "mccl/cmd/mccl/commands" + mcclprofile "mccl/internal/mccl_profile" + "os" +) + +var programVersion string + +func main() { + args, err := parseArguments(os.Args[1:]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + usage() + os.Exit(1) + } + + if args.ShowVersion { + version() + } + + if args.ShowHelp { + usage() + } + + if args.ShowHelp || args.ShowVersion { + os.Exit(0) + } + + if len(os.Args) < 2 { + usage() + os.Exit(0) + } + + var cmd commands.Command + + switch args.Command { + case "install": + fallthrough + case "i": + cmd = commands.NewInstallCommand(args.GameVersion, args.Directory) + case "run": + fallthrough + case "r": + cmd = commands.NewRunCommand(args.GameVersion, args.Directory, + args.Username, args.Uuid, args.JavaXmx, args.UseProfile) + case "list": + fallthrough + case "l": + cmd = commands.NewListCommand(args.Directory) + default: + usage() + os.Exit(0) + } + + exitCode := 0 + + if err := cmd.Run(); err != nil { + fmt.Println(err) + exitCode = 1 + } + + if args.DemandPressEnter { + fmt.Fprint(os.Stderr, "Press the enter key to continue...") + fmt.Scanln() + } + + os.Exit(exitCode) +} + +func version() { + fmt.Fprintf(os.Stderr, "mccl ver. %s\nCopyright (c) 2023 Alexander \"Arav\" Andreev \n", programVersion) + fmt.Fprintln(os.Stderr, "License GPLv3+: GNU GPL version 3 or later .") +} + +func usage() { + fmt.Fprintln(os.Stderr, "Usage: mccl []... command arguments... [options]...") + fmt.Fprintln(os.Stderr, "Common options:\n"+ + " -h,--help show help\n"+ + " -v,--version show version\n"+ + " --demand-press-enter\n"+ + " ask to press the enter key before closing\n"+ + "Commands:\n"+ + " \033[1minstall|i\033[0m version directory\n"+ + " Install any vanilla version (for mod loaders use theirs installers)\n"+ + " \033[1mrun|r\033[0m version directory [-u username] [-U UUID] [-java-Xmx memory] [--profile]\n"+ + " Run vanilla, forge, fabric, quilt, the others aren't tested\n"+ + " \033[1mlist|l\033[0m directory\n"+ + " List all installed versions in directory\n"+ + "Explanation of arguments and options for commands:\n"+ + " version minecraft's version \n"+ + " directory a path to where Minecraft will be installed\n"+ + " -u,--username username\n"+ + " player's username (Anonymous by default)\n"+ + " -U,--uuid UUID\n"+ + " player's account UUID (all zeroes by default)\n"+ + " --java-Xmx memory\n"+ + " java's -Xmx param, e.g. 4G\n"+ + " --profile\n"+ + " load/save username, UUID, and -Xmx from/to a file.\n"+ + " Once saved, you can ommit -u,-U and --java-Xmx,\n"+ + " since they are stored in", mcclprofile.ProfileFileName) +} + +type arguments struct { + ShowVersion bool + ShowHelp bool + DemandPressEnter bool + + Command string + GameVersion string + Directory string + Username string + Uuid string + JavaXmx string + + UseProfile bool +} + +type errNoOptArg struct { + ArgName string +} + +func (e errNoOptArg) Error() string { + return fmt.Sprintf("%s option demands an argument, but it wasn't provided", e.ArgName) +} + +func parseArguments(args []string) (parsed arguments, err error) { + parsed.Username = "Anonymous" + parsed.Directory = "." + parsed.Uuid = "00000000-0000-0000-0000-000000000000" + for i := 0; i < len(args); i++ { + if len(args[i]) > 2 && args[i][0] == '-' && args[i][1] == '-' { + switch args[i][2:] { + case "version": + parsed.ShowVersion = true + case "help": + parsed.ShowHelp = true + case "demand-press-enter": + parsed.DemandPressEnter = true + case "username": + if len(args) == i+1 || args[i+1][0] == '-' { + err = errNoOptArg{ArgName: args[i]} + return + } + parsed.Username = args[i+1] + i++ + case "uuid": + if len(args) == i+1 || args[i+1][0] == '-' { + err = errNoOptArg{ArgName: args[i]} + return + } + parsed.Uuid = args[i+1] + i++ + case "java-Xmx": + if len(args) == i+1 || args[i+1][0] == '-' { + err = errNoOptArg{ArgName: args[i]} + return + } + parsed.JavaXmx = args[i+1] + i++ + case "profile": + parsed.UseProfile = true + default: + err = fmt.Errorf("an unknown option %s was provided", args[i]) + return + } + } else if args[i][0] == '-' && len(args[i]) > 1 { + for _, option := range args[i][1:] { + switch option { + case 'v': + parsed.ShowVersion = true + case 'h': + parsed.ShowHelp = true + case 'u': + if len(args) == i+1 || args[i+1][0] == '-' { + err = errNoOptArg{ArgName: args[i]} + return + } + parsed.Username = args[i+1] + i++ + case 'U': + if len(args) == i+1 || args[i+1][0] == '-' { + err = errNoOptArg{ArgName: args[i]} + return + } + parsed.Uuid = args[i+1] + i++ + default: + err = fmt.Errorf("an unknown option %c was provided", option) + return + } + } + } else { + if parsed.Command != "" { + err = fmt.Errorf("only one command should present") + return + } + switch parsed.Command = args[i]; parsed.Command { + case "i": + fallthrough + case "install": + fallthrough + case "r": + fallthrough + case "run": + if len(args) <= i+3 || args[i+1][0] == '-' || args[i+2][0] == '-' { + err = fmt.Errorf("not enough arguments was provided") + return + } + parsed.GameVersion = args[i+1] + parsed.Directory = args[i+2] + i += 2 + case "l": + fallthrough + case "list": + if len(args) <= i+2 || args[i+1][0] == '-' { + err = fmt.Errorf("not enough arguments was provided") + return + } + parsed.Directory = args[i+1] + i++ + default: + err = fmt.Errorf("an unknown command %s was provided", parsed.Command) + return + } + } + } + return +} diff --git a/go.mod b/go.mod new file mode 100755 index 0000000..e1c1581 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module mccl + +go 1.17 + +require golang.org/x/sys v0.13.0 diff --git a/go.sum b/go.sum new file mode 100755 index 0000000..d4673ec --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/assets/assets.go b/internal/assets/assets.go new file mode 100755 index 0000000..a231705 --- /dev/null +++ b/internal/assets/assets.go @@ -0,0 +1,124 @@ +package assets + +import ( + "crypto/sha1" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "mccl/pkg/retriever" + "net/http" + "os" + "path" +) + +// assetDownloadUrl is a base URL for an asset to be retrieved from, first %s +// is a directory which name is first two characters of a hash that goes +// under the second %s. +const assetDownloadUrl = "https://resources.download.minecraft.net/%s/%s" + +// Asset represents a single asset file, that is some resource like a texture, +// or a sound, and so on. +type Asset struct { + Hash string `json:"hash"` + Size int64 `json:"size"` +} + +// url forms a full URL to an asset. +func (a *Asset) url() string { + return fmt.Sprintf(assetDownloadUrl, a.Hash[:2], a.Hash) +} + +// path forms a relative path to an asset. +func (a *Asset) path() string { + return path.Join(a.Hash[:2], a.Hash) +} + +// Retrieve is a method of a Retrievable interface that is used to download +// an asset from Mojang's server. It performs the checks for a valid size +// and hash. Returns a slice of bytes an asset consists of or an error +// if something gone wrong. +func (a Asset) Retrieve() ([]byte, error) { + resp, err := http.Get(a.url()) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to download a file. Status code is %d", resp.StatusCode) + } else if resp.ContentLength != a.Size { + return nil, fmt.Errorf("response size mismatch. %d != %d", resp.ContentLength, a.Size) + } + + buf, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if l := len(buf); l != int(a.Size) { + return nil, fmt.Errorf("file size mismatch. %d != %d", l, a.Size) + } + + h := sha1.New() + if _, err := h.Write(buf); err != nil { + return nil, err + } + + if hex.EncodeToString(h.Sum(nil)) != a.Hash { + return nil, errors.New("hash mismatch") + } + + return buf, nil +} + +// Assets represents an asset index file. +type Assets struct { + Objects map[string]Asset `json:"objects"` +} + +// New returns a new Assets that is being unmarshaled from a JSON data. +func New(data []byte) (*Assets, error) { + a := &Assets{} + if err := json.Unmarshal(data, a); err != nil { + return nil, err + } + return a, nil +} + +// RetrieveAsset is a special retriever.WorkerFunc function that is used to +// check for an existency of an asset, and, if not, to retrieve it and write +// it to an actual file. +func RetrieveAsset(item retriever.Retrievable, basePath string) (int, error) { + asset := item.(Asset) + + p := path.Join(basePath, asset.path()) + if s, err := os.Stat(p); err == nil && s.Size() == asset.Size { + return int(s.Size()), nil + } + + data, err := item.Retrieve() + if err != nil { + return 0, err + } + + if err := os.MkdirAll(path.Dir(p), 0777); err != nil { + return 0, err + } + + tf, err := os.Create(p) + if err != nil { + return 0, err + } + defer tf.Close() + + written, err := tf.Write(data) + if err != nil { + return 0, err + } else if written != len(data) { + return 0, fmt.Errorf("written bytes length mismatch. %d != %d", written, len(data)) + } + + return written, nil +} diff --git a/internal/manifest/argument.go b/internal/manifest/argument.go new file mode 100755 index 0000000..ff5416e --- /dev/null +++ b/internal/manifest/argument.go @@ -0,0 +1,60 @@ +package manifest + +import ( + "bytes" + "encoding/json" +) + +// Argument is a special struct that was made to handle a mixed-typed array +// of arguments in a manifest. +// +// An argument in a manifest could be: +// - a single string +// - an object consists of rules array field and a value field that could be +// a single string or an array of strings. +type Argument struct { + Rules []Rule `json:"rules"` + Value []string `json:"value"` +} + +// CheckRules returns true if all rules passed and a feature set in a rule if +// applicable, a FeatureUnknown otherwise. +func (a Argument) CheckRules() (ok bool, feature Feature) { + feature = FeatureUnknown + for _, r := range a.Rules { + ok, feature = r.Check() + if !ok { + return false, feature + } + } + return true, feature +} + +// UnmarshalJSON had to be implemented because of mixed-typed nature of an +// arguments array in a manifest. +func (a *Argument) UnmarshalJSON(data []byte) error { + if data[0] == '"' { + a.Value = append(a.Value, string(data[1:len(data)-1])) + } else if data[0] == '{' && bytes.Contains(data, []byte("value\": \"")) { + singleValArg := struct { + Rules []Rule `json:"rules"` + Value string `json:"value"` + }{} + if err := json.Unmarshal(data, &singleValArg); err != nil { + return err + } + a.Rules = singleValArg.Rules + a.Value = append(a.Value, singleValArg.Value) + } else if data[0] == '{' { + multiValArg := struct { + Rules []Rule `json:"rules"` + Value []string `json:"value"` + }{} + if err := json.Unmarshal(data, &multiValArg); err != nil { + return err + } + a.Rules = multiValArg.Rules + a.Value = multiValArg.Value + } + return nil +} diff --git a/internal/manifest/argument_test.go b/internal/manifest/argument_test.go new file mode 100755 index 0000000..9cd97cc --- /dev/null +++ b/internal/manifest/argument_test.go @@ -0,0 +1,16 @@ +package manifest + +import ( + "encoding/json" + "testing" +) + +func TestArgument(t *testing.T) { + s := []byte("{\"rules\": [{ \"action\": \"allow\", \"os\": { \"name\": \"osx\" } }], \"value\":[\"-XstartOnFirstThread\"]}") + a := Argument{} + if err := json.Unmarshal(s, &a); err != nil { + t.Fatal(err) + } + ok, f := a.CheckRules() + t.Log(a, ok, f) +} diff --git a/internal/manifest/library.go b/internal/manifest/library.go new file mode 100755 index 0000000..d6b326a --- /dev/null +++ b/internal/manifest/library.go @@ -0,0 +1,136 @@ +package manifest + +import ( + "fmt" + "mccl/pkg/retriever" + "mccl/pkg/util" + "os" + "path" + "runtime" + "strings" +) + +type Library struct { + Downloads struct { + Artifact util.File `json:"artifact"` + Classifiers map[string]util.File `json:"classifiers"` + } `json:"downloads"` + Name string `json:"name"` + Url string `json:"url"` + Rules []Rule `json:"rules"` +} + +func (l *Library) Path() string { + if l.Downloads.Artifact.Path != "" { + return l.Downloads.Artifact.Path + } + + return l.pathFromName() +} + +func (l *Library) ToFile() []util.File { + var f []util.File + if l.Downloads.Artifact.Url != "" { + f = append(f, l.Downloads.Artifact) + } else if l.Url != "" { + lf := util.File{ + Id: l.Name, + Url: path.Join(l.Url, l.pathFromName()), + Size: -1, + Path: l.pathFromName(), + } + f = append(f, lf) + } + + if natives := l.Natives(); natives != nil { + f = append(f, *natives) + } + + return f +} + +func (l *Library) pathFromName() string { + parts := strings.Split(l.Name, ":") + parts = append(strings.Split(parts[0], "."), parts[1:]...) + pth := path.Join(parts...) + jarName := strings.Join([]string{parts[len(parts)-2], parts[len(parts)-1]}, "-") + ".jar" + return path.Join(pth, jarName) +} + +func RetrieveLibrary(item retriever.Retrievable, destDir string) (int, error) { + l := item.(util.File) + p := path.Join(destDir, l.Path) + + if s, err := os.Stat(p); err == nil && s.Size() == l.Size { + return int(l.Size), nil + } + + data, err := item.Retrieve() + if err != nil { + return 0, err + } + + if err := os.MkdirAll(path.Dir(p), 0777); err != nil { + return 0, err + } + + tf, err := os.Create(p) + if err != nil { + return 0, err + } + defer tf.Close() + + written, err := tf.Write(data) + if err != nil { + return 0, err + } else if written != len(data) { + return 0, fmt.Errorf("written bytes length mismatch. %d != %d", written, len(data)) + } + + return written, nil +} + +func (l *Library) CheckRules() bool { + if strings.Contains(l.Name, "natives") { + nat := strings.SplitAfter(l.Name, ":")[3] + nats := strings.Split(nat, "-") + + archR := Rule{Action: ActionAllow} + if len(nats) == 3 { + archR.Os.Arch = nats[2] + l.Rules = append(l.Rules, archR) + } + } + + for _, r := range l.Rules { + if ok, _ := r.Check(); !ok { + return false + } + } + + return true +} + +func (l *Library) Natives() *util.File { + if !l.CheckRules() { + return nil + } + + var nativeOs string + switch v := runtime.GOOS; v { + case "windows": + fallthrough + case "linux": + nativeOs = "natives-" + v + case "darwin": + nativeOs = "natives-macos" + } + + if len(l.Downloads.Classifiers) > 0 { + if v, ok := l.Downloads.Classifiers[nativeOs]; ok { + return &v + } + } + + return nil +} diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go new file mode 100755 index 0000000..90a7169 --- /dev/null +++ b/internal/manifest/manifest.go @@ -0,0 +1,200 @@ +package manifest + +import ( + "encoding/json" + "fmt" + "mccl/pkg/util" + "path" + "strings" +) + +type Manifest struct { + InheritsFrom string `json:"inheritsFrom"` + Arguments struct { + Game []Argument `json:"game"` + Jvm []Argument `json:"jvm"` + } `json:"arguments"` + AssetIndex struct { + util.File + TotalSize int `json:"totalSize"` + } `json:"assetIndex"` + Assets string `json:"assets"` + ComplianceLevel int `json:"complianceLevel"` + Downloads map[string]util.File `json:"downloads"` + Id string `json:"id"` + JavaVersion struct { + Component string `json:"component"` + MajorVersion int `json:"majorVersion"` + } `json:"javaVersion"` + Libraries []Library `json:"libraries"` + Logging struct { + Client struct { + Argument string `json:"argument"` + File util.File `json:"file"` + Type string `json:"type"` + } `json:"client"` + } `json:"logging"` + MainClass string `json:"mainClass"` + MinecraftArguments string `json:"minecraftArguments"` + MinimumLauncherVersion int `json:"minimumLauncherVersion"` + ReleaseTime string `json:"releaseTime"` + Time string `json:"time"` + Type string `json:"type"` +} + +// New returns a new Manifest for a specific Minecraft version. +func New(data []byte) (*Manifest, error) { + m := &Manifest{} + if err := json.Unmarshal(data, m); err != nil { + return nil, err + } + + return m, nil +} + +func (m *Manifest) BuildCommandLine(properties map[string]string) (arguments []string) { + if m.MinecraftArguments != "" { + arguments = append(arguments, fmt.Sprintf("-Djava.library.path=%s", properties["natives_directory"])) + arguments = append(arguments, "-cp") + arguments = append(arguments, properties["classpath"]) + arguments = append(arguments, m.MainClass) + args := strings.Split(m.MinecraftArguments, " ") + for _, arg := range args { + if strings.HasPrefix(arg, "${") { + propName := arg[2 : len(arg)-1] + arg = properties[propName] + } + arguments = append(arguments, arg) + } + return arguments + } + + var allArgs []Argument + + allArgs = append(allArgs, m.Arguments.Jvm...) + + if m.Logging.Client.Argument != "" { + logConf := strings.Split(m.Logging.Client.Argument, "${")[0] + logConf += properties["logging_path"] + allArgs = append(allArgs, Argument{Value: []string{logConf}}) + } + + allArgs = append(allArgs, Argument{Value: []string{m.MainClass}}) + allArgs = append(allArgs, m.Arguments.Game...) + + for _, arg := range allArgs { + ok, feature := arg.CheckRules() + if !ok { + continue + } else if feature != FeatureUnknown { + if _, ok := properties[feature.String()]; !ok { + continue + } + } + + for _, v := range arg.Value { + if strings.Contains(v, "${library_directory}") { + v = strings.ReplaceAll(v, "${library_directory}", properties["library_directory"]) + v = strings.ReplaceAll(v, "${classpath_separator}", properties["classpath_separator"]) + } else if i := strings.Index(v, "${"); i != -1 { + j := strings.Index(v, "}") + propName := v[i+2 : j] + switch propName { + case "version_name": + if strings.Contains(m.Id, "forge") { + v = strings.ReplaceAll(v, "${version_name}", m.InheritsFrom) + } else { + v = strings.ReplaceAll(v, "${version_name}", m.Id) + } + + case "assets_index_name": + v = m.Assets + case "version_type": + v = m.Type + default: + v = strings.Replace(v, v[i:j+1], properties[propName], 1) + } + } + arguments = append(arguments, v) + } + } + + return arguments +} + +func (m *Manifest) BuildClasspath(baseDir, clientPath string) string { + var sb []string = make([]string, 0, len(m.Libraries)) + for _, lib := range m.Libraries { + if lib.CheckRules() { + sb = append(sb, path.Join(baseDir, "libraries", lib.Path())) + if natives := lib.Natives(); natives != nil { + sb = append(sb, path.Join(baseDir, "libraries", natives.Path)) + } + } + } + + if m.InheritsFrom != "" { + sb = append(sb, path.Join(clientPath, m.InheritsFrom, m.InheritsFrom+".jar")) + } else { + sb = append(sb, path.Join(clientPath, m.Id, m.Id+".jar")) + } + + return strings.Join(sb, util.PathSeparator()) +} + +func (m *Manifest) InheritFrom(parent *Manifest) error { + if parent == nil { + return nil + } + + m.Arguments.Game = append(parent.Arguments.Game, m.Arguments.Game...) + m.Arguments.Jvm = append(parent.Arguments.Jvm, m.Arguments.Jvm...) + + if m.AssetIndex.TotalSize == 0 { + m.AssetIndex.File = parent.AssetIndex.File + m.AssetIndex.TotalSize = parent.AssetIndex.TotalSize + } + + if m.Assets == "" { + m.Assets = parent.Assets + } + + if m.ComplianceLevel == 0 { + m.ComplianceLevel = parent.ComplianceLevel + } + + if m.Downloads == nil { + m.Downloads = make(map[string]util.File) + } + + for k, v := range parent.Downloads { + m.Downloads[k] = v + } + + if m.Id == "" { + m.Id = parent.Id + } + + if m.JavaVersion.Component != "" { + m.JavaVersion.Component = parent.JavaVersion.Component + m.JavaVersion.MajorVersion = parent.JavaVersion.MajorVersion + } + + m.Libraries = append(m.Libraries, parent.Libraries...) + + if m.Logging.Client.Argument == "" { + m.Logging.Client.Argument = parent.Logging.Client.Argument + m.Logging.Client.Type = parent.Logging.Client.Type + m.Logging.Client.File = parent.Logging.Client.File + } + + if m.MainClass == "" { + m.MainClass = parent.MainClass + } + + if m.MinecraftArguments == "" { + m.MinecraftArguments = parent.MinecraftArguments + } + + return nil +} diff --git a/internal/manifest/rule.go b/internal/manifest/rule.go new file mode 100755 index 0000000..87f463f --- /dev/null +++ b/internal/manifest/rule.go @@ -0,0 +1,122 @@ +package manifest + +import ( + "mccl/pkg/util" + "regexp" + "runtime" +) + +type Feature int + +const ( + FeatureIsDemoUser Feature = iota + FeatureHasCustomResolution + FeatureHasQuickPlaysSupport + FeatureIsQuickPlaySingleplayer + FeatureIsQuickPlayMultiplayer + FeatureIsQuickPlayRealms + FeatureUnknown +) + +func (f Feature) String() string { + switch f { + case FeatureIsDemoUser: + return "is_demo_user" + case FeatureHasCustomResolution: + return "has_custom_resolution" + case FeatureHasQuickPlaysSupport: + return "has_quick_plays_support" + case FeatureIsQuickPlayMultiplayer: + return "is_quick_play_multiplayer" + case FeatureIsQuickPlayRealms: + return "is_quick_play_realms" + case FeatureIsQuickPlaySingleplayer: + return "is_quick_play_singleplayer" + default: + return "" + } +} + +type Action string + +const ( + ActionAllow Action = "allow" + ActionDisallow Action = "disallow" + ActionUnknown Action = "" +) + +type Rule struct { + Action Action `json:"action"` + Os struct { + Name string `json:"name"` + Arch string `json:"arch"` + Version string `json:"version"` + } `json:"os"` + Features struct { + IsDemoUser bool `json:"is_demo_user"` + HasCustomResolution bool `json:"has_custom_resolution"` + HasQuickPlaysSupport bool `json:"has_quick_plays_support"` + IsQuickPlaySingleplayer bool `json:"is_quick_play_singleplayer"` + IsQuickPlayMultiplayer bool `json:"is_quick_play_multiplayer"` + IsQuickPlayRealms bool `json:"is_quick_play_realms"` + } `json:"features"` +} + +func (r *Rule) GetFeature() Feature { + switch { + case r.Features.IsDemoUser: + return FeatureIsDemoUser + case r.Features.HasCustomResolution: + return FeatureHasCustomResolution + case r.Features.HasQuickPlaysSupport: + return FeatureHasQuickPlaysSupport + case r.Features.IsQuickPlayMultiplayer: + return FeatureIsQuickPlayMultiplayer + case r.Features.IsQuickPlaySingleplayer: + return FeatureIsQuickPlaySingleplayer + case r.Features.IsQuickPlayRealms: + return FeatureIsQuickPlayRealms + default: + return FeatureUnknown + } +} + +func (r *Rule) Check() (bool, Feature) { + feature := r.GetFeature() + + osArchOk := true + switch r.Os.Arch { + case "x86": + osArchOk = r.Action == ActionAllow && runtime.GOARCH == "386" + case "x86_64": + osArchOk = r.Action == ActionAllow && runtime.GOARCH == "amd64" + case "arm64": + osArchOk = r.Action == ActionAllow && runtime.GOARCH == "arm64" + } + + osName := runtime.GOOS + if osName == "darwin" { + osName = "osx" + } + + osNameOk := true + if r.Os.Name != "" { + if r.Action == ActionAllow { + osNameOk = r.Os.Name == osName + } else { + osNameOk = r.Os.Name != osName + } + } + + osVersionOk := true + if r.Os.Name == osName && r.Os.Version != "" { + ver, err := util.OSVersion() + if err != nil { + return false, feature + } + ok, _ := regexp.MatchString(r.Os.Version, ver) + osVersionOk = ok && r.Action == ActionAllow + } + + return osArchOk && osNameOk && osVersionOk, feature +} diff --git a/internal/manifest/rule_test.go b/internal/manifest/rule_test.go new file mode 100755 index 0000000..fd99d21 --- /dev/null +++ b/internal/manifest/rule_test.go @@ -0,0 +1,49 @@ +package manifest + +import ( + "runtime" + "testing" +) + +func TestRuleOsName(t *testing.T) { + macos := Rule{Action: ActionAllow} + macos.Os.Name = "osx" + + if ok, _ := macos.Check(); runtime.GOOS != "darwin" && ok { + t.Fatal("Rule.Os.Name == 'osx', but is true on ", runtime.GOOS) + } + + linux := Rule{Action: ActionAllow} + linux.Os.Name = "linux" + + if ok, _ := linux.Check(); runtime.GOOS != "linux" && ok { + t.Fatalf("Rule.Os.Name == '%s', but is true on %s\n", linux.Os.Name, runtime.GOOS) + } + + windows := Rule{Action: ActionAllow} + windows.Os.Name = "windows" + + if ok, _ := windows.Check(); runtime.GOOS != "windows" && ok { + t.Fatalf("Rule.Os.Name == '%s', but is true on %s\n", windows.Os.Name, runtime.GOOS) + } + + x86 := Rule{Action: ActionAllow} + x86.Os.Arch = "x86" + + if ok, _ := x86.Check(); runtime.GOARCH != "386" && ok { + t.Fatalf("Rule.Os.Arch == '%s', but is true on %s\n", windows.Os.Arch, runtime.GOOS) + } + + emptyAllow := Rule{Action: ActionAllow} + + if ok, _ := emptyAllow.Check(); !ok { + t.Fatalf("An empty allow rule must return true\n") + } + + disOsx := Rule{Action: ActionDisallow} + disOsx.Os.Name = "osx" + + if ok, _ := disOsx.Check(); runtime.GOOS != "darwin" && !ok { + t.Fatalf("A disallow rule for OSX must return false, and true for other OSes\n") + } +} diff --git a/internal/mccl_profile/profile.go b/internal/mccl_profile/profile.go new file mode 100644 index 0000000..2b70e7f --- /dev/null +++ b/internal/mccl_profile/profile.go @@ -0,0 +1,47 @@ +package mcclprofile + +import ( + "encoding/json" + "os" + "path" +) + +const ProfileFileName = "mccl_profile.json" + +type Profile struct { + Username string `json:"username"` + Uuid string `json:"uuid"` + JavaXmx string `json:"java-Xmx"` +} + +func (p *Profile) Store(rootDir string) error { + pf, err := os.OpenFile(path.Join(rootDir, ProfileFileName), os.O_CREATE|os.O_RDWR, 0666) + if err != nil { + return err + } + defer pf.Close() + + je := json.NewEncoder(pf) + je.SetIndent("", "\t") + if err := je.Encode(p); err != nil { + return err + } + + return nil +} + +func Load(rootDir string) (*Profile, error) { + p := &Profile{} + pf, err := os.Open(path.Join(rootDir, ProfileFileName)) + if err != nil { + return nil, err + } + defer pf.Close() + + jd := json.NewDecoder(pf) + if err := jd.Decode(p); err != nil { + return nil, err + } + + return p, nil +} diff --git a/internal/version_manifest/version_manifest.go b/internal/version_manifest/version_manifest.go new file mode 100755 index 0000000..038dccf --- /dev/null +++ b/internal/version_manifest/version_manifest.go @@ -0,0 +1,74 @@ +package version_manifest + +import ( + "encoding/json" + "fmt" + "time" +) + +// VersionManifestUrl is a URL to a version manifest JSON file of version 2. +const VersionManifestUrl = "https://launchermeta.mojang.com/mc/game/version_manifest_v2.json" + +// VersionType is telling if it is a stable release or an intermedialry snapshot. +type VersionType string + +const ( + // VersionTypeSnapshot is an unstable intermediary version of a game. + VersionTypeSnapshot VersionType = "snapshot" + // VersionTypeRelease is a stable release version of a game. + VersionTypeRelease VersionType = "release" +) + +// Version holds an information about a particular version. +type Version struct { + Id string `json:"id"` + Type VersionType `json:"type"` + Url string `json:"url"` + Time time.Time `json:"time"` + ReleaseTime time.Time `json:"releaseTime"` + Sha1 string `json:"sha1"` +} + +// VersionManifest holds an array of all game versions, also a latest metadata +// that holds the latest release and snapshot. +type VersionManifest struct { + Latest struct { + Release string `json:"release"` + Snapshot string `json:"snapshot"` + } `json:"latest"` + Versions []Version `json:"versions"` +} + +// New returns a new VersionManifest that holds info on every +// known Minecraft version. +func New(data []byte) (*VersionManifest, error) { + vm := &VersionManifest{} + if err := json.Unmarshal(data, vm); err != nil { + return nil, err + } + return vm, nil +} + +// GetLatest returns a latest release or snapshot depending on a type provided. +func (vm *VersionManifest) GetLatest(typ VersionType) (Version, error) { + var version string + switch typ { + case VersionTypeSnapshot: + version = vm.Latest.Snapshot + case VersionTypeRelease: + version = vm.Latest.Release + default: + return Version{}, fmt.Errorf("version type %s doesn't exist", typ) + } + return vm.Get(version) +} + +// Get returns a specific version of Minecraft. +func (vm *VersionManifest) Get(version string) (Version, error) { + for _, v := range vm.Versions { + if v.Id == version { + return v, nil + } + } + return Version{}, fmt.Errorf("version %s not found", version) +} diff --git a/pkg/retriever/retriever.go b/pkg/retriever/retriever.go new file mode 100755 index 0000000..6d62b5d --- /dev/null +++ b/pkg/retriever/retriever.go @@ -0,0 +1,81 @@ +package retriever + +import ( + "fmt" + "runtime" + "sync" +) + +// Retrievable must be implemented by any type used with Retriever. +type Retrievable interface { + // Retrieve is used just to retrieve some data from somewhere (usually from + // network) and return a slice of bytes or nil and an error. If you need to + // write the data retrieved use a WorkerFunc where you can call this method. + Retrieve() ([]byte, error) +} + +// Retriever allows to retrieve a bunch of files in parallel taking care of +// errors and a done channel that returns an amount of bytes written. +type Retriever struct { + retrieveErrors chan error + retrieveDone chan int +} + +// New returns a new Retriever with a base path set. +func New() *Retriever { + return &Retriever{ + retrieveErrors: make(chan error, 1), + retrieveDone: make(chan int)} +} + +// GetErrorChan returns an error channel. +// +// An error channel holds just one error and hangs after that. So it is considered +// as a "soft panic". +func (r *Retriever) GetErrorChan() chan error { + return r.retrieveErrors +} + +// GetDoneChan returns a channel that is used to track a progress. Its value +// is an amount of bytes written for an item. +func (r *Retriever) GetDoneChan() chan int { + return r.retrieveDone +} + +// WorkerFunc accepts a Retrievable item and a basePath where an item should be +// placed to. And it returns how much was written and an error if something +// went wrong. +type WorkerFunc func(item Retrievable, basePath string) (int, error) + +// Run runs in parallel a number of CPU threads * 2 WorkerFunc functions. +// If an error occured it stops and returns that error. +func (r *Retriever) Run(items []Retrievable, worker WorkerFunc, basePath string) error { + var workersGroup sync.WaitGroup + retrieveLimiter := make(chan struct{}, runtime.NumCPU()*2) + for _, item := range items { + if len(r.retrieveErrors) > 0 { + workersGroup.Wait() + return <-r.retrieveErrors + } + retrieveLimiter <- struct{}{} + workersGroup.Add(1) + go func(item Retrievable) { + bytesLen, err := worker(item, basePath) + workersGroup.Done() + <-retrieveLimiter + if err != nil { + r.retrieveErrors <- fmt.Errorf("failed to retrieve: %s", err) + return + } + r.retrieveDone <- bytesLen + }(item) + } + workersGroup.Wait() + return nil +} + +// Close the channels. +func (r *Retriever) Close() { + close(r.retrieveDone) + close(r.retrieveErrors) +} diff --git a/pkg/util/locatejavahome_unix.go b/pkg/util/locatejavahome_unix.go new file mode 100644 index 0000000..8adff7d --- /dev/null +++ b/pkg/util/locatejavahome_unix.go @@ -0,0 +1,8 @@ +package util + +func LocateJavaHome(component string, majorVersion int) (string, error) { + if component == "jre-legacy" { + return "/usr/lib/jvm/java-8-openjdk/jre", nil + } + return "/usr/lib/jvm/default", nil +} diff --git a/pkg/util/locatejavahome_windows.go b/pkg/util/locatejavahome_windows.go new file mode 100644 index 0000000..ce49c7b --- /dev/null +++ b/pkg/util/locatejavahome_windows.go @@ -0,0 +1,68 @@ +package util + +import ( + "fmt" + "strconv" + "strings" + + "golang.org/x/sys/windows/registry" +) + +const legacyJavaRegPath = "SOFTWARE\\JavaSoft\\Java Runtime Environment" +const jdkRegPath = "SOFTWARE\\JavaSoft\\JDK" + +func LocateJavaHome(component string, majorVersion int) (string, error) { + if component == "jre-legacy" { + jre, err := registry.OpenKey(registry.LOCAL_MACHINE, legacyJavaRegPath, registry.QUERY_VALUE) + if err != nil { + return "", err + } + curVer, _, err := jre.GetStringValue("CurrentVersion") + if err != nil { + return "", nil + } + mVer, err := strconv.ParseInt(strings.Split(curVer, ".")[1], 10, 64) + if err != nil { + return "", nil + } + if mVer < int64(majorVersion) { + return "", fmt.Errorf("installed JRE version %s is older than allowed 1.%d", curVer, majorVersion) + } + jre, err = registry.OpenKey(registry.LOCAL_MACHINE, fmt.Sprintf("%s\\%s", legacyJavaRegPath, curVer), registry.QUERY_VALUE) + if err != nil { + return "", err + } + javaHome, _, err := jre.GetStringValue("JavaHome") + if err != nil { + return "", err + } + return javaHome, nil + } else { + jdk, err := registry.OpenKey(registry.LOCAL_MACHINE, jdkRegPath, registry.QUERY_VALUE) + if err != nil { + return "", err + } + curVer, _, err := jdk.GetStringValue("CurrentVersion") + if err != nil { + return "", nil + } + mVer, err := strconv.ParseInt(strings.Split(curVer, ".")[0], 10, 64) + if err != nil { + return "", nil + } + if mVer < int64(majorVersion) { + return "", fmt.Errorf("installed JDK version %d is older than allowed %d", mVer, majorVersion) + } + jdk, err = registry.OpenKey(registry.LOCAL_MACHINE, fmt.Sprintf("%s\\%s", jdkRegPath, curVer), registry.QUERY_VALUE) + if err != nil { + return "", err + } + javaHome, _, err := jdk.GetStringValue("JavaHome") + if err != nil { + return "", err + } + return javaHome, nil + } + + return "", nil +} diff --git a/pkg/util/osversion_unix.go b/pkg/util/osversion_unix.go new file mode 100644 index 0000000..2b05121 --- /dev/null +++ b/pkg/util/osversion_unix.go @@ -0,0 +1,26 @@ +package util + +import ( + "errors" + "os" + "runtime" + "strings" +) + +func OSVersion() (string, error) { + switch osName := runtime.GOOS; osName { + case "linux": + // Implemented just in case for future. Guess if it will ever be needed + // then a kernel version will be used to compare against. + osRelease, err := os.ReadFile("/proc/sys/kernel/osrelease") + if err != nil { + return "", err + } + kVer := strings.Split(string(osRelease), "-") + if len(kVer) >= 1 { + return kVer[0], nil + } + return "", errors.New("malformed osrelease") + } + return "", errors.New("unknown OS") +} diff --git a/pkg/util/osversion_windows.go b/pkg/util/osversion_windows.go new file mode 100644 index 0000000..2d70960 --- /dev/null +++ b/pkg/util/osversion_windows.go @@ -0,0 +1,29 @@ +package util + +import ( + "errors" + "fmt" + "runtime" + + "golang.org/x/sys/windows/registry" +) + +func OSVersion() (string, error) { + switch osName := runtime.GOOS; osName { + case "windows": + verKey, err := registry.OpenKey(registry.LOCAL_MACHINE, "Software\\Microsoft\\Windows NT\\CurrentVersion", registry.QUERY_VALUE) + if err != nil { + return "", err + } + major, _, err := verKey.GetIntegerValue("CurrentMajorVersionNumber") + if err != nil { + return "", err + } + minor, _, err := verKey.GetIntegerValue("CurrentMinorVersionNumber") + if err != nil { + return "", err + } + return fmt.Sprintf("%d.%d", major, minor), nil + } + return "", errors.New("unknown OS") +} diff --git a/pkg/util/util.go b/pkg/util/util.go new file mode 100755 index 0000000..79c3aa0 --- /dev/null +++ b/pkg/util/util.go @@ -0,0 +1,145 @@ +package util + +import ( + "crypto/md5" + "crypto/sha1" + "encoding/base64" + "encoding/hex" + "fmt" + "hash" + "io" + "net/http" + "os" + "path" + "runtime" + "strings" +) + +type File struct { + Id string `json:"id"` + Sha1 string `json:"sha1"` + Size int64 `json:"size"` + Url string `json:"url"` + Path string `json:"path"` +} + +func (f File) Retrieve() ([]byte, error) { + return GetFromUrl(f.Url, f.Sha1, "sha1", f.Size) +} + +func (f *File) GetName() string { + if f.Id == "" { + return f.Path + } + return f.Id +} + +func GetFromUrl(url string, hash, hashType string, fSize int64) ([]byte, error) { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status code returned is %d", resp.StatusCode) + } + if fSize != -1 && resp.ContentLength != -1 && resp.ContentLength != fSize { + return nil, fmt.Errorf("response size mismatch. %d != %d", resp.ContentLength, fSize) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if fSize != -1 && len(data) != int(fSize) { + return nil, fmt.Errorf("body size mismatch. %d != %d", len(data), fSize) + } + + if hash != "" { + if err := CheckHash(data, hash, hashType); err != nil { + return nil, err + } + } + + return data, nil +} + +func IsFileExist(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func WriteFile(fPath string, data []byte) error { + if err := os.MkdirAll(path.Dir(fPath), 0777); err != nil { + return err + } + if err := os.WriteFile(fPath, data, 0666); err != nil { + return err + } + return nil +} + +func ReadFile(fPath string) ([]byte, error) { + if !IsFileExist(fPath) { + return nil, fmt.Errorf("File %s not found", fPath) + } + + data, err := os.ReadFile(fPath) + if err != nil { + return nil, err + } + + return data, nil +} + +// LoadOrDownloadFile will check if file exists and correct, will download it otherwise. +func LoadOrDownloadFile(fPath, url string, hash, hashType string, fSize int64) ([]byte, error) { + if IsFileExist(fPath) { + data, err := os.ReadFile(fPath) + if err != nil { + return nil, err + } + if hash != "" { + if err := CheckHash(data, hash, hashType); err != nil { + return nil, err + } + } + return data, nil + } + + return GetFromUrl(url, hash, hashType, fSize) +} + +func CheckHash(data []byte, fHash, hashType string) error { + var hasher hash.Hash + + switch hashType { + case "sha1": + hasher = sha1.New() + case "md5": + hasher = md5.New() + default: + return fmt.Errorf("unsupported hash type %s", hashType) + } + + if _, err := hasher.Write(data); err != nil { + return err + } + resultedHash := hasher.Sum(nil) + + if (strings.Contains(fHash, "=") && base64.StdEncoding.EncodeToString(resultedHash) == fHash) || + hex.EncodeToString(resultedHash) == fHash { + return nil + } + + return fmt.Errorf("hash mismatch: %s != %s", hex.EncodeToString(resultedHash), fHash) +} + +func PathSeparator() string { + if runtime.GOOS == "windows" { + return ";" + } + return ":" +}