9 Commits
0.0.2 ... 1.0

Author SHA1 Message Date
134d70e29b update for 1.0 2025-06-27 10:59:02 -07:00
6199f0865e add windows support 2025-06-21 09:31:23 -07:00
a24eb6903f cleanup README 2025-06-16 14:16:59 -07:00
1fc00d83eb homebrew 2025-06-16 14:11:58 -07:00
50526b78aa remove native UI elements, update README 2025-06-16 13:53:06 -07:00
656109e935 wip update 2025-06-13 10:19:33 -07:00
a281948d0b homebrew 2025-06-13 10:03:49 -07:00
ec7e63ed16 Update README.md 2025-06-10 04:56:08 +00:00
72f7a1e163 upgraded config creation 2025-06-09 21:46:35 -07:00
10 changed files with 189 additions and 79 deletions

1
.gitignore vendored
View File

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

16
Formula/epochcli.rb Normal file
View File

@ -0,0 +1,16 @@
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,23 +1,42 @@
# 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.
## Setup Instructions ## 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, the [Kegworks Wineskin port](https://github.com/Kegworks-App/Kegworks) works great (tutorial coming in the future...) ### Windows
1. Install `epochcli` by either 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.
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` ### Linux
3. Compile the source yourself A `wine` prefix with `dxvk` installed is sufficient, or you can use something like Lutris or faugus-launcher and just use `epochcli` for updating.
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 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.
### 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.
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
```
## First Run
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
## Usage ## Usage
``` ```
> ./epochcli -h > ./epochcli -h
-c Runs config configuration step. Overrides the config file -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 -h Print help
-u Ignore EnableLauncher setting in config and only runs an update. Does nothing if EnableLauncher is false -u Ignore EnableLauncher setting in config and only runs an update. Does nothing if EnableLauncher is false
``` ```

View File

@ -1,12 +1,13 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
"github.com/sqweek/dialog"
"os" "os"
"path"
"path/filepath" "path/filepath"
"runtime"
"strings"
) )
type Config struct { type Config struct {
@ -23,10 +24,11 @@ const (
var cfgPath string var 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,
@ -34,44 +36,56 @@ func setupConfig(rerun bool) (*Config, error) {
EnableLauncher: false, EnableLauncher: false,
} }
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("Press any key to open a file window and select your wow directory") fmt.Println("Enter the path to your Wow directory below:")
var r rune var s string
_, _ = fmt.Scanf("%c", &r) _, err = fmt.Scanln(&s)
var err error
newConfig.WowDir, err = dialog.Directory().Title("Select your wow directory").Browse()
if err != nil { if err != nil {
if errors.Is(err, dialog.ErrCancelled) { return nil, fmt.Errorf("unable to read input: %v", err)
return nil, fmt.Errorf("cancelled dialog box, exiting")
}
return nil, err
} }
newConfig.WowDir = strings.TrimSpace(s)
for { 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): ") 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) _, err = fmt.Scanf("%s", &s)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("unable to read input: %v", err)
} }
s = strings.TrimSpace(s)
if s == "y" || s == "Y" { if s == "y" || s == "Y" {
newConfig.EnableLauncher = true newConfig.EnableLauncher = true
if runtime.GOOS == "windows" {
newConfig.LaunchCmd = path.Join(newConfig.WowDir, "Project-Epoch.exe")
exePath, err := os.Executable()
if err != nil {
fmt.Println("unable to create desktop shortcut: ", err)
break
}
err = makeLink(exePath, path.Join(home, "Desktop", "Project-Epoch.lnk"))
if err != nil {
fmt.Println("unable to create desktop shortcut: ", err)
}
break
} 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") 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("> ") fmt.Printf("> ")
_, err = fmt.Scanf("%s", &s) _, err = fmt.Scanf("%s", &s)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("unable to read input: %v", err)
} }
s = strings.TrimSpace(s)
if s != "" { if s != "" {
newConfig.LaunchCmd = s newConfig.LaunchCmd = s
} }
break break
} }
}
if s == "n" || s == "N" { if s == "n" || s == "N" {
break break
@ -80,23 +94,26 @@ func setupConfig(rerun bool) (*Config, error) {
fmt.Println("Please enter a valid value of either 'y' or 'n'") 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 return nil, fmt.Errorf("unable to create config file: %v", err)
} }
defer file.Close() 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 return nil, fmt.Errorf("unable to encode config file: %v", err)
} }
fmt.Println("Created new config at ", cfgPath) fmt.Println("Created new config at ", cfgPath)
} }
_, err := toml.DecodeFile(cfgPath, &newConfig) _, err = toml.DecodeFile(cfgPath, &newConfig)
if err != nil { if err != nil {
return nil, err return nil, err
} }

8
config_unix.go Normal file
View 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
View 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
}

4
go.mod
View File

@ -4,7 +4,7 @@ go 1.24.3
require ( require (
github.com/BurntSushi/toml v1.5.0 github.com/BurntSushi/toml v1.5.0
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 github.com/go-ole/go-ole v1.3.0
) )
require github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect require golang.org/x/sys v0.1.0 // 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 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/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2jiKyS17BH6IrTmUBYqZa/CXT4uvb+I= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 h1:2JL2wmHXWIAxDofCK+AdkFi1KEg3dgkefCsm7isADzQ= golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

46
main.go
View File

@ -25,15 +25,18 @@ func main() {
var ( var (
helpFlag bool helpFlag bool
updateOnlyFlag bool updateOnlyFlag bool
forceFlag bool
rerunConfig bool rerunConfig bool
) )
flag.BoolVar(&helpFlag, "h", false, "Print help") 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(&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.BoolVar(&rerunConfig, "c", false, "Runs config configuration step. Overrides the config file")
flag.Parse() flag.Parse()
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)
} }
@ -47,14 +50,14 @@ 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)
} }
updated, current, err := downloadUpdate(config) stats, err := downloadUpdate(config, forceFlag)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
fmt.Printf("Updated %d files\n", updated) fmt.Printf("%d files updated\n", stats.updated)
if current > 0 { if stats.current > 0 {
fmt.Printf("%d files are already up to date\n", current) fmt.Printf("%d files are already up to date\n", stats.current)
} }
if updateOnlyFlag { if updateOnlyFlag {
@ -67,17 +70,21 @@ func main() {
} }
fmt.Println("Starting Epoch...") fmt.Println("Starting Epoch...")
switch runtime.GOOS { if runtime.GOOS == "darwin" {
case "darwin":
exec.Command("open", config.LaunchCmd).Run() exec.Command("open", config.LaunchCmd).Run()
case "linux": } else {
exec.Command(config.LaunchCmd).Run() exec.Command(config.LaunchCmd).Run()
} }
} }
} }
func downloadUpdate(config *Config) (int, int, error) { type DownloadStats struct {
var updateCount, currentCount int updated int
current int
}
func downloadUpdate(config *Config, force bool) (DownloadStats, error) {
var stats DownloadStats
manifest, err := getManifest() manifest, err := getManifest()
if err != nil { if err != nil {
@ -94,43 +101,46 @@ func downloadUpdate(config *Config) (int, int, error) {
os.MkdirAll(localDir, 0755) os.MkdirAll(localDir, 0755)
} }
if !force {
if _, err = os.Stat(localPath); err == nil { if _, err = os.Stat(localPath); err == nil {
data, err := os.ReadFile(localPath) data, err := os.ReadFile(localPath)
if err != nil { if err != nil {
return updateCount, currentCount, err return stats, err
} }
hashBytes := md5.Sum(data) hashBytes := md5.Sum(data)
hash := hex.EncodeToString(hashBytes[:]) hash := hex.EncodeToString(hashBytes[:])
if hash == file.Hash { if hash == file.Hash {
currentCount += 1 fmt.Printf("File %s is up to date\n", localPath)
stats.current += 1
continue continue
} }
} }
}
fmt.Printf("Updating %s...\n", file.Path) fmt.Printf(" %s...\n", localPath)
outFile, err := os.Create(localPath) outFile, err := os.Create(localPath)
if err != nil { if err != nil {
return updateCount, currentCount, err return stats, err
} }
defer outFile.Close() defer outFile.Close()
resp, err := http.Get(file.URL) resp, err := http.Get(file.URL)
if err != nil { if err != nil {
return updateCount, currentCount, err return stats, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return updateCount, currentCount, fmt.Errorf("failed to download update from %s, status code: %d", file.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) _, err = io.Copy(outFile, resp.Body)
if err != nil { if err != nil {
return updateCount, currentCount, err return stats, err
} }
updateCount += 1 stats.updated += 1
} }
return updateCount, currentCount, nil return stats, nil
} }

View File

@ -1,4 +1,4 @@
#!/usr/bin/env sh #!/usr/bin/env bash
set -e set -e
@ -10,10 +10,13 @@ mkdir bin
GOOS=linux GOARCH=amd64 go build -o bin/epochcli-linux-amd64 GOOS=linux GOARCH=amd64 go build -o bin/epochcli-linux-amd64
tar czvf epochcli-linux-amd64.tar.gz bin/epochcli-linux-amd64 tar czvf epochcli-linux-amd64.tar.gz bin/epochcli-linux-amd64
GOOS=darwin GOARCH=amd64 go build -o bin/epochcli-darwin-amd64 GOOS=linux GOARCH=arm64 go build -o bin/epochcli-linux-arm64
tar czvf epochcli-darwin-amd64.tar.gz bin/epochcli-darwin-amd64 tar czvf epochcli-linux-arm64.tar.gz bin/epochcli-linux-arm64
GOOS=darwin GOARCH=arm64 go build -o bin/epochcli-darwin-arm64 GOOS=windows GOARCH=amd64 go build -o bin/epochcli-windows-amd64
tar czvf epochcli-darwin-arm64.tar.gz bin/epochcli-darwin-arm64 zip epochcli-windows-amd64.zip bin/epochcli-windows-amd64
GOOS=windows GOARCH=arm64 go build -o bin/epochcli-windows-arm64
zip epochcli-windows-arm64.zip bin/epochcli-windows-arm64
rm -rf bin rm -rf bin