1 Commits
1.0.7 ... 0.0.2

Author SHA1 Message Date
3ee6b1dce1 upgraded config creation 2025-06-09 21:38:39 -07:00
13 changed files with 116 additions and 343 deletions

1
.gitignore vendored
View File

@ -1,5 +1,4 @@
.idea
bin/
epochcli-*
epochcli
*.tar.gz

View File

@ -1,16 +0,0 @@
class Epochcli < Formula
desc "Updater and Launcher for Epoch"
homepage "https://git.burkey.co/eburk/epochcli/src/branch/master"
license "ISC"
head "https://git.burkey.co/eburk/epochcli.git", branch: "master"
depends_on "go" => :build
def install
system "go", "build", *std_go_args(ldflags: "-s -w")
end
test do
assert_match "Epochcli Help:", shell_output("#{bin}/epochcli -h")
end
end

View File

@ -1,53 +1,27 @@
# epochcli
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 Linux & macOS.
## Installing
## Setup Instructions
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`
### Linux
For macOS, the [Kegworks Wineskin port](https://github.com/Kegworks-App/Kegworks) works great (tutorial coming in the future...)
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 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. I currently use a Parallels VM and run the Windows version of epochcli inside the VM as an updater and launcher.
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
![ss](ss.png)
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
1. Install `epochcli` by either
1. Downloading the latest binary from the [releases](https://git.burkey.co/eburk/epochcli/releases) page
2. If you have the `go` toolchain installed, you can run `go install git.burkey.co/eburk/epochcli` to install to your `$GOROOT`
3. Compile the source yourself
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
## Usage
```
> ./epochcli -h
-c Runs config configuration step. Overrides the config file
-f Forces epochcli to update files even if they match the current version
-h Print help
-u Ignore EnableLauncher setting in config and only runs an update. Does nothing if EnableLauncher is false
```
## Issues
If you have any issues, [email me](mailto:epochcli@burkey.co) or send a private message to `Battlehammer` on the Epoch discord
If you have any issues, [email me](mailto:evan@burkey.co) or ping `Battlehammer` on the Epoch discord

137
config.go
View File

@ -1,20 +1,17 @@
package main
import (
"bufio"
"errors"
"fmt"
"github.com/BurntSushi/toml"
"github.com/sqweek/dialog"
"os"
"path"
"path/filepath"
"runtime"
"strings"
)
type Config struct {
WowDir string
LaunchCmd string
WinePrefix string
EnableLauncher bool
}
@ -23,17 +20,13 @@ const (
configName = "config.toml"
)
var (
configRun = false
cfgPath string
)
var cfgPath string
func setupConfig(rerun bool) (*Config, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("unable to determine home directory: %v", err)
home := os.Getenv("HOME")
if home == "" {
return nil, fmt.Errorf("$HOME environment variable not set")
}
cfgPath = filepath.Join(home, ".config", configDirName, configName)
newConfig := Config{
WowDir: defaultWowPath,
@ -41,118 +34,72 @@ func setupConfig(rerun bool) (*Config, error) {
EnableLauncher: false,
}
cfgPath = filepath.Join(home, ".config", configDirName, configName)
_, statErr := os.Stat(cfgPath)
if rerun || os.IsNotExist(statErr) {
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("Press any key to open a file window and select your wow directory")
var r rune
_, _ = fmt.Scanf("%c", &r)
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): "))
var err error
newConfig.WowDir, err = dialog.Directory().Title("Select your wow directory").Browse()
if err != nil {
if errors.Is(err, dialog.ErrCancelled) {
return nil, fmt.Errorf("cancelled dialog box, exiting")
}
return nil, err
}
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
}
fmt.Println()
if p {
if s == "y" || s == "Y" {
newConfig.EnableLauncher = true
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")
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)
if err != nil {
return nil, err
}
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)
if s != "" {
newConfig.LaunchCmd = s
}
break
}
err = os.MkdirAll(filepath.Join(home, ".config", configDirName), 0755)
if err != nil {
return nil, fmt.Errorf("unable to create config directory: %v", err)
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)
file, err := os.Create(cfgPath)
if err != nil {
return nil, fmt.Errorf("unable to create config file: %v", err)
return nil, 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 nil, err
}
fmt.Printf("Created new config at %s\n\n", cfgPath)
fmt.Println("Created new config at ", cfgPath)
}
_, err = toml.DecodeFile(cfgPath, &newConfig)
_, err := toml.DecodeFile(cfgPath, &newConfig)
if err != nil {
return nil, 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
}

View File

@ -1,8 +0,0 @@
//go:build linux || darwin
package main
func makeLink(src, dst string) error {
// Noop on unix
return nil
}

View File

@ -1,36 +0,0 @@
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
}

4
go.mod
View File

@ -4,7 +4,7 @@ go 1.24.3
require (
github.com/BurntSushi/toml v1.5.0
github.com/go-ole/go-ole v1.3.0
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627
)
require golang.org/x/sys v0.1.0 // indirect
require github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect

8
go.sum
View File

@ -1,6 +1,6 @@
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/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=
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2jiKyS17BH6IrTmUBYqZa/CXT4uvb+I=
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I=
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 h1:2JL2wmHXWIAxDofCK+AdkFi1KEg3dgkefCsm7isADzQ=
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw=

93
main.go
View File

@ -11,41 +11,29 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
const (
manifestUrl = "https://updater.project-epoch.net/api/v2/manifest"
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")
os.Exit(1)
}
var (
helpFlag bool
updateOnlyFlag bool
forceFlag bool
rerunConfig bool
)
flag.BoolVar(&helpFlag, "h", false, "Print help")
flag.BoolVar(&forceFlag, "f", false, "Forces epochcli to update files even if they match the current version")
flag.BoolVar(&updateOnlyFlag, "u", false, "Ignore EnableLauncher setting in config and only runs an update. Does nothing if EnableLauncher is false")
flag.BoolVar(&rerunConfig, "c", false, "Runs config configuration step. Overrides the config file")
flag.Parse()
if helpFlag {
flag.CommandLine.SetOutput(os.Stdout)
fmt.Println("Epochcli Help:")
flag.PrintDefaults()
os.Exit(0)
}
@ -59,19 +47,14 @@ func main() {
log.Fatalf("WowDir in %s is still the default setting, exiting", cfgPath)
}
stats, err := downloadUpdate(config, forceFlag)
updated, current, err := downloadUpdate(config)
if err != nil {
log.Fatal(err)
}
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)
fmt.Printf("Updated %d files\n", updated)
if current > 0 {
fmt.Printf("%d files are already up to date\n", current)
}
if updateOnlyFlag {
@ -84,30 +67,17 @@ func main() {
}
fmt.Println("Starting Epoch...")
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
}
fmt.Println("Running command:", cmdStr)
err = ex.Run()
if err != nil {
log.Fatal(err)
switch runtime.GOOS {
case "darwin":
exec.Command("open", config.LaunchCmd).Run()
case "linux":
exec.Command(config.LaunchCmd).Run()
}
}
}
type DownloadStats struct {
updated int
current int
}
func downloadUpdate(config *Config, force bool) (DownloadStats, error) {
var stats DownloadStats
func downloadUpdate(config *Config) (int, int, error) {
var updateCount, currentCount int
manifest, err := getManifest()
if err != nil {
@ -121,59 +91,46 @@ func downloadUpdate(config *Config, force bool) (DownloadStats, error) {
localPath := filepath.Join(config.WowDir, path)
localDir := filepath.Dir(localPath)
if _, err = os.Stat(localDir); os.IsNotExist(err) {
err = os.MkdirAll(localDir, 0755)
if err != nil {
return stats, fmt.Errorf("failed to create directory %s: %v", localDir, 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
return updateCount, currentCount, 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
currentCount += 1
continue
}
}
}
fmt.Printf("Updating %s...\n", localPath)
fmt.Printf("Updating %s...\n", file.Path)
outFile, err := os.Create(localPath)
if err != nil {
return stats, err
return updateCount, currentCount, err
}
defer outFile.Close()
for _, url := range []string{file.Urls.Cloudflare, file.Urls.Digitalocean, file.Urls.None} {
resp, err := http.Get(url)
resp, err := http.Get(file.URL)
if err != nil {
outFile.Close()
return stats, err
return updateCount, currentCount, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
outFile.Close()
return stats, fmt.Errorf("failed to download update from %s, status code: %d", url, resp.StatusCode)
return updateCount, currentCount, fmt.Errorf("failed to download update from %s, status code: %d", file.URL, resp.StatusCode)
}
_, err = io.Copy(outFile, resp.Body)
if err != nil {
outFile.Close()
return stats, err
return updateCount, currentCount, err
}
break
updateCount += 1
}
outFile.Close()
stats.updated += 1
}
return stats, nil
return updateCount, currentCount, nil
}

View File

@ -11,18 +11,13 @@ type File struct {
Hash string `json:"Hash"`
Size int `json:"Size"`
Custom bool `json:"Custom"`
Urls struct {
Digitalocean string `json:"digitalocean"`
Cloudflare string `json:"cloudflare"`
None string `json:"none"`
} `json:"Urls"`
URL string `json:"URL"`
Origin string `json:"Origin"`
}
type Manifest struct {
Version string `json:"Version"`
UID string `json:"Uid"`
Files []File `json:"Files"`
CheckedAt string `json:"checked_at"`
}
func getManifest() (*Manifest, error) {

View File

@ -1,22 +1,19 @@
#!/usr/bin/env bash
#!/usr/bin/env sh
set -e
rm -f ./*.tar.gz
rm -f ./*.zip
rm -rf bin
GOOS=linux GOARCH=amd64 go build -o epochcli
tar czvf epochcli-linux-amd64.tar.gz epochcli
rm ./epochcli
mkdir bin
GOOS=linux GOARCH=arm64 go build -o epochcli
tar czvf epochcli-linux-arm64.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=windows GOARCH=amd64 go build -o epochcli.exe
zip -j epochcli-windows-amd64.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
GOOS=windows GOARCH=arm64 go build -o epochcli.exe
zip -j epochcli-windows-arm64.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
rm -rf bin

BIN
ss.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

View File

@ -1,36 +0,0 @@
package main
import (
"fmt"
"io"
"net/http"
"regexp"
)
const version = "1.0.7"
const 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(`const version = "([\d.]+)"`)
ver := re.FindStringSubmatch(string(b))
if len(ver) < 2 || ver[1] == "" {
return false, fmt.Errorf("unable to find version in response")
}
return ver[1] != version, nil
}