Compare commits
	
		
			40 Commits
		
	
	
		
			0.0.3
			...
			92c1ce74e5
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 92c1ce74e5 | |||
| 8e5a94e8d8 | |||
| 61e17cffc5 | |||
| e7b5215d0f | |||
| 336340269b | |||
| f8ebb74fc9 | |||
| ccd2586c8d | |||
| 6abb9ce47a | |||
| 365171fddc | |||
| 4d47056e5b | |||
| 7f454dc632 | |||
| 516a1f9b57 | |||
| 6b6dbdda99 | |||
| 54cc1d9a55 | |||
| d2d61e64fa | |||
| c2f13637f9 | |||
| f3e50a197f | |||
| 89f954eec9 | |||
| d133ae6b29 | |||
| 882413fb1e | |||
| 2cdeeef5b4 | |||
| afb8457489 | |||
| 2cb2208478 | |||
| d21e48c1c9 | |||
| f916ba0059 | |||
| 970d8c654f | |||
| a5b3719d33 | |||
| 1200a1bfc2 | |||
| af8a2dbd1e | |||
| dd8282623e | |||
| a9367763f3 | |||
| 7dd915b060 | |||
| 4e78277f99 | |||
| ad97817e80 | |||
| 9a0606ce32 | |||
| d4f841f8a0 | |||
| 134d70e29b | |||
| 6199f0865e | |||
| a24eb6903f | |||
| 1fc00d83eb | 
| @@ -1,19 +1,16 @@ | ||||
| class Epochcli < Formula | ||||
|   desc "Updater and Launcher for Epoch" | ||||
|   homepage "https://git.burkey.co/eburk/epochcli/src/branch/master" | ||||
|   url "https://git.burkey.co/eburk/epochcli/archive/0.0.2.tar.gz" | ||||
|   sha256 "192ae687256a33468e97c7116177b65fb42b42a66138f61f24b93b76f4048f1c" | ||||
|   license "ISC" | ||||
|   head "https://git.burkey.co/eburk/epochcli.git", branch: "master" | ||||
|  | ||||
|   livecheck do | ||||
|     url :stable | ||||
|   end | ||||
|  | ||||
|   depends_on "go" => :build | ||||
|  | ||||
|   def install | ||||
|     system "go", "build" | ||||
|     bin.install "epochcli" | ||||
|     system "go", "build", *std_go_args(ldflags: "-s -w") | ||||
|   end | ||||
|  | ||||
|   test do | ||||
|     assert_match "Epochcli Help:", shell_output("#{bin}/epochcli -h") | ||||
|   end | ||||
| end | ||||
|   | ||||
							
								
								
									
										45
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										45
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,15 +1,46 @@ | ||||
| # epochcli | ||||
|  | ||||
| CLI tool for updating and launching [Project Epoch](https://www.project-epoch.net/) on Linux & macOS. | ||||
| CLI tool for updating and launching [Project Epoch](https://www.project-epoch.net/) on Windows, Linux and macOS. | ||||
|  | ||||
| If you're a Linux newbie or just looking for a simpler one-click solution, check out [this excellent Lutris script](https://lutris.net/games/project-epoch/) by another community member | ||||
|  | ||||
| ## Installing | ||||
|  | ||||
| ### Linux | ||||
|  | ||||
| A `wine` prefix with `dxvk` installed is sufficient, or you can use something like Lutris or faugus-launcher and just use `epochcli` for updating. | ||||
|  | ||||
| Download and extract the latest binary from the [releases](https://git.burkey.co/eburk/epochcli/releases) page, build from source yourself, or use homebrew from the macOS instructions to install. | ||||
|  | ||||
| If you are an Arch Linux (or derivative) user, you can use the provided [AUR package](https://aur.archlinux.org/packages/epochcli) to install | ||||
|  | ||||
| A tutorial for the full setup of Epoch on Arch Linux [can be found here](https://burkey.co/posts/epoch-linux/) | ||||
|  | ||||
| ### macOS | ||||
|  | ||||
| For arm64 macOS (aka M1, M2 etc), I have built an app that handles all of Epoch's setup for you. [Go check it out](https://git.burkey.co/eburk/EpochSilicon) | ||||
|  | ||||
| Alternatively, you can easily install with homebrew or build from source yourself. I dont have time to setup codesigning right now so there are no binaries provided for macOS. For homebrew, do the following: | ||||
|  | ||||
| ```shell | ||||
| brew tap eburk/epochcli https://git.burkey.co/eburk/epochcli | ||||
| brew install --HEAD epochcli | ||||
|  | ||||
| # To update | ||||
| brew upgrade epochcli --fetch-HEAD | ||||
| ``` | ||||
|  | ||||
| ### Windows | ||||
|  | ||||
| Download and extract the latest binary from the [releases](https://git.burkey.co/eburk/epochcli/releases) page or build from source yourself, then copy `epochcli.exe` to a location of your choice. To make it easy, just use the same folder as your Wow game files. | ||||
|  | ||||
| ## Setup | ||||
| For Linux, a `wine` prefix with `dxvk` installed is sufficient, or you can use something like Lutris or faugus-launcher without the launcher functionality in `epochcli`. | ||||
|  | ||||
| For macOS, I've found the best way to run the client is in a Parallels Win 11 VM. Kegworks, Codeweavers, etc crash when the game starts up and I have not found a good solution so far. | ||||
|  | ||||
|  | ||||
| 1. Download and extract the latest binary from the [releases](https://git.burkey.co/eburk/epochcli/releases) page.  | ||||
| 2. Run `epochcli`. You will be taken through a setup process that configures the program and creates a config file at `$HOME/.config/epochcli/config.toml` | ||||
| 3. You can now use `epochcli` as just a standalone updater or also a launcher based on your configuration. You can always run `epochcli -c` to redo the configuration or edit the config file manually | ||||
| 1. Run `epochcli`. You will be taken through a setup process that configures the program and creates a config file at `$HOME/.config/epochcli/config.toml` | ||||
| 2. You can now use `epochcli` as a standalone updater, but it can also act as a launcher based on your configuration. You can always run `epochcli -c` to redo the configuration, or edit the config file manually | ||||
| 3. By default, `epochcli` will delete any non-standard patches. This can be disabled by changing the configuration file's `RemoveUnknownPatches` value to `false` | ||||
|  | ||||
| ## Usage | ||||
| ``` | ||||
| @@ -22,4 +53,4 @@ For macOS, I've found the best way to run the client is in a Parallels Win 11 VM | ||||
|  | ||||
| ## Issues | ||||
|  | ||||
| If you have any issues, [email me](mailto:evan@burkey.co) or ping `Battlehammer` on the Epoch discord | ||||
| If you have any issues, [email me](mailto:epochcli@burkey.co) or send a private message to `Battlehammer` on the Epoch discord | ||||
|   | ||||
							
								
								
									
										158
									
								
								config.go
									
									
									
									
									
								
							
							
						
						
									
										158
									
								
								config.go
									
									
									
									
									
								
							| @@ -1,96 +1,176 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"bufio" | ||||
| 	"fmt" | ||||
| 	"github.com/BurntSushi/toml" | ||||
| 	"os" | ||||
| 	"path" | ||||
| 	"path/filepath" | ||||
| 	"runtime" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| type Config struct { | ||||
| 	WowDir               string | ||||
| 	LaunchCmd            string | ||||
| 	WinePrefix           string | ||||
| 	EnableLauncher       bool | ||||
| 	RemoveUnknownPatches bool | ||||
| } | ||||
|  | ||||
| const ( | ||||
| 	configDirName    = "epochcli" | ||||
| 	configName       = "config.toml" | ||||
| 	defaultWowPath   = "/path/to/wow" | ||||
| 	defaultLaunchCmd = "not configured" | ||||
| ) | ||||
|  | ||||
| var cfgPath string | ||||
| var ( | ||||
| 	configRun = false | ||||
| 	cfgPath   string | ||||
| ) | ||||
|  | ||||
| func setupConfig(rerun bool) (*Config, error) { | ||||
| 	home := os.Getenv("HOME") | ||||
| 	if home == "" { | ||||
| 		return nil, fmt.Errorf("$HOME environment variable not set") | ||||
| 	home, err := os.UserHomeDir() | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("unable to determine home directory: %v", err) | ||||
| 	} | ||||
| 	cfgPath = filepath.Join(home, ".config", configDirName, configName) | ||||
|  | ||||
| 	newConfig := Config{ | ||||
| 		WowDir:               defaultWowPath, | ||||
| 		LaunchCmd:            defaultLaunchCmd, | ||||
| 		EnableLauncher:       false, | ||||
| 		RemoveUnknownPatches: true, | ||||
| 	} | ||||
|  | ||||
| 	cfgPath = filepath.Join(home, ".config", configDirName, configName) | ||||
|  | ||||
| 	_, statErr := os.Stat(cfgPath) | ||||
| 	if rerun || os.IsNotExist(statErr) { | ||||
| 		fmt.Println("Enter the path to your Wow directory below:") | ||||
| 		_, err := fmt.Scanln(&newConfig.WowDir) | ||||
| 		configRun = true | ||||
| 		fmt.Println("Enter the path to your Wow directory below. Use the full path without shortcuts like '~' (ex: /home/user/epoch):") | ||||
| 		s, err := readLine() | ||||
| 		if err != nil { | ||||
| 			return nil, fmt.Errorf("unable to read input: %v", err) | ||||
| 		} | ||||
| 		newConfig.WowDir = s | ||||
|  | ||||
| 		fmt.Println() | ||||
| 		p, err := promptYesNo(fmt.Sprintf("Do you want to use epochcli to launch Wow? Select No if you plan on using a launcher tool like Lutris (y/n): ")) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 		fmt.Println() | ||||
|  | ||||
| 		for { | ||||
| 			fmt.Printf("Do you want to use epochcli to launch Wow? Select No if you plan on using a launcher tool like Lutris (y/n): ") | ||||
| 			var s string | ||||
| 			_, err := fmt.Scanf("%s", &s) | ||||
| 			if err != nil { | ||||
| 				return nil, err | ||||
| 			} | ||||
|  | ||||
| 			if s == "y" || s == "Y" { | ||||
| 		if p { | ||||
| 			newConfig.EnableLauncher = true | ||||
| 				fmt.Println("Enter your launch command to start Wow below. If you would rather configure this later in the configuration file, just press Enter") | ||||
| 				fmt.Printf("> ") | ||||
| 				_, err = fmt.Scanf("%s", &s) | ||||
| 			newConfig.LaunchCmd = path.Join(newConfig.WowDir, "Project-Epoch.exe") | ||||
|  | ||||
| 			if runtime.GOOS == "windows" { | ||||
| 				exePath, err := os.Executable() | ||||
| 				if err != nil { | ||||
| 					return nil, fmt.Errorf("unable to create desktop shortcut: %v", err) | ||||
| 				} | ||||
| 				err = makeLink(exePath, path.Join(home, "Desktop", "Project-Epoch.lnk")) | ||||
| 				if err != nil { | ||||
| 					return nil, fmt.Errorf("unable to create desktop shortcut: %v", err) | ||||
| 				} | ||||
| 			} else { | ||||
| 				s, err = input("Enter your wine prefix. Leave blank if you do not need to set WINEPREFIX") | ||||
| 				if err != nil { | ||||
| 					return nil, err | ||||
| 				} | ||||
| 				if s != "" { | ||||
| 					newConfig.LaunchCmd = s | ||||
| 				fmt.Println() | ||||
|  | ||||
| 				newConfig.WinePrefix = s | ||||
| 				newConfig.LaunchCmd = "wine " + newConfig.LaunchCmd | ||||
|  | ||||
| 				fmt.Println("Your launch command has been set to the following:") | ||||
| 				if s == "" { | ||||
| 					fmt.Printf("wine %s\n", newConfig.LaunchCmd) | ||||
| 				} else { | ||||
| 					fmt.Printf("WINEPREFIX=%s %s\n", newConfig.WinePrefix, newConfig.LaunchCmd) | ||||
| 				} | ||||
| 				fmt.Printf("Modify the configuration file at %s if you need to customize it\n\n", cfgPath) | ||||
| 			} | ||||
| 				break | ||||
| 		} | ||||
|  | ||||
| 			if s == "n" || s == "N" { | ||||
| 				break | ||||
| 		err = os.MkdirAll(filepath.Join(home, ".config", configDirName), 0755) | ||||
| 		if err != nil { | ||||
| 			return nil, fmt.Errorf("unable to create config directory: %v", err) | ||||
| 		} | ||||
|  | ||||
| 			fmt.Println("Please enter a valid value of either 'y' or 'n'") | ||||
| 		} | ||||
|  | ||||
| 		os.MkdirAll(filepath.Join(home, ".config", configDirName), 0755) | ||||
|  | ||||
| 		file, err := os.Create(cfgPath) | ||||
| 		if err != nil { | ||||
| 			file.Close() | ||||
| 			return nil, fmt.Errorf("unable to create config file: %v", err) | ||||
| 		} | ||||
|  | ||||
| 		encoder := toml.NewEncoder(file) | ||||
| 		if err = encoder.Encode(newConfig); err != nil { | ||||
| 			file.Close() | ||||
| 			return nil, fmt.Errorf("unable to encode config file: %v", err) | ||||
| 		} | ||||
| 		file.Close() | ||||
|  | ||||
| 		fmt.Printf("Created new config at %s\n\n", cfgPath) | ||||
| 	} | ||||
|  | ||||
| 	_, err = toml.DecodeFile(cfgPath, &newConfig) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	// Rewrite config file with any new defaults | ||||
| 	file, err := os.Create(cfgPath) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("unable to config file: %v", err) | ||||
| 	} | ||||
| 	defer file.Close() | ||||
|  | ||||
| 	encoder := toml.NewEncoder(file) | ||||
| 	if err = encoder.Encode(newConfig); err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
|  | ||||
| 		fmt.Println("Created new config at ", cfgPath) | ||||
| 	} | ||||
|  | ||||
| 	_, err := toml.DecodeFile(cfgPath, &newConfig) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 		return nil, fmt.Errorf("unable to encode config file: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	return &newConfig, nil | ||||
| } | ||||
|  | ||||
| func promptYesNo(prompt string) (bool, error) { | ||||
| 	for { | ||||
| 		fmt.Print(prompt) | ||||
| 		s, err := readLine() | ||||
| 		if err != nil { | ||||
| 			return false, fmt.Errorf("unable to read input: %v", err) | ||||
| 		} | ||||
|  | ||||
| 		if s == "y" || s == "Y" { | ||||
| 			return true, nil | ||||
| 		} | ||||
|  | ||||
| 		if s == "n" || s == "N" { | ||||
| 			return false, nil | ||||
| 		} | ||||
| 		fmt.Println("Please enter a valid value of either 'y' or 'n'") | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func input(prompt string) (string, error) { | ||||
| 	fmt.Println(prompt) | ||||
| 	fmt.Printf("> ") | ||||
|  | ||||
| 	s, err := readLine() | ||||
| 	if err != nil { | ||||
| 		return "", fmt.Errorf("unable to read input: %v", err) | ||||
| 	} | ||||
| 	return s, nil | ||||
| } | ||||
|  | ||||
| func readLine() (string, error) { | ||||
| 	reader := bufio.NewReader(os.Stdin) | ||||
| 	in, err := reader.ReadString('\n') | ||||
| 	if err != nil { | ||||
| 		return "", fmt.Errorf("unable to read input: %v", err) | ||||
| 	} | ||||
| 	return strings.TrimSpace(in), nil | ||||
| } | ||||
|   | ||||
							
								
								
									
										8
									
								
								config_unix.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								config_unix.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| //go:build linux || darwin | ||||
|  | ||||
| package main | ||||
|  | ||||
| func makeLink(src, dst string) error { | ||||
| 	// Noop on unix | ||||
| 	return nil | ||||
| } | ||||
							
								
								
									
										36
									
								
								config_windows.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								config_windows.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"github.com/go-ole/go-ole" | ||||
| 	"github.com/go-ole/go-ole/oleutil" | ||||
| 	"runtime" | ||||
| ) | ||||
|  | ||||
| func makeLink(src, dst string) error { | ||||
| 	runtime.LockOSThread() | ||||
| 	defer runtime.UnlockOSThread() | ||||
|  | ||||
| 	err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED|ole.COINIT_SPEED_OVER_MEMORY) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	oleShellObject, err := oleutil.CreateObject("WScript.Shell") | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer oleShellObject.Release() | ||||
| 	wshell, err := oleShellObject.QueryInterface(ole.IID_IDispatch) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer wshell.Release() | ||||
| 	cs, err := oleutil.CallMethod(wshell, "CreateShortcut", dst) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	idispatch := cs.ToIDispatch() | ||||
| 	oleutil.PutProperty(idispatch, "TargetPath", src) | ||||
| 	oleutil.CallMethod(idispatch, "Save") | ||||
| 	return nil | ||||
| } | ||||
							
								
								
									
										8
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										8
									
								
								go.mod
									
									
									
									
									
								
							| @@ -2,4 +2,10 @@ module git.burkey.co/eburk/epochcli | ||||
|  | ||||
| go 1.24.3 | ||||
|  | ||||
| require github.com/BurntSushi/toml v1.5.0 | ||||
| require ( | ||||
| 	github.com/BurntSushi/toml v1.5.0 | ||||
| 	github.com/Masterminds/semver/v3 v3.4.0 | ||||
| 	github.com/go-ole/go-ole v1.3.0 | ||||
| ) | ||||
|  | ||||
| require golang.org/x/sys v0.1.0 // indirect | ||||
|   | ||||
							
								
								
									
										6
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										6
									
								
								go.sum
									
									
									
									
									
								
							| @@ -1,2 +1,8 @@ | ||||
| github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= | ||||
| github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= | ||||
| github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= | ||||
| github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= | ||||
| github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= | ||||
| github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= | ||||
| golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= | ||||
| golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
|   | ||||
							
								
								
									
										123
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										123
									
								
								main.go
									
									
									
									
									
								
							| @@ -1,27 +1,29 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"crypto/md5" | ||||
| 	"encoding/hex" | ||||
| 	"flag" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"git.burkey.co/eburk/epochcli/pkg/epoch" | ||||
| 	"log" | ||||
| 	"net/http" | ||||
| 	"os" | ||||
| 	"os/exec" | ||||
| 	"path/filepath" | ||||
| 	"runtime" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	manifestUrl      = "https://updater.project-epoch.net/api/manifest" | ||||
| 	defaultWowPath   = "/path/to/wow" | ||||
| 	defaultLaunchCmd = "not configured" | ||||
| ) | ||||
|  | ||||
| func main() { | ||||
| 	outOfDate, err := needUpdate() | ||||
| 	if err != nil { | ||||
| 		log.Fatal(err) | ||||
| 	} | ||||
|  | ||||
| 	if outOfDate { | ||||
| 		fmt.Println("There is a new version of epochcli, you must update before running") | ||||
| 		fmt.Println("Press any key to exit...") | ||||
| 		var c rune | ||||
| 		fmt.Scanf("%c", &c) | ||||
| 		os.Exit(1) | ||||
| 	} | ||||
|  | ||||
| 	var ( | ||||
| 		helpFlag       bool | ||||
| 		updateOnlyFlag bool | ||||
| @@ -36,6 +38,7 @@ func main() { | ||||
|  | ||||
| 	if helpFlag { | ||||
| 		flag.CommandLine.SetOutput(os.Stdout) | ||||
| 		fmt.Println("Epochcli Help:") | ||||
| 		flag.PrintDefaults() | ||||
| 		os.Exit(0) | ||||
| 	} | ||||
| @@ -49,14 +52,19 @@ func main() { | ||||
| 		log.Fatalf("WowDir in %s is still the default setting, exiting", cfgPath) | ||||
| 	} | ||||
|  | ||||
| 	stats, err := downloadUpdate(config, forceFlag) | ||||
| 	if err != nil { | ||||
| 	stats := epoch.Update(config.WowDir, forceFlag, config.RemoveUnknownPatches, false) | ||||
| 	if stats.Error != nil { | ||||
| 		log.Fatal(err) | ||||
| 	} | ||||
|  | ||||
| 	fmt.Printf("%d files updated\n", stats.updated) | ||||
| 	if stats.current > 0 { | ||||
| 		fmt.Printf("%d files are already up to date\n", stats.current) | ||||
| 	fmt.Printf("%d files updated\n\n", stats.Updated) | ||||
| 	if stats.Current > 0 { | ||||
| 		fmt.Printf("%d files are already up to date\n\n", stats.Current) | ||||
| 	} | ||||
|  | ||||
| 	if configRun { | ||||
| 		fmt.Println("Configuration complete!") | ||||
| 		os.Exit(0) | ||||
| 	} | ||||
|  | ||||
| 	if updateOnlyFlag { | ||||
| @@ -69,78 +77,19 @@ func main() { | ||||
| 		} | ||||
|  | ||||
| 		fmt.Println("Starting Epoch...") | ||||
| 		switch runtime.GOOS { | ||||
| 		case "darwin": | ||||
| 			exec.Command("open", config.LaunchCmd).Run() | ||||
| 		case "linux": | ||||
| 			exec.Command(config.LaunchCmd).Run() | ||||
| 		var cmd = strings.Split(config.LaunchCmd, " ") | ||||
| 		ex := exec.Command(cmd[0], cmd[1:]...) | ||||
| 		cmdStr := strings.Join(cmd, " ") | ||||
| 		if config.WinePrefix != "" { | ||||
| 			prefix := fmt.Sprintf("WINEPREFIX=%s", config.WinePrefix) | ||||
| 			newEnv := append(os.Environ(), prefix) | ||||
| 			ex.Env = newEnv | ||||
| 			cmdStr = prefix + " " + cmdStr | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| type DownloadStats struct { | ||||
| 	updated int | ||||
| 	current int | ||||
| } | ||||
|  | ||||
| func downloadUpdate(config *Config, force bool) (DownloadStats, error) { | ||||
| 	var stats DownloadStats | ||||
|  | ||||
| 	manifest, err := getManifest() | ||||
| 		fmt.Println("Running command:", cmdStr) | ||||
| 		err = ex.Run() | ||||
| 		if err != nil { | ||||
| 		log.Fatalf("Failed to get manifest: %v\n", err) | ||||
| 	} | ||||
|  | ||||
| 	for _, file := range manifest.Files { | ||||
| 		path := strings.ReplaceAll(file.Path, `\`, `/`) | ||||
| 		path = strings.TrimLeft(path, `\`) | ||||
|  | ||||
| 		localPath := filepath.Join(config.WowDir, path) | ||||
| 		localDir := filepath.Dir(localPath) | ||||
| 		if _, err = os.Stat(localDir); os.IsNotExist(err) { | ||||
| 			os.MkdirAll(localDir, 0755) | ||||
| 		} | ||||
|  | ||||
| 		if !force { | ||||
| 			if _, err = os.Stat(localPath); err == nil { | ||||
| 				data, err := os.ReadFile(localPath) | ||||
| 				if err != nil { | ||||
| 					return stats, err | ||||
| 				} | ||||
| 				hashBytes := md5.Sum(data) | ||||
| 				hash := hex.EncodeToString(hashBytes[:]) | ||||
| 				if hash == file.Hash { | ||||
| 					fmt.Printf("File %s is up to date\n", localPath) | ||||
| 					stats.current += 1 | ||||
| 					continue | ||||
| 			log.Fatal(err) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| 		fmt.Printf(" %s...\n", localPath) | ||||
|  | ||||
| 		outFile, err := os.Create(localPath) | ||||
| 		if err != nil { | ||||
| 			return stats, err | ||||
| 		} | ||||
| 		defer outFile.Close() | ||||
|  | ||||
| 		resp, err := http.Get(file.URL) | ||||
| 		if err != nil { | ||||
| 			return stats, err | ||||
| 		} | ||||
| 		defer resp.Body.Close() | ||||
| 		if resp.StatusCode != http.StatusOK { | ||||
| 			return stats, fmt.Errorf("failed to download update from %s, status code: %d", file.URL, resp.StatusCode) | ||||
| 		} | ||||
|  | ||||
| 		_, err = io.Copy(outFile, resp.Body) | ||||
| 		if err != nil { | ||||
| 			return stats, err | ||||
| 		} | ||||
|  | ||||
| 		stats.updated += 1 | ||||
| 	} | ||||
|  | ||||
| 	return stats, nil | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| package main | ||||
| package epoch | ||||
| 
 | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| @@ -6,21 +6,30 @@ import ( | ||||
| 	"net/http" | ||||
| ) | ||||
| 
 | ||||
| const ( | ||||
| 	manifestUrl = "https://updater.project-epoch.net/api/v2/manifest" | ||||
| ) | ||||
| 
 | ||||
| type File struct { | ||||
| 	Path   string `json:"Path"` | ||||
| 	Hash   string `json:"Hash"` | ||||
| 	Size   int    `json:"Size"` | ||||
| 	Custom bool   `json:"Custom"` | ||||
| 	URL    string `json:"URL"` | ||||
| 	Origin string `json:"Origin"` | ||||
| 	Urls   struct { | ||||
| 		Digitalocean string `json:"digitalocean"` | ||||
| 		Cloudflare   string `json:"cloudflare"` | ||||
| 		None         string `json:"none"` | ||||
| 	} `json:"Urls"` | ||||
| } | ||||
| 
 | ||||
| type Manifest struct { | ||||
| 	Version   string `json:"Version"` | ||||
| 	UID       string `json:"Uid"` | ||||
| 	Files     []File `json:"Files"` | ||||
| 	CheckedAt string `json:"checked_at"` | ||||
| } | ||||
| 
 | ||||
| func getManifest() (*Manifest, error) { | ||||
| func GetManifest() (*Manifest, error) { | ||||
| 	resp, err := http.Get(manifestUrl) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
							
								
								
									
										168
									
								
								pkg/epoch/update.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										168
									
								
								pkg/epoch/update.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,168 @@ | ||||
| package epoch | ||||
|  | ||||
| import ( | ||||
| 	"crypto/md5" | ||||
| 	"encoding/hex" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"io/fs" | ||||
| 	"net/http" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"regexp" | ||||
| 	"slices" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| type UpdateStats struct { | ||||
| 	Updated    int | ||||
| 	Current    int | ||||
| 	Outdated   int | ||||
| 	Error      error | ||||
| 	MessageBuf []string | ||||
| } | ||||
|  | ||||
| func Update(wowdir string, force bool, removeUnknown bool, skipDownload bool) UpdateStats { | ||||
| 	stats := UpdateStats{ | ||||
| 		Error:      nil, | ||||
| 		MessageBuf: make([]string, 0), | ||||
| 	} | ||||
|  | ||||
| 	msgChan := make(chan string) | ||||
| 	done := make(chan bool) | ||||
|  | ||||
| 	go func() { | ||||
| 		manifest, err := GetManifest() | ||||
| 		if err != nil { | ||||
| 			stats.Error = fmt.Errorf("Failed to get manifest: %v\n", err) | ||||
| 			done <- true | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		for _, file := range manifest.Files { | ||||
| 			path := strings.ReplaceAll(file.Path, `\`, `/`) | ||||
| 			path = strings.TrimLeft(path, `\`) | ||||
|  | ||||
| 			localPath := filepath.Join(wowdir, path) | ||||
| 			localDir := filepath.Dir(localPath) | ||||
| 			if _, err = os.Stat(localDir); os.IsNotExist(err) { | ||||
| 				err = os.MkdirAll(localDir, 0755) | ||||
| 				if err != nil { | ||||
| 					stats.Error = fmt.Errorf("failed to create directory %s: %v", localDir, err) | ||||
| 					done <- true | ||||
| 					return | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			if !force { | ||||
| 				if _, err = os.Stat(localPath); err == nil { | ||||
| 					data, err := os.ReadFile(localPath) | ||||
| 					if err != nil { | ||||
| 						stats.Error = fmt.Errorf("failed to read %s: %v", localPath, err) | ||||
| 						done <- true | ||||
| 						return | ||||
| 					} | ||||
| 					hashBytes := md5.Sum(data) | ||||
| 					hash := hex.EncodeToString(hashBytes[:]) | ||||
| 					if hash == file.Hash { | ||||
| 						msgChan <- fmt.Sprintf("File %s is up to date", localPath) | ||||
| 						stats.Current += 1 | ||||
| 						continue | ||||
| 					} else { | ||||
| 						stats.Outdated += 1 | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			if !skipDownload { | ||||
| 				msgChan <- fmt.Sprintf("Downloading %s", localPath) | ||||
| 				outFile, err := os.Create(localPath) | ||||
| 				if err != nil { | ||||
| 					stats.Error = fmt.Errorf("failed to create file %s: %v", localPath, err) | ||||
| 					done <- true | ||||
| 					return | ||||
| 				} | ||||
|  | ||||
| 				downloadSuccess := false | ||||
| 				for _, url := range []string{file.Urls.Cloudflare, file.Urls.Digitalocean, file.Urls.None} { | ||||
| 					resp, err := http.Get(url) | ||||
| 					if err != nil { | ||||
| 						if resp != nil { | ||||
| 							resp.Body.Close() | ||||
| 						} | ||||
| 						msgChan <- fmt.Sprintf("Failed to download %s: %v", url, err) | ||||
| 						continue | ||||
| 					} | ||||
| 					if resp.StatusCode != http.StatusOK { | ||||
| 						resp.Body.Close() | ||||
| 						msgChan <- fmt.Sprintf("HTTP Status %d", resp.StatusCode) | ||||
| 						continue | ||||
| 					} | ||||
|  | ||||
| 					_, err = io.Copy(outFile, resp.Body) | ||||
| 					if err != nil { | ||||
| 						msgChan <- fmt.Sprintf("Failed to write file %s: %v", localPath, err) | ||||
| 						resp.Body.Close() | ||||
| 						continue | ||||
| 					} | ||||
|  | ||||
| 					resp.Body.Close() | ||||
| 					downloadSuccess = true | ||||
| 					msgChan <- fmt.Sprintf("Successfully downloaded %s", localPath) | ||||
| 					break | ||||
| 				} | ||||
|  | ||||
| 				outFile.Close() | ||||
| 				if !downloadSuccess { | ||||
| 					stats.Error = fmt.Errorf("Failed to download updates, see above messages") | ||||
| 					done <- true | ||||
| 					return | ||||
| 				} | ||||
|  | ||||
| 				stats.Updated += 1 | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if removeUnknown { | ||||
| 			patches := make([]string, 0) | ||||
| 			patchreg := regexp.MustCompile(`patch-[A-Za-z].MPQ`) | ||||
|  | ||||
| 			for _, file := range manifest.Files { | ||||
| 				if patchreg.MatchString(file.Path) { | ||||
| 					patches = append(patches, strings.Split(file.Path, "Data\\")[1]) | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			err = filepath.WalkDir(filepath.Join(wowdir, "Data"), func(path string, d fs.DirEntry, err error) error { | ||||
| 				if !d.IsDir() && patchreg.MatchString(d.Name()) { | ||||
| 					del := true | ||||
| 					if slices.Contains(patches, d.Name()) { | ||||
| 						del = false | ||||
| 					} | ||||
| 					if del { | ||||
| 						err = os.Remove(path) | ||||
| 						if err != nil { | ||||
| 							return err | ||||
| 						} | ||||
| 						msgChan <- fmt.Sprintf("Removed unknown patch %s", d.Name()) | ||||
| 					} | ||||
| 				} | ||||
| 				return nil | ||||
| 			}) | ||||
| 			if err != nil { | ||||
| 				stats.Error = fmt.Errorf("failed to delete unknown patches: %v", err) | ||||
| 			} | ||||
| 		} | ||||
| 		done <- true | ||||
| 	}() | ||||
|  | ||||
| 	for { | ||||
| 		select { | ||||
| 		case msg := <-msgChan: | ||||
| 			fmt.Println(msg) | ||||
| 			stats.MessageBuf = append(stats.MessageBuf, msg) | ||||
| 		case <-done: | ||||
| 			return stats | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										26
									
								
								release.sh
									
									
									
									
									
								
							
							
						
						
									
										26
									
								
								release.sh
									
									
									
									
									
								
							| @@ -3,20 +3,20 @@ | ||||
| set -e | ||||
|  | ||||
| rm -f ./*.tar.gz | ||||
| rm -rf bin | ||||
| rm -f ./*.zip | ||||
|  | ||||
| mkdir bin | ||||
| GOOS=linux GOARCH=amd64 go build -o epochcli | ||||
| tar czvf epochcli-linux-amd64.tar.gz epochcli | ||||
| rm ./epochcli | ||||
|  | ||||
| GOOS=linux GOARCH=amd64 go build -o bin/epochcli-linux-amd64 | ||||
| tar czvf epochcli-linux-amd64.tar.gz bin/epochcli-linux-amd64 | ||||
| GOOS=linux GOARCH=arm64 go build -o epochcli | ||||
| tar czvf epochcli-linux-arm64.tar.gz epochcli | ||||
| rm ./epochcli | ||||
|  | ||||
| GOOS=linux GOARCH=arm64 go build -o bin/epochcli-linux-arm64 | ||||
| tar czvf epochcli-linux-arm64.tar.gz bin/epochcli-linux-arm64 | ||||
| GOOS=windows GOARCH=amd64 go build -o epochcli.exe | ||||
| zip -j epochcli-windows-amd64.zip epochcli.exe | ||||
| rm ./epochcli.exe | ||||
|  | ||||
| GOOS=darwin GOARCH=arm64 go build -o bin/epochcli-darwin-arm64 | ||||
| tar czvf epochcli-darwin-arm64.tar.gz bin/epochcli-darwin-arm64 | ||||
|  | ||||
| GOOS=darwin GOARCH=amd64 go build -o bin/epochcli-darwin-amd64 | ||||
| tar czvf epochcli-darwin-amd64.tar.gz bin/epochcli-darwin-amd64 | ||||
|  | ||||
| rm -rf bin | ||||
| GOOS=windows GOARCH=arm64 go build -o epochcli.exe | ||||
| zip -j epochcli-windows-arm64.zip epochcli.exe | ||||
| rm ./epochcli.exe | ||||
|   | ||||
							
								
								
									
										55
									
								
								version.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								version.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"regexp" | ||||
|  | ||||
| 	"github.com/Masterminds/semver/v3" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	version    = "1.1.2" | ||||
| 	versionUrl = "https://git.burkey.co/eburk/epochcli/raw/branch/master/version.go" | ||||
| ) | ||||
|  | ||||
| func needUpdate() (bool, error) { | ||||
| 	resp, err := http.Get(versionUrl) | ||||
| 	if err != nil { | ||||
| 		return false, fmt.Errorf("unable to get version file: %v", err) | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
|  | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 		return false, fmt.Errorf("failed to download update, status code: %d", resp.StatusCode) | ||||
| 	} | ||||
|  | ||||
| 	b, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return false, fmt.Errorf("unable to read response body: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	re := regexp.MustCompile(`version\s+=\s+"(\d+.\d+.\d+)"`) | ||||
| 	ver := re.FindStringSubmatch(string(b)) | ||||
| 	if ver[1] == "" { | ||||
| 		return false, fmt.Errorf("unable to parse version number") | ||||
| 	} | ||||
|  | ||||
| 	curVer, err := semver.NewVersion(version) | ||||
| 	if err != nil { | ||||
| 		return false, fmt.Errorf("unable to parse current version to semver: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	newVer, err := semver.NewVersion(ver[1]) | ||||
| 	if err != nil { | ||||
| 		return false, fmt.Errorf("unable to parse new version to semver: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	if curVer.LessThan(newVer) { | ||||
| 		fmt.Println("Current Version: ", curVer) | ||||
| 		fmt.Println("Latest Version: ", newVer) | ||||
| 		return true, nil | ||||
| 	} | ||||
| 	return false, nil | ||||
| } | ||||
		Reference in New Issue
	
	Block a user