Compare commits
	
		
			34 Commits
		
	
	
		
			1.0
			...
			61e17cffc5
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | 
							
								
								
									
										30
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										30
									
								
								README.md
									
									
									
									
									
								
							| @@ -2,22 +2,25 @@ | |||||||
|  |  | ||||||
| CLI tool for updating and launching [Project Epoch](https://www.project-epoch.net/) on Windows, Linux and macOS. | CLI tool for updating and launching [Project Epoch](https://www.project-epoch.net/) on Windows, Linux and macOS. | ||||||
|  |  | ||||||
| ## Setup | 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 | ||||||
|  |  | ||||||
| ### Windows | ## Installing | ||||||
|  |  | ||||||
| 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. |  | ||||||
|  |  | ||||||
| ### Linux | ### 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. | 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 to install. | 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 | ### macOS | ||||||
|  |  | ||||||
| For macOS, I've found the best way to run Wow 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. Any suggestions would be welcome, see my contact information below. | 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) | ||||||
|  |  | ||||||
| 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: | 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 | ```shell | ||||||
| brew tap eburk/epochcli https://git.burkey.co/eburk/epochcli | brew tap eburk/epochcli https://git.burkey.co/eburk/epochcli | ||||||
| @@ -27,10 +30,17 @@ brew install --HEAD epochcli | |||||||
| brew upgrade epochcli --fetch-HEAD | brew upgrade epochcli --fetch-HEAD | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| ## First Run | ### 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 | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| 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` | 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 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 | 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 | ## Usage | ||||||
| ``` | ``` | ||||||
| @@ -43,4 +53,4 @@ brew upgrade epochcli --fetch-HEAD | |||||||
|  |  | ||||||
| ## Issues | ## 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 | ||||||
|   | |||||||
							
								
								
									
										134
									
								
								config.go
									
									
									
									
									
								
							
							
						
						
									
										134
									
								
								config.go
									
									
									
									
									
								
							| @@ -1,6 +1,7 @@ | |||||||
| package main | package main | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
|  | 	"bufio" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"github.com/BurntSushi/toml" | 	"github.com/BurntSushi/toml" | ||||||
| 	"os" | 	"os" | ||||||
| @@ -13,15 +14,22 @@ import ( | |||||||
| type Config struct { | type Config struct { | ||||||
| 	WowDir               string | 	WowDir               string | ||||||
| 	LaunchCmd            string | 	LaunchCmd            string | ||||||
|  | 	WinePrefix           string | ||||||
| 	EnableLauncher       bool | 	EnableLauncher       bool | ||||||
|  | 	RemoveUnknownPatches bool | ||||||
| } | } | ||||||
|  |  | ||||||
| const ( | const ( | ||||||
| 	configDirName    = "epochcli" | 	configDirName    = "epochcli" | ||||||
| 	configName       = "config.toml" | 	configName       = "config.toml" | ||||||
|  | 	defaultWowPath   = "/path/to/wow" | ||||||
|  | 	defaultLaunchCmd = "not configured" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| var cfgPath string | var ( | ||||||
|  | 	configRun = false | ||||||
|  | 	cfgPath   string | ||||||
|  | ) | ||||||
|  |  | ||||||
| func setupConfig(rerun bool) (*Config, error) { | func setupConfig(rerun bool) (*Config, error) { | ||||||
| 	home, err := os.UserHomeDir() | 	home, err := os.UserHomeDir() | ||||||
| @@ -34,64 +42,57 @@ func setupConfig(rerun bool) (*Config, error) { | |||||||
| 		WowDir:               defaultWowPath, | 		WowDir:               defaultWowPath, | ||||||
| 		LaunchCmd:            defaultLaunchCmd, | 		LaunchCmd:            defaultLaunchCmd, | ||||||
| 		EnableLauncher:       false, | 		EnableLauncher:       false, | ||||||
|  | 		RemoveUnknownPatches: true, | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	_, statErr := os.Stat(cfgPath) | 	_, statErr := os.Stat(cfgPath) | ||||||
| 	if rerun || os.IsNotExist(statErr) { | 	if rerun || os.IsNotExist(statErr) { | ||||||
| 		fmt.Println("Enter the path to your Wow directory below:") | 		configRun = true | ||||||
| 		var s string | 		fmt.Println("Enter the path to your Wow directory below. Use the full path without shortcuts like '~' (ex: /home/user/epoch):") | ||||||
| 		_, err = fmt.Scanln(&s) | 		s, err := readLine() | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return nil, fmt.Errorf("unable to read input: %v", err) | 			return nil, fmt.Errorf("unable to read input: %v", err) | ||||||
| 		} | 		} | ||||||
| 		newConfig.WowDir = strings.TrimSpace(s) | 		newConfig.WowDir = s | ||||||
|  |  | ||||||
| 		for { | 		fmt.Println() | ||||||
| 			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): ") | 		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): ")) | ||||||
| 			_, err = fmt.Scanf("%s", &s) |  | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 				return nil, fmt.Errorf("unable to read input: %v", err) | 			return nil, err | ||||||
| 		} | 		} | ||||||
| 			s = strings.TrimSpace(s) | 		fmt.Println() | ||||||
|  |  | ||||||
| 			if s == "y" || s == "Y" { | 		if p { | ||||||
| 			newConfig.EnableLauncher = true | 			newConfig.EnableLauncher = true | ||||||
|  | 			newConfig.LaunchCmd = path.Join(newConfig.WowDir, "Project-Epoch.exe") | ||||||
|  |  | ||||||
| 			if runtime.GOOS == "windows" { | 			if runtime.GOOS == "windows" { | ||||||
| 					newConfig.LaunchCmd = path.Join(newConfig.WowDir, "Project-Epoch.exe") |  | ||||||
| 				exePath, err := os.Executable() | 				exePath, err := os.Executable() | ||||||
| 				if err != nil { | 				if err != nil { | ||||||
| 						fmt.Println("unable to create desktop shortcut: ", err) | 					return nil, fmt.Errorf("unable to create desktop shortcut: %v", err) | ||||||
| 						break |  | ||||||
| 				} | 				} | ||||||
| 				err = makeLink(exePath, path.Join(home, "Desktop", "Project-Epoch.lnk")) | 				err = makeLink(exePath, path.Join(home, "Desktop", "Project-Epoch.lnk")) | ||||||
| 				if err != nil { | 				if err != nil { | ||||||
| 						fmt.Println("unable to create desktop shortcut: ", err) | 					return nil, fmt.Errorf("unable to create desktop shortcut: %v", err) | ||||||
| 				} | 				} | ||||||
| 					break |  | ||||||
| 			} else { | 			} else { | ||||||
| 					fmt.Println("Enter your launch command to start Wow below. If you would rather configure this later in the configuration file, just press Enter") | 				s, err = input("Enter your wine prefix. Leave blank if you do not need to set WINEPREFIX") | ||||||
| 					fmt.Printf("> ") |  | ||||||
|  |  | ||||||
| 					_, err = fmt.Scanf("%s", &s) |  | ||||||
| 				if err != nil { | 				if err != nil { | ||||||
| 						return nil, fmt.Errorf("unable to read input: %v", err) | 					return nil, err | ||||||
| 				} | 				} | ||||||
| 					s = strings.TrimSpace(s) | 				fmt.Println() | ||||||
|  |  | ||||||
| 					if s != "" { | 				newConfig.WinePrefix = s | ||||||
| 						newConfig.LaunchCmd = s | 				newConfig.LaunchCmd = "wine " + newConfig.LaunchCmd | ||||||
| 					} |  | ||||||
|  |  | ||||||
| 					break | 				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) | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 			if s == "n" || s == "N" { |  | ||||||
| 				break |  | ||||||
| 			} |  | ||||||
|  |  | ||||||
| 			fmt.Println("Please enter a valid value of either 'y' or 'n'") |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		err = os.MkdirAll(filepath.Join(home, ".config", configDirName), 0755) | 		err = os.MkdirAll(filepath.Join(home, ".config", configDirName), 0755) | ||||||
| @@ -101,8 +102,30 @@ func setupConfig(rerun bool) (*Config, error) { | |||||||
|  |  | ||||||
| 		file, err := os.Create(cfgPath) | 		file, err := os.Create(cfgPath) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
|  | 			file.Close() | ||||||
| 			return nil, fmt.Errorf("unable to create config file: %v", err) | 			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() | 	defer file.Close() | ||||||
|  |  | ||||||
| 	encoder := toml.NewEncoder(file) | 	encoder := toml.NewEncoder(file) | ||||||
| @@ -110,13 +133,44 @@ func setupConfig(rerun bool) (*Config, error) { | |||||||
| 		return nil, fmt.Errorf("unable to encode config file: %v", err) | 		return nil, fmt.Errorf("unable to encode config file: %v", err) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 		fmt.Println("Created new config at ", cfgPath) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	_, err = toml.DecodeFile(cfgPath, &newConfig) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	return &newConfig, nil | 	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 | ||||||
|  | } | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								go.mod
									
									
									
									
									
								
							| @@ -4,6 +4,7 @@ go 1.24.3 | |||||||
|  |  | ||||||
| require ( | require ( | ||||||
| 	github.com/BurntSushi/toml v1.5.0 | 	github.com/BurntSushi/toml v1.5.0 | ||||||
|  | 	github.com/Masterminds/semver/v3 v3.4.0 | ||||||
| 	github.com/go-ole/go-ole v1.3.0 | 	github.com/go-ole/go-ole v1.3.0 | ||||||
| ) | ) | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								go.sum
									
									
									
									
									
								
							| @@ -1,5 +1,7 @@ | |||||||
| github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= | 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/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 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= | ||||||
| github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= | 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 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= | ||||||
|   | |||||||
							
								
								
									
										121
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										121
									
								
								main.go
									
									
									
									
									
								
							| @@ -1,27 +1,29 @@ | |||||||
| package main | package main | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"crypto/md5" |  | ||||||
| 	"encoding/hex" |  | ||||||
| 	"flag" | 	"flag" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"io" | 	"git.burkey.co/eburk/epochcli/pkg/epoch" | ||||||
| 	"log" | 	"log" | ||||||
| 	"net/http" |  | ||||||
| 	"os" | 	"os" | ||||||
| 	"os/exec" | 	"os/exec" | ||||||
| 	"path/filepath" |  | ||||||
| 	"runtime" |  | ||||||
| 	"strings" | 	"strings" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| const ( |  | ||||||
| 	manifestUrl      = "https://updater.project-epoch.net/api/manifest" |  | ||||||
| 	defaultWowPath   = "/path/to/wow" |  | ||||||
| 	defaultLaunchCmd = "not configured" |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| func main() { | 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 ( | 	var ( | ||||||
| 		helpFlag       bool | 		helpFlag       bool | ||||||
| 		updateOnlyFlag bool | 		updateOnlyFlag bool | ||||||
| @@ -50,14 +52,19 @@ func main() { | |||||||
| 		log.Fatalf("WowDir in %s is still the default setting, exiting", cfgPath) | 		log.Fatalf("WowDir in %s is still the default setting, exiting", cfgPath) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	stats, err := downloadUpdate(config, forceFlag) | 	stats := epoch.Update(config.WowDir, forceFlag, config.RemoveUnknownPatches, false) | ||||||
| 	if err != nil { | 	if stats.Error != nil { | ||||||
| 		log.Fatal(err) | 		log.Fatal(err) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	fmt.Printf("%d files updated\n", stats.updated) | 	fmt.Printf("%d files updated\n\n", stats.Updated) | ||||||
| 	if stats.current > 0 { | 	if stats.Current > 0 { | ||||||
| 		fmt.Printf("%d files are already up to date\n", stats.current) | 		fmt.Printf("%d files are already up to date\n\n", stats.Current) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if configRun { | ||||||
|  | 		fmt.Println("Configuration complete!") | ||||||
|  | 		os.Exit(0) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if updateOnlyFlag { | 	if updateOnlyFlag { | ||||||
| @@ -70,77 +77,19 @@ func main() { | |||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		fmt.Println("Starting Epoch...") | 		fmt.Println("Starting Epoch...") | ||||||
| 		if runtime.GOOS == "darwin" { | 		var cmd = strings.Split(config.LaunchCmd, " ") | ||||||
| 			exec.Command("open", config.LaunchCmd).Run() | 		ex := exec.Command(cmd[0], cmd[1:]...) | ||||||
| 		} else { | 		cmdStr := strings.Join(cmd, " ") | ||||||
| 			exec.Command(config.LaunchCmd).Run() | 		if config.WinePrefix != "" { | ||||||
|  | 			prefix := fmt.Sprintf("WINEPREFIX=%s", config.WinePrefix) | ||||||
|  | 			newEnv := append(os.Environ(), prefix) | ||||||
|  | 			ex.Env = newEnv | ||||||
|  | 			cmdStr = prefix + " " + cmdStr | ||||||
| 		} | 		} | ||||||
| 	} | 		fmt.Println("Running command:", cmdStr) | ||||||
| } | 		err = ex.Run() | ||||||
|  |  | ||||||
| type DownloadStats struct { |  | ||||||
| 	updated int |  | ||||||
| 	current int |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func downloadUpdate(config *Config, force bool) (DownloadStats, error) { |  | ||||||
| 	var stats DownloadStats |  | ||||||
|  |  | ||||||
| 	manifest, err := getManifest() |  | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 		log.Fatalf("Failed to get manifest: %v\n", err) | 			log.Fatal(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 |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| 		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 ( | import ( | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
| @@ -6,21 +6,30 @@ import ( | |||||||
| 	"net/http" | 	"net/http" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | const ( | ||||||
|  | 	manifestUrl = "https://updater.project-epoch.net/api/v2/manifest" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
| type File struct { | type File struct { | ||||||
| 	Path   string `json:"Path"` | 	Path   string `json:"Path"` | ||||||
| 	Hash   string `json:"Hash"` | 	Hash   string `json:"Hash"` | ||||||
| 	Size   int    `json:"Size"` | 	Size   int    `json:"Size"` | ||||||
| 	Custom bool   `json:"Custom"` | 	Custom bool   `json:"Custom"` | ||||||
| 	URL    string `json:"URL"` | 	Urls   struct { | ||||||
| 	Origin string `json:"Origin"` | 		Digitalocean string `json:"digitalocean"` | ||||||
|  | 		Cloudflare   string `json:"cloudflare"` | ||||||
|  | 		None         string `json:"none"` | ||||||
|  | 	} `json:"Urls"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type Manifest struct { | type Manifest struct { | ||||||
| 	Version   string `json:"Version"` | 	Version   string `json:"Version"` | ||||||
|  | 	UID       string `json:"Uid"` | ||||||
| 	Files     []File `json:"Files"` | 	Files     []File `json:"Files"` | ||||||
|  | 	CheckedAt string `json:"checked_at"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func getManifest() (*Manifest, error) { | func GetManifest() (*Manifest, error) { | ||||||
| 	resp, err := http.Get(manifestUrl) | 	resp, err := http.Get(manifestUrl) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, err | 		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 | set -e | ||||||
|  |  | ||||||
| rm -f ./*.tar.gz | 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 | GOOS=linux GOARCH=arm64 go build -o epochcli | ||||||
| tar czvf epochcli-linux-amd64.tar.gz bin/epochcli-linux-amd64 | tar czvf epochcli-linux-arm64.tar.gz epochcli | ||||||
|  | rm ./epochcli | ||||||
|  |  | ||||||
| GOOS=linux GOARCH=arm64 go build -o bin/epochcli-linux-arm64 | GOOS=windows GOARCH=amd64 go build -o epochcli.exe | ||||||
| tar czvf epochcli-linux-arm64.tar.gz bin/epochcli-linux-arm64 | zip -j epochcli-windows-amd64.zip epochcli.exe | ||||||
|  | rm ./epochcli.exe | ||||||
|  |  | ||||||
| GOOS=windows GOARCH=amd64 go build -o bin/epochcli-windows-amd64 | GOOS=windows GOARCH=arm64 go build -o epochcli.exe | ||||||
| zip epochcli-windows-amd64.zip bin/epochcli-windows-amd64 | zip -j epochcli-windows-arm64.zip epochcli.exe | ||||||
|  | rm ./epochcli.exe | ||||||
| GOOS=windows GOARCH=arm64 go build -o bin/epochcli-windows-arm64 |  | ||||||
| zip epochcli-windows-arm64.zip bin/epochcli-windows-arm64 |  | ||||||
|  |  | ||||||
| rm -rf bin |  | ||||||
|   | |||||||
							
								
								
									
										51
									
								
								version.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								version.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | |||||||
|  | package main | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 	"io" | ||||||
|  | 	"net/http" | ||||||
|  | 	"regexp" | ||||||
|  |  | ||||||
|  | 	"github.com/Masterminds/semver/v3" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | const ( | ||||||
|  | 	version    = "1.1.1" | ||||||
|  | 	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) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	fmt.Println(string(b)) | ||||||
|  | 	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) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return curVer != newVer, nil | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user