1 Commits
1.0.6 ... 0.0.3

Author SHA1 Message Date
8149d07619 homebrew 2025-06-16 14:00:59 -07:00
12 changed files with 83 additions and 286 deletions

View File

@ -1,6 +1,8 @@
class Epochcli < Formula
desc "Updater and Launcher for Epoch"
homepage "https://git.burkey.co/eburk/epochcli/src/branch/master"
url "https://git.burkey.co/eburk/epochcli/archive/0.0.2.tar.gz"
sha256 "192ae687256a33468e97c7116177b65fb42b42a66138f61f24b93b76f4048f1c"
license "ISC"
head "https://git.burkey.co/eburk/epochcli.git", branch: "master"

View File

@ -1,43 +1,15 @@
# epochcli
CLI tool for updating and launching [Project Epoch](https://www.project-epoch.net/) on Windows, Linux and macOS.
## 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 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.
CLI tool for updating and launching [Project Epoch](https://www.project-epoch.net/) on Linux & macOS.
## 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`.
![ss](ss.png)
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. 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. Download and extract the latest binary from the [releases](https://git.burkey.co/eburk/epochcli/releases) page.
2. Run `epochcli`. You will be taken through a setup process that configures the program and creates a config file at `$HOME/.config/epochcli/config.toml`
3. You can now use `epochcli` as just a standalone updater or also a launcher based on your configuration. You can always run `epochcli -c` to redo the configuration or edit the config file manually
## Usage
```
@ -50,4 +22,4 @@ Download and extract the latest binary from the [releases](https://git.burkey.co
## 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

116
config.go
View File

@ -4,16 +4,12 @@ import (
"fmt"
"github.com/BurntSushi/toml"
"os"
"path"
"path/filepath"
"runtime"
"strings"
)
type Config struct {
WowDir string
LaunchCmd string
WinePrefix string
EnableLauncher bool
}
@ -25,11 +21,10 @@ const (
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,
@ -37,112 +32,65 @@ func setupConfig(rerun bool) (*Config, error) {
EnableLauncher: false,
}
cfgPath = filepath.Join(home, ".config", configDirName, configName)
_, statErr := os.Stat(cfgPath)
if rerun || os.IsNotExist(statErr) {
fmt.Println("Enter the path to your Wow directory below. Use the full path without shortcuts like '~' (ex: /home/user/epoch):")
fmt.Println("Enter the path to your Wow directory below:")
_, err := fmt.Scanln(&newConfig.WowDir)
if err != nil {
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.Scanln(&s)
if err != nil {
return nil, fmt.Errorf("unable to read input: %v", err)
}
newConfig.WowDir = strings.TrimSpace(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): "))
_, 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 wine %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) {
var s string
for {
fmt.Print(prompt)
_, err := fmt.Scanf("%s", &s)
if err != nil {
return false, fmt.Errorf("unable to read input: %v", err)
}
s := strings.TrimSpace(s)
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("> ")
var s string
_, err := fmt.Scanf("%s", &s)
if err != nil {
return "", fmt.Errorf("unable to read input: %v", err)
}
return strings.TrimSpace(s), 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
}

7
go.mod
View File

@ -2,9 +2,4 @@ module git.burkey.co/eburk/epochcli
go 1.24.3
require (
github.com/BurntSushi/toml v1.5.0
github.com/go-ole/go-ole v1.3.0
)
require golang.org/x/sys v0.1.0 // indirect
require github.com/BurntSushi/toml v1.5.0

4
go.sum
View File

@ -1,6 +1,2 @@
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=

57
main.go
View File

@ -16,22 +16,12 @@ import (
)
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
@ -65,9 +55,9 @@ func main() {
log.Fatal(err)
}
fmt.Printf("%d files updated\n\n", stats.updated)
fmt.Printf("%d files updated\n", stats.updated)
if stats.current > 0 {
fmt.Printf("%d files are already up to date\n\n", stats.current)
fmt.Printf("%d files are already up to date\n", stats.current)
}
if updateOnlyFlag {
@ -80,22 +70,11 @@ func main() {
}
fmt.Println("Starting Epoch...")
var cmd = strings.Split(config.LaunchCmd, " ")
if runtime.GOOS == "darwin" {
cmd = append([]string{"open"}, cmd...)
}
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()
}
}
}
@ -120,10 +99,7 @@ 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 {
@ -142,35 +118,28 @@ func downloadUpdate(config *Config, force bool) (DownloadStats, error) {
}
}
fmt.Printf("Updating %s...\n", localPath)
fmt.Printf(" %s...\n", localPath)
outFile, err := os.Create(localPath)
if err != nil {
return stats, 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
}
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 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 {
outFile.Close()
return stats, err
}
break
}
outFile.Close()
stats.updated += 1
}

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

@ -3,20 +3,20 @@
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=linux GOARCH=arm64 go build -o bin/epochcli-linux-arm64
tar czvf epochcli-linux-arm64.tar.gz bin/epochcli-linux-arm64
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
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

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.6"
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
}