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 | class Epochcli < Formula | ||||||
|   desc "Updater and Launcher for Epoch" |   desc "Updater and Launcher for Epoch" | ||||||
|   homepage "https://git.burkey.co/eburk/epochcli/src/branch/master" |   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" |   license "ISC" | ||||||
|   head "https://git.burkey.co/eburk/epochcli.git", branch: "master" |   head "https://git.burkey.co/eburk/epochcli.git", branch: "master" | ||||||
|  |  | ||||||
|   livecheck do |  | ||||||
|     url :stable |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   depends_on "go" => :build |   depends_on "go" => :build | ||||||
|  |  | ||||||
|   def install |   def install | ||||||
|     system "go", "build" |     system "go", "build", *std_go_args(ldflags: "-s -w") | ||||||
|     bin.install "epochcli" |   end | ||||||
|  |  | ||||||
|  |   test do | ||||||
|  |     assert_match "Epochcli Help:", shell_output("#{bin}/epochcli -h") | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
							
								
								
									
										45
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										45
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,15 +1,46 @@ | |||||||
| # epochcli | # 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 | ## 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.  | 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. 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. 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 | 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 | ||||||
| ``` | ``` | ||||||
| @@ -22,4 +53,4 @@ For macOS, I've found the best way to run the client is in a Parallels Win 11 VM | |||||||
|  |  | ||||||
| ## 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 | ||||||
|   | |||||||
							
								
								
									
										166
									
								
								config.go
									
									
									
									
									
								
							
							
						
						
									
										166
									
								
								config.go
									
									
									
									
									
								
							| @@ -1,96 +1,176 @@ | |||||||
| package main | package main | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
|  | 	"bufio" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"github.com/BurntSushi/toml" | 	"github.com/BurntSushi/toml" | ||||||
| 	"os" | 	"os" | ||||||
|  | 	"path" | ||||||
| 	"path/filepath" | 	"path/filepath" | ||||||
|  | 	"runtime" | ||||||
|  | 	"strings" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| type Config struct { | type Config struct { | ||||||
| 	WowDir         string | 	WowDir               string | ||||||
| 	LaunchCmd      string | 	LaunchCmd            string | ||||||
| 	EnableLauncher bool | 	WinePrefix           string | ||||||
|  | 	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 := os.Getenv("HOME") | 	home, err := os.UserHomeDir() | ||||||
| 	if home == "" { | 	if err != nil { | ||||||
| 		return nil, fmt.Errorf("$HOME environment variable not set") | 		return nil, fmt.Errorf("unable to determine home directory: %v", err) | ||||||
| 	} | 	} | ||||||
|  | 	cfgPath = filepath.Join(home, ".config", configDirName, configName) | ||||||
|  |  | ||||||
| 	newConfig := Config{ | 	newConfig := Config{ | ||||||
| 		WowDir:         defaultWowPath, | 		WowDir:               defaultWowPath, | ||||||
| 		LaunchCmd:      defaultLaunchCmd, | 		LaunchCmd:            defaultLaunchCmd, | ||||||
| 		EnableLauncher: false, | 		EnableLauncher:       false, | ||||||
|  | 		RemoveUnknownPatches: true, | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	cfgPath = filepath.Join(home, ".config", configDirName, configName) |  | ||||||
|  |  | ||||||
| 	_, 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 | ||||||
| 		_, err := fmt.Scanln(&newConfig.WowDir) | 		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 { | 		if err != nil { | ||||||
| 			return nil, err | 			return nil, err | ||||||
| 		} | 		} | ||||||
|  | 		fmt.Println() | ||||||
|  |  | ||||||
| 		for { | 		if p { | ||||||
| 			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): ") | 			newConfig.EnableLauncher = true | ||||||
| 			var s string | 			newConfig.LaunchCmd = path.Join(newConfig.WowDir, "Project-Epoch.exe") | ||||||
| 			_, err := fmt.Scanf("%s", &s) |  | ||||||
| 			if err != nil { |  | ||||||
| 				return nil, err |  | ||||||
| 			} |  | ||||||
|  |  | ||||||
| 			if s == "y" || s == "Y" { | 			if runtime.GOOS == "windows" { | ||||||
| 				newConfig.EnableLauncher = true | 				exePath, err := os.Executable() | ||||||
| 				fmt.Println("Enter your launch command to start Wow below. If you would rather configure this later in the configuration file, just press Enter") | 				if err != nil { | ||||||
| 				fmt.Printf("> ") | 					return nil, fmt.Errorf("unable to create desktop shortcut: %v", err) | ||||||
| 				_, err = fmt.Scanf("%s", &s) | 				} | ||||||
|  | 				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 { | 				if err != nil { | ||||||
| 					return nil, err | 					return nil, err | ||||||
| 				} | 				} | ||||||
| 				if s != "" { | 				fmt.Println() | ||||||
| 					newConfig.LaunchCmd = s |  | ||||||
|  | 				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) | ||||||
| 				} | 				} | ||||||
| 				break | 				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'") |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		os.MkdirAll(filepath.Join(home, ".config", configDirName), 0755) | 		err = os.MkdirAll(filepath.Join(home, ".config", configDirName), 0755) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return nil, fmt.Errorf("unable to create config directory: %v", err) | ||||||
|  | 		} | ||||||
|  |  | ||||||
| 		file, err := os.Create(cfgPath) | 		file, err := os.Create(cfgPath) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return nil, err | 			file.Close() | ||||||
|  | 			return nil, fmt.Errorf("unable to create config file: %v", err) | ||||||
| 		} | 		} | ||||||
| 		defer file.Close() |  | ||||||
|  |  | ||||||
| 		encoder := toml.NewEncoder(file) | 		encoder := toml.NewEncoder(file) | ||||||
| 		if err = encoder.Encode(newConfig); err != nil { | 		if err = encoder.Encode(newConfig); err != nil { | ||||||
| 			return nil, err | 			file.Close() | ||||||
|  | 			return nil, fmt.Errorf("unable to encode config file: %v", err) | ||||||
| 		} | 		} | ||||||
|  | 		file.Close() | ||||||
|  |  | ||||||
| 		fmt.Println("Created new config at ", cfgPath) | 		fmt.Printf("Created new config at %s\n\n", cfgPath) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	_, err := toml.DecodeFile(cfgPath, &newConfig) | 	_, err = toml.DecodeFile(cfgPath, &newConfig) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, err | 		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, fmt.Errorf("unable to encode config file: %v", 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 | ||||||
|  | } | ||||||
|   | |||||||
							
								
								
									
										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 | 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 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/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= | ||||||
|   | |||||||
							
								
								
									
										127
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										127
									
								
								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 | ||||||
| @@ -36,6 +38,7 @@ func main() { | |||||||
|  |  | ||||||
| 	if helpFlag { | 	if helpFlag { | ||||||
| 		flag.CommandLine.SetOutput(os.Stdout) | 		flag.CommandLine.SetOutput(os.Stdout) | ||||||
|  | 		fmt.Println("Epochcli Help:") | ||||||
| 		flag.PrintDefaults() | 		flag.PrintDefaults() | ||||||
| 		os.Exit(0) | 		os.Exit(0) | ||||||
| 	} | 	} | ||||||
| @@ -49,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 { | ||||||
| @@ -69,78 +77,19 @@ func main() { | |||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		fmt.Println("Starting Epoch...") | 		fmt.Println("Starting Epoch...") | ||||||
| 		switch runtime.GOOS { | 		var cmd = strings.Split(config.LaunchCmd, " ") | ||||||
| 		case "darwin": | 		ex := exec.Command(cmd[0], cmd[1:]...) | ||||||
| 			exec.Command("open", config.LaunchCmd).Run() | 		cmdStr := strings.Join(cmd, " ") | ||||||
| 		case "linux": | 		if config.WinePrefix != "" { | ||||||
| 			exec.Command(config.LaunchCmd).Run() | 			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() | ||||||
|  | 		if err != nil { | ||||||
|  | 			log.Fatal(err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| type DownloadStats struct { |  | ||||||
| 	updated int |  | ||||||
| 	current int |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func downloadUpdate(config *Config, force bool) (DownloadStats, error) { |  | ||||||
| 	var stats DownloadStats |  | ||||||
|  |  | ||||||
| 	manifest, err := getManifest() |  | ||||||
| 	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 |  | ||||||
| 				} |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		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"` | ||||||
| 	Files   []File `json:"Files"` | 	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) | 	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=darwin GOARCH=arm64 go build -o bin/epochcli-darwin-arm64 | GOOS=windows GOARCH=arm64 go build -o epochcli.exe | ||||||
| tar czvf epochcli-darwin-arm64.tar.gz bin/epochcli-darwin-arm64 | zip -j epochcli-windows-arm64.zip epochcli.exe | ||||||
|  | rm ./epochcli.exe | ||||||
| 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 |  | ||||||
|   | |||||||
							
								
								
									
										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