diff --git a/README.md b/README.md index ead0dc7..eb1011e 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,21 @@ This TUI utility provides an easy way to copy addon settings, keybinds, and macr It does not currently copy client settings (graphics, sound levels, etc) between versions of the game. +## Backups + +This software overwrites large amounts of configuration data automatically. There is no "undo". For that reason, before you copy profiles around, you should consider taking a backup. + +To do that, simply copy the entire `WTF` folder in the version folder that you're going to be copying to (e.g. `_classic_ptr_`) to somewhere safe before running the tool. + # FAQ ## My keybinds aren't copying correctly! Disable keybind synchronization. -When logged in, put this in the chat window: +Log into the game version that configs are being copied to. + +Type this in the chat window: ``` /console synchronizeBindings 0 ``` @@ -21,8 +29,10 @@ Open the game, and run: ``` /console synchronizeBindings 1 ``` +In the chat window. + Change *any* key binding, and apply the changes - you can change it back later. This "tricks" the WoW client into accepting the new keybindings, and saving them to Blizzard's servers. Otherwise, it sees that the keybindings for the account don't match the ones saved on the server, and "helpfully" changes them. -Leaving `synchronizeBindings` turned off entirely also solves the issue. \ No newline at end of file +Leaving `synchronizeBindings` turned off entirely also solves the issue. diff --git a/go.sum b/go.sum index 2660da9..cd8cf37 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +atomicgo.dev/assert v0.0.2 h1:FiKeMiZSgRrZsPo9qn/7vmr7mCsh5SZyXY4YGYiYwrg= atomicgo.dev/cursor v0.1.1 h1:0t9sxQomCTRh5ug+hAMCs59x/UmC9QL6Ci5uosINKD4= atomicgo.dev/cursor v0.1.1/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU= atomicgo.dev/keyboard v0.2.8 h1:Di09BitwZgdTV1hPyX/b9Cqxi8HVuJQwWivnZUEqlj4= @@ -9,10 +10,12 @@ github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSr github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI= github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c= github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= +github.com/MarvinJWendt/testza v0.5.1 h1:a9Fqx6vQrHQ4CyiaLhktfTTelwGotmFWy8MNhyaohw8= github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= @@ -21,6 +24,7 @@ github.com/gookit/color v1.5.2/go.mod h1:w8h4bGiHeeBpvQVePTutdbERIUf3oJE5lZ8HM0U github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= +github.com/klauspost/cpuid/v2 v2.2.0 h1:4ZexSFt8agMNzNisrsilL6RClWDC5YJnLHNIfTy4iuc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= @@ -29,6 +33,7 @@ github.com/lithammer/fuzzysearch v1.1.5/go.mod h1:1R1LRNk7yKid1BaQkmuLQaHruxcC4H github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg= @@ -41,6 +46,7 @@ github.com/pterm/pterm v0.12.50 h1:53nKg5lLI1kXkvLWq2IQI5rgkPkFzEQsuQjxAb39VlE= github.com/pterm/pterm v0.12.50/go.mod h1:79BLm4vos2z+eOoHnDG7ZWuYtLaSStyaspKjGmSoxc4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -48,6 +54,7 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= @@ -73,4 +80,5 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..5ad7ad9 --- /dev/null +++ b/helpers.go @@ -0,0 +1,97 @@ +package main + +import ( + "errors" + "github.com/pterm/pterm" + "io" + "os" + "path/filepath" +) + +// deduplicates slices by throwing them into a map +// not mine, credit to @kylewbanks +func deduplicateStringSlice(input []string) []string { + u := make([]string, 0, len(input)) + m := make(map[string]bool) + for _, val := range input { + if _, ok := m[val]; !ok { + m[val] = true + u = append(u, val) + } + } + return u +} + +// small wrapper around os and io to copy files from source to destination +func copyFile(src string, dest string) (bytes int64, err error) { + if src == dest { + return -1, errors.New("source and destination are the same") + } + srcFileHandle, err := os.Open(src) + if err != nil { + return -1, err + } + defer srcFileHandle.Close() + + dstFileHandle, err := os.Create(dest) + if err != nil { + return -1, err + } + defer dstFileHandle.Close() + + bytes, err = io.Copy(dstFileHandle, srcFileHandle) + return bytes, err +} + +// determines if a given path appears to contain a WoW install +func isWowInstallDirectory(dir string) bool { + var isInstallDir = false + + files, err := os.ReadDir(dir) + if err != nil { + // directory probably doesn't exist + return false + } + + for _, file := range files { + // if this directory contains a wow instance folder name, it's probably where WoW is installed + _, matchesInstanceName := _wowInstanceFolderNames[file.Name()] + if file.IsDir() && matchesInstanceName { + isInstallDir = true + break + } + } + + return isInstallDir +} + +func promptForWowDirectory(dir string) (wowDir string, err error) { + files, err := os.ReadDir(dir) + if err != nil { + return "", err + } + + var fileChoices = []string{".. (go back)"} + + for _, file := range files { + fileChoices = append(fileChoices, file.Name()) + } + + selectedFile, _ := pterm.DefaultInteractiveSelect. + WithOptions(fileChoices). + WithDefaultText("Select a WoW Install directory"). + WithMaxHeight(15). + Show() + var fullSelectedPath string + if selectedFile == ".. (go back)" { + fullSelectedPath = filepath.Clean(filepath.Join(dir, "..")) + } else { + fullSelectedPath = filepath.Join(dir, selectedFile) + } + isWowDir := isWowInstallDirectory(fullSelectedPath) + if !isWowDir { + return promptForWowDirectory(fullSelectedPath) + } else { + return fullSelectedPath, nil + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..e9fb6f2 --- /dev/null +++ b/main.go @@ -0,0 +1,252 @@ +package main + +import ( + "fmt" + "github.com/pterm/pterm" + "log" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + // "github.com/pterm/pterm/putils" +) + +type Wtf struct { + account string + server string + character string +} + +type CopyTarget struct { + wtf Wtf + version string +} + +// smelly? +var _wowInstanceFolderNames = map[string]string{ + "_classic_": "Classic", + "_classic_ptr_": "Classic PTR", + "_classic_beta_": "Classic Beta", + "_classic_era_": "Classic Era", + "_classic_era_ptr": "Classic Era PTR", + "_retail_": "Retail", + "_ptr_": "Retail PTR", + "_xptr_": "Retail PTR 2", +} + +var _probableWowInstallLocations = map[string]string{ + "darwin": "/Applications/World of Warcraft", + "windows": "C:\\Program Files (x86)\\World of Warcraft", +} + +func main() { + var wow WowInstall + + userHomeDir, err := os.UserHomeDir() + if err != nil { + log.Fatal(err) + } + + pterm.DefaultHeader.WithFullWidth().Println("wow-profile-copy 0.3.0") + + // + // prompts + // + + _probableWowInstallLocations["linux"] = fmt.Sprintf("%s/.var/app/com.usebottles.bottles/data/bottles/bottles/WoW/drive_c/Program Files (x86)/World of Warcraft", userHomeDir) + + // this will crash when not on linux, macOS, or windows + // if you're trying to run wow on BSD or plan9, you can probably fix this yourself + installLocation := _probableWowInstallLocations[runtime.GOOS] + base := "/" + + dirOk := isWowInstallDirectory(installLocation) + if !dirOk { + if runtime.GOOS == "windows" { + baseInput, _ := pterm.DefaultInteractiveTextInput. + WithDefaultText("Which drive is WoW located on? e.g. C, D"). + Show() + base = fmt.Sprintf("%s:\\", string(baseInput[0])) + } + installLocation, _ = promptForWowDirectory(base) + } + + pterm.Success.Printfln("Found WoW install. Location: %s", installLocation) + + dirConfirm, _ := pterm.DefaultInteractiveConfirm. + WithDefaultText("Is this directory correct?"). + WithDefaultValue(true). + Show() + if !dirConfirm { + installLocation, _ = promptForWowDirectory(base) + } + + wow.installDirectory = installLocation + wow.findAvailableVersions(installLocation) + + pterm.DefaultHeader.Printfln("WoW Install Directory: %s", wow.installDirectory) + + pterm.Info.Println("First, pick the Version, Account, Server, and Character to copy configuration data from.") + srcConfig := wow.selectWtf(true) + pterm.Info.Println("Next, pick the Version, Account, Server, and Character to apply that configuration data to.") + dstConfig := wow.selectWtf(false) + + pterm.Info.Printfln( + "Source: { Version: %s, Account: %s, Server: %s, Character: %s }", + _wowInstanceFolderNames[srcConfig.version], + srcConfig.wtf.account, + srcConfig.wtf.server, + srcConfig.wtf.character) + pterm.Info.Printfln( + "Destination: { Version: %s, Account :%s, Server: %s, Character: %s }", + _wowInstanceFolderNames[dstConfig.version], + dstConfig.wtf.account, + dstConfig.wtf.server, + dstConfig.wtf.character) + + pterm.DefaultHeader. + WithFullWidth(). + WithBackgroundStyle(pterm.NewStyle(pterm.BgRed)). + WithTextStyle(pterm.NewStyle(pterm.FgBlack)). + Println("Take a backup of the relevant WTF folder(s) - This operation can cause data loss!") + + confirmation, _ := pterm.DefaultInteractiveConfirm. + WithTextStyle(&pterm.ThemeDefault.ErrorMessageStyle). + WithDefaultText(fmt.Sprintf("Overwrite %s-%s's Keybindings, Macros, and SavedVariables?", dstConfig.wtf.character, dstConfig.wtf.server)). + Show() + if !confirmation { + os.Exit(1) + } + + // + // copying process + // + + hasProblems := false + srcWtfAccountPath := filepath.Join(wow.installDirectory, srcConfig.version, "WTF", "Account", srcConfig.wtf.account) + dstWtfAccountPath := filepath.Join(wow.installDirectory, dstConfig.version, "WTF", "Account", dstConfig.wtf.account) + svFileRegex := regexp.MustCompile(`.*\.lua$`) + + // + // account-level + // + + if srcConfig.wtf.account == dstConfig.wtf.account { + pterm.Warning.Println("Skipping account-level copying - accounts are the same.") + } else { + // client configuration + accountFilesToCopy := [3]string{"bindings-cache.wtf", "config-cache.wtf", "macros-cache.txt"} + + for _, file := range accountFilesToCopy { + src := filepath.Join(srcWtfAccountPath, file) + dst := filepath.Join(dstWtfAccountPath, file) + _, err := copyFile(src, dst) + if err != nil { + pterm.Warning.Printfln("Not copying %s: %s", file, err) + hasProblems = true + } else { + pterm.Info.Printfln("Copied %s", src) + } + } + + // saved variables + + accountSavedVariablesFiles, err := os.ReadDir(filepath.Join(srcWtfAccountPath, "SavedVariables")) + if err != nil { + log.Fatal(err) + } + + for _, file := range accountSavedVariablesFiles { + if svFileRegex.MatchString(file.Name()) { + src := filepath.Join(srcWtfAccountPath, "SavedVariables", file.Name()) + dst := filepath.Join(dstWtfAccountPath, "SavedVariables", file.Name()) + _, err := copyFile(src, dst) + if err != nil { + pterm.Warning.Printfln("Not copying %s: %s", file, err) + hasProblems = true + } else { + pterm.Info.Printfln("Copied %s", src) + } + } + } + } + + // + // character-level client configuration + // + + srcWtfCharacterPath := filepath.Join(srcWtfAccountPath, srcConfig.wtf.server, srcConfig.wtf.character) + dstWtfCharacterPath := filepath.Join(dstWtfAccountPath, dstConfig.wtf.server, dstConfig.wtf.character) + + characterFilesToCopy := [4]string{"AddOns.txt", "config-cache.wtf", "layout-local.txt", "macros-cache.txt"} + + for _, file := range characterFilesToCopy { + src := filepath.Join(srcWtfCharacterPath, file) + dst := filepath.Join(dstWtfCharacterPath, file) + _, err := copyFile(src, dst) + if err != nil { + pterm.Warning.Printfln("Not copying %s: %s", file, err) + hasProblems = true + } else { + pterm.Info.Printfln("Copied %s", src) + } + } + + // + // character-level saved variables + // + + charSavedVariablesFiles, err := os.ReadDir(filepath.Join(srcWtfCharacterPath, "SavedVariables")) + if err != nil { + log.Fatal(err) + } + + for _, file := range charSavedVariablesFiles { + if svFileRegex.MatchString(file.Name()) { + src := filepath.Join(srcWtfCharacterPath, "SavedVariables", file.Name()) + dst := filepath.Join(dstWtfCharacterPath, "SavedVariables", file.Name()) + _, err := copyFile(src, dst) + if err != nil { + pterm.Warning.Printfln("Not copying %s: %s", file, err) + hasProblems = true + } else { + pterm.Info.Printfln("Copied %s", src) + } + } + } + + // + // clean up + // + + dstAccountCache := filepath.Join(dstWtfAccountPath, "cache.md5") + err = os.Remove(dstAccountCache) + if err != nil { + if !strings.Contains(err.Error(), "no such file or directory") { + log.Fatal(err) + } + } + + pterm.Info.Printfln("Removed %s", dstAccountCache) + + dstCharacterCache := filepath.Join(dstWtfCharacterPath, "cache.md5") + err = os.Remove(dstCharacterCache) + if err != nil { + if !strings.Contains(err.Error(), "no such file or directory") { + log.Fatal(err) + } + } + + pterm.Info.Printfln("Removed %s", dstCharacterCache) + if !hasProblems { + pterm.Success.Println("Profile copying completed without issues!") + } else { + pterm.Warning.Println("Profile copying completed with warnings. See above.") + } + + if runtime.GOOS == "windows" { + fmt.Println("Press Enter to continue...") + fmt.Scanln() + } +} diff --git a/wow-profile-copy.go b/wow-profile-copy.go deleted file mode 100644 index 2a4b19f..0000000 --- a/wow-profile-copy.go +++ /dev/null @@ -1,440 +0,0 @@ -package main - -import ( - "fmt" - "os" - "io" - "log" - "runtime" - "regexp" - "path/filepath" - "github.com/pterm/pterm" - "strings" - // "github.com/pterm/pterm/putils" -) - -type WowInstall struct { - availableVersions []string - installDirectory string -} - -type Wtf struct { - account string - server string - character string -} - -type CopyTarget struct { - wtf Wtf - version string -} - -// smelly? -var _wowInstanceFolderNames = map[string]string{ - "_classic_": "WoTLK Classic", - "_classic_ptr_": "WoTLK Classic PTR", - "_classic_beta_": "WoTLK Classic Beta", - "_retail_": "Retail", - } - -var _probableWowInstallLocations = map[string]string{ - "darwin": "/Applications/World of Warcraft", - "windows": "C:\\Program Files (x86)\\World of Warcraft", - } - - -// -// -// WoWInstall methods -// -// - -// Finds all valid WTF configs (account, server, character) for a given WoW version -func (wow WowInstall) getWtfConfigurations(version string) []Wtf { - var configurations []Wtf - - wtfPath := filepath.Join(wow.installDirectory, version, "WTF", "Account") // a fitting name - - // enumerate available accounts on this instance - wtfFiles, err := os.ReadDir(wtfPath) - if err != nil { - log.Fatal(err) - } - - // search all directories in WTF/Account - for _, acct := range wtfFiles { - if acct.IsDir() && acct.Name() != "SavedVariables" { - accountPath := filepath.Join(wtfPath, acct.Name()) - serverFiles, err := os.ReadDir(accountPath) // enumerate available servers under each account - if err != nil { - log.Fatal(err) - } - for _, server := range serverFiles { - if server.IsDir() && server.Name() != "SavedVariables" { // assume that any folder that isn't SavedVariables here is a realm - serverPath := filepath.Join(accountPath, server.Name()) - characterFiles, err := os.ReadDir(serverPath) - if err != nil { - log.Fatal(err) - } - for _, character := range characterFiles { // any subdirectories of the server directories are characters, they have arbitrary names - if character.IsDir() { - finalWtf := Wtf{ - account: acct.Name(), - server: server.Name(), - character: character.Name(), - } - configurations = append(configurations, finalWtf) - } - } - } - } - } - } - return configurations -} - -// determines which WoW versions are available in a given WoW install directory (classic, retail, SoM, etc..) -func (wow *WowInstall) findAvailableVersions(dir string) { - files, err := os.ReadDir(dir) - if err != nil { - log.Fatal(err) - } - - for _, file := range files { - // if this directory contains a wow instance folder name, it's probably where WoW is installed - _, matchesInstanceName := _wowInstanceFolderNames[file.Name()] - if file.IsDir() && matchesInstanceName { - wow.availableVersions = append(wow.availableVersions, file.Name()) - } - } -} - -// prompts the user to select a WTF tuple to copy to/from -// isSource: whether we are selecting the source of the copy or the destination -func (wow WowInstall) selectWtf(isSource bool) CopyTarget { - var preposition = "to" - if isSource { - preposition = "from" - } - - var versions []string - - for _, version := range wow.availableVersions { - versions = append(versions, version) - } - - wowVersion, _ := pterm.DefaultInteractiveSelect. - WithOptions(versions). - WithDefaultText(fmt.Sprintf("WoW Version to copy %s", preposition)). - Show() - pterm.Debug.Printfln("chose %s", wowVersion) - - wtfConfigs := wow.getWtfConfigurations(wowVersion) - if len(wtfConfigs) == 0 { - pterm.Error.Printfln("No valid WTF configurations found in %s. Try logging into a character on this version of the client, first!", wowVersion) - if runtime.GOOS == "windows" { - // make windows users feel at home - fmt.Println("Press Enter to continue...") - fmt.Scanln() - } - os.Exit(1) - } - - var accountOptions []string - for _, wtf := range wtfConfigs { - accountOptions = append(accountOptions, wtf.account) - } - accountOptions = deduplicateStringSlice(accountOptions) - - chosenAccount, _ := pterm.DefaultInteractiveSelect. - WithOptions(accountOptions). - WithDefaultText(fmt.Sprintf("Account to copy %s", preposition)). - Show() - pterm.Debug.Printfln("chose %s", chosenAccount) - - var serverOptions []string - for _, wtf := range wtfConfigs { - if wtf.account == chosenAccount { - serverOptions = append(serverOptions, wtf.server) - } - } - serverOptions = deduplicateStringSlice(serverOptions) - - chosenServer, _ := pterm.DefaultInteractiveSelect. - WithOptions(serverOptions). - WithDefaultText(fmt.Sprintf("Server to copy %s", preposition)). - Show() - pterm.Debug.Printfln("chose %s", chosenServer) - - var characterOptions []string - for _, wtf := range wtfConfigs { - if wtf.account == chosenAccount && wtf.server == chosenServer { - characterOptions = append(characterOptions, wtf.character) - } - } - - chosenCharacter, _ := pterm.DefaultInteractiveSelect. - WithOptions(characterOptions). - WithDefaultText(fmt.Sprintf("Character to copy %s", preposition)). - Show() - pterm.Debug.Printfln("chose %s", chosenCharacter) - - return CopyTarget{ - wtf: Wtf{ - account: chosenAccount, - server: chosenServer, - character: chosenCharacter, - }, - version: wowVersion, - } -} - -// -// -// helper functions -// -// - -// determines if a given path appears to contain a WoW install -func isWowInstallDirectory(dir string) bool { - var isInstallDir = false - - files, err := os.ReadDir(dir) - if err != nil { - // directory probably doesn't exist - return false - } - - for _, file := range files { - // if this directory contains a wow instance folder name, it's probably where WoW is installed - _, matchesInstanceName := _wowInstanceFolderNames[file.Name()] - if file.IsDir() && matchesInstanceName { - isInstallDir = true - break - } - } - - return isInstallDir -} - -func promptForWowDirectory(dir string) (wowDir string, err error) { - files, err := os.ReadDir(dir) - if err != nil { - return "", err - } - - var fileChoices = []string{".. (go back)"} - - for _, file := range files { - fileChoices = append(fileChoices, file.Name()) - } - - selectedFile, _ := pterm.DefaultInteractiveSelect. - WithOptions(fileChoices). - WithDefaultText("Select a WoW Install directory"). - WithMaxHeight(15). - Show() - var fullSelectedPath string - if selectedFile == ".. (go back)" { - fullSelectedPath = filepath.Clean(filepath.Join(dir, "..")) - } else { - fullSelectedPath = filepath.Join(dir, selectedFile) - } - isWowDir := isWowInstallDirectory(fullSelectedPath) - if !isWowDir { - return promptForWowDirectory(fullSelectedPath) - } else { - return fullSelectedPath, nil - } -} - -// deduplicates slices by throwing them into a map -// not mine, credit to @kylewbanks -func deduplicateStringSlice(input []string) []string { - u := make([]string, 0, len(input)) - m := make(map[string]bool) - for _, val := range input { - if _, ok := m[val]; !ok { - m[val] = true - u = append(u, val) - } - } - return u -} - -// small wrapper around os and io to copy files from source to destination -func copyFile(src string, dest string) (bytes int64, err error) { - srcFileHandle, err := os.Open(src) - if err != nil { - return -1, err - } - defer srcFileHandle.Close() - - dstFileHandle, err := os.Create(dest) - if err != nil { - return -1, err - } - defer dstFileHandle.Close() - - bytes, err = io.Copy(dstFileHandle, srcFileHandle) - return bytes, err -} - -func main() { - var wow WowInstall - - userHomeDir, err := os.UserHomeDir() - if err != nil { - log.Fatal(err) - } - - _probableWowInstallLocations["linux"] = fmt.Sprintf("%s/.var/app/com.usebottles.bottles/data/bottles/bottles/WoW/drive_c/Program Files (x86)/World of Warcraft", userHomeDir) - - // this will crash when not on linux, macOS, or windows - // if you're trying to run wow on BSD or plan9, you can probably fix this yourself - installLocation := _probableWowInstallLocations[runtime.GOOS] - - dirOk := isWowInstallDirectory(installLocation); - if !dirOk { - base := "/" - if runtime.GOOS == "windows" { - baseInput, _ := pterm.DefaultInteractiveTextInput. - WithDefaultText("Which drive is WoW located on? e.g. C, D"). - Show() - base = fmt.Sprintf("%s:\\", string(baseInput[0])) - } - installLocation, _ = promptForWowDirectory(base); - } - - wow.installDirectory = installLocation - wow.findAvailableVersions(installLocation) - - pterm.DefaultHeader.Printfln("WoW Install Directory: %s", wow.installDirectory) - - pterm.Info.Println("First, pick the Version, Account, Server, and Character to copy configuration data from.") - srcConfig := wow.selectWtf(true) - pterm.Info.Println("Next, pick the Version, Account, Server, and Character to apply that configuration data to.") - dstConfig := wow.selectWtf(false) - - pterm.Info.Printfln("Source: { Version: %s, Account: %s, Server: %s, Character: %s }", _wowInstanceFolderNames[srcConfig.version], srcConfig.wtf.account, srcConfig.wtf.server, srcConfig.wtf.character) - pterm.Info.Printfln("Destination: { Version: %s, Account :%s, Server: %s, Character: %s }", _wowInstanceFolderNames[dstConfig.version], dstConfig.wtf.account, dstConfig.wtf.server, dstConfig.wtf.character) - - confirmation, _ := pterm.DefaultInteractiveConfirm. - WithTextStyle(&pterm.ThemeDefault.WarningMessageStyle). - WithDefaultText(fmt.Sprintf("Overwrite %s-%s's Keybindings, Macros, and SavedVariables?\nThis can cause data loss - make a backup if unsure!", dstConfig.wtf.character, dstConfig.wtf.server)). - Show() - if !confirmation { - os.Exit(1) - } - - // - // account-level client configuration - // - - srcWtfAccountPath := filepath.Join(wow.installDirectory, srcConfig.version, "WTF", "Account", srcConfig.wtf.account) - dstWtfAccountPath := filepath.Join(wow.installDirectory, dstConfig.version, "WTF", "Account", dstConfig.wtf.account) - - accountFilesToCopy := [3]string{"bindings-cache.wtf", "config-cache.wtf", "macros-cache.txt"} - - for _, file := range accountFilesToCopy { - src := filepath.Join(srcWtfAccountPath, file) - dst := filepath.Join(dstWtfAccountPath, file) - _, err := copyFile(src, dst) - if err != nil { - log.Fatal(err) - } - pterm.Info.Printfln("Copied %s", src) - } - - // - // character-level client configuration - // - - srcWtfCharacterPath := filepath.Join(srcWtfAccountPath, srcConfig.wtf.server, srcConfig.wtf.character) - dstWtfCharacterPath := filepath.Join(dstWtfAccountPath, dstConfig.wtf.server, dstConfig.wtf.character) - - characterFilesToCopy := [4]string{"AddOns.txt", "config-cache.wtf", "layout-local.txt", "macros-cache.txt"} - - for _, file := range characterFilesToCopy { - src := filepath.Join(srcWtfCharacterPath, file) - dst := filepath.Join(dstWtfCharacterPath, file) - _, err := copyFile(src, dst) - if err != nil { - log.Fatal(err) - } - pterm.Info.Printfln("Copied %s", src) - } - - // - // account-level saved variables - // - - svFileRegex := regexp.MustCompile(`.*\.lua$`) - - accountSavedVariablesFiles, err := os.ReadDir(filepath.Join(srcWtfAccountPath, "SavedVariables")) - if err != nil { - log.Fatal(err) - } - - for _, file := range accountSavedVariablesFiles { - if svFileRegex.MatchString(file.Name()) { - src := filepath.Join(srcWtfAccountPath, "SavedVariables", file.Name()) - dst := filepath.Join(dstWtfAccountPath, "SavedVariables", file.Name()) - _, err := copyFile(src, dst) - if err != nil { - log.Fatal(err) - } - pterm.Info.Printfln("Copied %s", src) - } - } - - // - // character-level saved variables - // - - charSavedVariablesFiles, err := os.ReadDir(filepath.Join(srcWtfCharacterPath, "SavedVariables")) - if err != nil { - log.Fatal(err) - } - - for _, file := range charSavedVariablesFiles { - if svFileRegex.MatchString(file.Name()) { - src := filepath.Join(srcWtfCharacterPath, "SavedVariables", file.Name()) - dst := filepath.Join(dstWtfCharacterPath, "SavedVariables", file.Name()) - _, err := copyFile(src, dst) - if err != nil { - log.Fatal(err) - } - pterm.Info.Printfln("Copied %s", src) - } - } - - // - // clean up - // - dstAccountCache := filepath.Join(dstWtfAccountPath, "cache.md5") - err = os.Remove(dstAccountCache) - if err != nil { - if !strings.Contains(err.Error(), "no such file or directory") { - log.Fatal(err) - } - } - - pterm.Info.Printfln("Removed %s", dstAccountCache) - - dstCharacterCache := filepath.Join(dstWtfCharacterPath, "cache.md5") - err = os.Remove(dstCharacterCache) - if err != nil { - if !strings.Contains(err.Error(), "no such file or directory") { - log.Fatal(err) - } - } - - pterm.Info.Printfln("Removed %s", dstCharacterCache) - pterm.Success.Println("All files copied successfully!") - - if runtime.GOOS == "windows" { - fmt.Println("Press Enter to continue...") - fmt.Scanln() - } -} \ No newline at end of file diff --git a/wowinstall.go b/wowinstall.go new file mode 100644 index 0000000..fb15fd8 --- /dev/null +++ b/wowinstall.go @@ -0,0 +1,204 @@ +package main + +import ( + "fmt" + "github.com/pterm/pterm" + "log" + "os" + "path/filepath" + "runtime" +) + +type WowInstall struct { + availableVersions []string + installDirectory string +} + +// Finds all valid WTF configs (account, server, character) for a given WoW version +func (wow WowInstall) getWtfConfigurations(version string) []Wtf { + var configurations []Wtf + + wtfPath := filepath.Join(wow.installDirectory, version, "WTF", "Account") // a fitting name + + // enumerate available accounts on this instance + wtfFiles, err := os.ReadDir(wtfPath) + if err != nil { + log.Fatal(err) + } + + // search all directories in WTF/Account + for _, acct := range wtfFiles { + if acct.IsDir() && acct.Name() != "SavedVariables" { + accountPath := filepath.Join(wtfPath, acct.Name()) + serverFiles, err := os.ReadDir(accountPath) // enumerate available servers under each account + if err != nil { + log.Fatal(err) + } + for _, server := range serverFiles { + if server.IsDir() && server.Name() != "SavedVariables" { // assume that any folder that isn't SavedVariables here is a realm + serverPath := filepath.Join(accountPath, server.Name()) + characterFiles, err := os.ReadDir(serverPath) + if err != nil { + log.Fatal(err) + } + for _, character := range characterFiles { // any subdirectories of the server directories are characters, they have arbitrary names + if character.IsDir() { + finalWtf := Wtf{ + account: acct.Name(), + server: server.Name(), + character: character.Name(), + } + configurations = append(configurations, finalWtf) + } + } + } + } + } + } + return configurations +} + +// determines which WoW versions are available in a given WoW install directory (classic, retail, SoM, etc..) +func (wow *WowInstall) findAvailableVersions(dir string) { + files, err := os.ReadDir(dir) + if err != nil { + log.Fatal(err) + } + + for _, file := range files { + // if this directory contains a wow instance folder name, it's probably where WoW is installed + _, matchesInstanceName := _wowInstanceFolderNames[file.Name()] + if file.IsDir() && matchesInstanceName { + wow.availableVersions = append(wow.availableVersions, file.Name()) + } + } +} + +// prompts the user to select a wow game version, and a WTF tuple to copy to/from +// wtf tuples are (account, server, character) +// isSource: whether we are selecting the source of the copy or the destination +func (wow WowInstall) selectWtf(isSource bool) CopyTarget { + preposition := "to" + if isSource { + preposition = "from" + } + + optionsHiddenText := "[Some options hidden, use arrow keys to reveal]" + const selectHeight = 15 + var versions []string + + // + // prompt for WoW version + // + + for _, version := range wow.availableVersions { + versions = append(versions, version) + } + + defaultText := fmt.Sprintf("WoW Version to copy %s", preposition) + // give the user an indication that they can scroll the selection window + if len(versions) > selectHeight { + defaultText = fmt.Sprintf("WoW Version to copy %s %s", preposition, optionsHiddenText) + } + + wowVersion, _ := pterm.DefaultInteractiveSelect. + WithOptions(versions). + WithDefaultText(defaultText). + WithMaxHeight(selectHeight). + Show() + pterm.Debug.Printfln("chose %s", wowVersion) + + // validate that the chosen wow version actually has configurations to copy from/to + // wtf configs are only generated when you login to a character + wtfConfigs := wow.getWtfConfigurations(wowVersion) + if len(wtfConfigs) == 0 { + pterm.Error.Printfln("No valid WTF configurations found in %s. Try logging into a character on this version of the client, first!", wowVersion) + if runtime.GOOS == "windows" { + // make windows users feel at home + fmt.Println("Press Enter to continue...") + fmt.Scanln() + } + os.Exit(1) + } + + // + // prompt for account + // + + var accountOptions []string + for _, wtf := range wtfConfigs { + accountOptions = append(accountOptions, wtf.account) + } + accountOptions = deduplicateStringSlice(accountOptions) + + if len(accountOptions) > selectHeight { + defaultText = fmt.Sprintf("Account to copy %s %s", preposition, optionsHiddenText) + } else { + defaultText = fmt.Sprintf("Account to copy %s", preposition) + } + + chosenAccount, _ := pterm.DefaultInteractiveSelect. + WithOptions(accountOptions). + WithDefaultText(defaultText). + WithMaxHeight(selectHeight). + Show() + pterm.Debug.Printfln("chose %s", chosenAccount) + + // + // prompt for server + // + + var serverOptions []string + for _, wtf := range wtfConfigs { + if wtf.account == chosenAccount { + serverOptions = append(serverOptions, wtf.server) + } + } + serverOptions = deduplicateStringSlice(serverOptions) + + if len(serverOptions) > selectHeight { + defaultText = fmt.Sprintf("Server to copy %s %s", preposition, optionsHiddenText) + } else { + defaultText = fmt.Sprintf("Server to copy %s", preposition) + } + + chosenServer, _ := pterm.DefaultInteractiveSelect. + WithOptions(serverOptions). + WithDefaultText(fmt.Sprintf("Server to copy %s", preposition)). + WithMaxHeight(selectHeight). + Show() + pterm.Debug.Printfln("chose %s", chosenServer) + + // + // prompt for character + // + + var characterOptions []string + for _, wtf := range wtfConfigs { + if wtf.account == chosenAccount && wtf.server == chosenServer { + characterOptions = append(characterOptions, wtf.character) + } + } + + if len(characterOptions) > selectHeight { + defaultText = fmt.Sprintf("Character to copy %s %s", preposition, optionsHiddenText) + } else { + defaultText = fmt.Sprintf("Character to copy %s", preposition) + } + + chosenCharacter, _ := pterm.DefaultInteractiveSelect. + WithOptions(characterOptions). + WithDefaultText(defaultText). + WithMaxHeight(selectHeight). + Show() + pterm.Debug.Printfln("chose %s", chosenCharacter) + + return CopyTarget{ + wtf: Wtf{ + account: chosenAccount, + server: chosenServer, + character: chosenCharacter, + }, + version: wowVersion, + } +}