From 07dfe141ad8cb77ff3300978dcf8f0e81ad9aec9 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 29 Nov 2025 03:20:32 -0500 Subject: [PATCH 1/4] Create internal package --- cmd/install.go | 5 +- cmd/uninstall.go | 86 +++++++++---------- .../channels.go => internal/models/channel.go | 0 utils/api.go => internal/models/github.go | 0 {utils => internal/utils}/download.go | 0 {utils => internal/utils}/paths.go | 21 +++-- utils/exe.go | 25 ------ utils/kill.go | 39 --------- 8 files changed, 58 insertions(+), 118 deletions(-) rename utils/channels.go => internal/models/channel.go (100%) rename utils/api.go => internal/models/github.go (100%) rename {utils => internal/utils}/download.go (100%) rename {utils => internal/utils}/paths.go (89%) delete mode 100644 utils/exe.go delete mode 100644 utils/kill.go diff --git a/cmd/install.go b/cmd/install.go index acd4399..e8878e4 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -9,7 +9,8 @@ import ( "github.com/spf13/cobra" - utils "github.com/betterdiscord/cli/utils" + models "github.com/betterdiscord/cli/internal/models" + utils "github.com/betterdiscord/cli/internal/utils" ) func init() { @@ -65,7 +66,7 @@ var installCmd = &cobra.Command{ } // Get download URL from GitHub API - var apiData, err = utils.DownloadJSON[utils.Release]("https://api.github.com/repos/BetterDiscord/BetterDiscord/releases/latest") + var apiData, err = utils.DownloadJSON[models.Release]("https://api.github.com/repos/BetterDiscord/BetterDiscord/releases/latest") if err != nil { fmt.Println("Could not get API response") fmt.Println(err) diff --git a/cmd/uninstall.go b/cmd/uninstall.go index e3084dc..0bfeacf 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -8,57 +8,57 @@ import ( "github.com/spf13/cobra" - utils "github.com/betterdiscord/cli/utils" + utils "github.com/betterdiscord/cli/internal/utils" ) func init() { - rootCmd.AddCommand(uninstallCmd) + rootCmd.AddCommand(uninstallCmd) } var uninstallCmd = &cobra.Command{ - Use: "uninstall ", - Short: "Uninstalls BetterDiscord from your Discord", - Long: "This can uninstall BetterDiscord to multiple versions and paths of Discord at once. Options for channel are: stable, canary, ptb", - ValidArgs: []string{"canary", "stable", "ptb"}, - Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), - Run: func(cmd *cobra.Command, args []string) { - var releaseChannel = args[0] - var corePath = utils.DiscordPath(releaseChannel) - var indString = "module.exports = require(\"./core.asar\");" + Use: "uninstall ", + Short: "Uninstalls BetterDiscord from your Discord", + Long: "This can uninstall BetterDiscord to multiple versions and paths of Discord at once. Options for channel are: stable, canary, ptb", + ValidArgs: []string{"canary", "stable", "ptb"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + Run: func(cmd *cobra.Command, args []string) { + var releaseChannel = args[0] + var corePath = utils.DiscordPath(releaseChannel) + var indString = "module.exports = require(\"./core.asar\");" - if err := os.WriteFile(path.Join(corePath, "index.js"), []byte(indString), 0755); err != nil { - fmt.Println("Could not write index.js in discord_desktop_core!") - return - } + if err := os.WriteFile(path.Join(corePath, "index.js"), []byte(indString), 0755); err != nil { + fmt.Println("Could not write index.js in discord_desktop_core!") + return + } - var targetExe = "" - switch releaseChannel { - case "stable": - targetExe = "Discord.exe" - break - case "canary": - targetExe = "DiscordCanary.exe" - break - case "ptb": - targetExe = "DiscordPTB.exe" - break - default: - targetExe = "" - } + var targetExe = "" + switch releaseChannel { + case "stable": + targetExe = "Discord.exe" + break + case "canary": + targetExe = "DiscordCanary.exe" + break + case "ptb": + targetExe = "DiscordPTB.exe" + break + default: + targetExe = "" + } - // Kill Discord if it's running - var exe = utils.GetProcessExe(targetExe) - if len(exe) > 0 { - if err := utils.KillProcess(targetExe); err != nil { - fmt.Println("Could not kill Discord") - return - } - } + // Kill Discord if it's running + var exe = utils.GetProcessExe(targetExe) + if len(exe) > 0 { + if err := utils.KillProcess(targetExe); err != nil { + fmt.Println("Could not kill Discord") + return + } + } - // Launch Discord if we killed it - if len(exe) > 0 { - var cmd = exec.Command(exe) - cmd.Start() - } - }, + // Launch Discord if we killed it + if len(exe) > 0 { + var cmd = exec.Command(exe) + cmd.Start() + } + }, } diff --git a/utils/channels.go b/internal/models/channel.go similarity index 100% rename from utils/channels.go rename to internal/models/channel.go diff --git a/utils/api.go b/internal/models/github.go similarity index 100% rename from utils/api.go rename to internal/models/github.go diff --git a/utils/download.go b/internal/utils/download.go similarity index 100% rename from utils/download.go rename to internal/utils/download.go diff --git a/utils/paths.go b/internal/utils/paths.go similarity index 89% rename from utils/paths.go rename to internal/utils/paths.go index 590a5c7..75e6d62 100644 --- a/utils/paths.go +++ b/internal/utils/paths.go @@ -7,6 +7,8 @@ import ( "runtime" "sort" "strings" + + models "github.com/betterdiscord/cli/internal/models" ) var Roaming string @@ -33,7 +35,7 @@ func Exists(path string) bool { } func DiscordPath(channel string) string { - var channelName = GetChannelName(channel) + var channelName = models.GetChannelName(channel) switch op := runtime.GOOS; op { case "windows": @@ -51,14 +53,13 @@ func ValidatePath(proposed string) string { switch op := runtime.GOOS; op { case "windows": return validateWindows(proposed) - case "darwin","linux": + case "darwin", "linux": return validateMacLinux(proposed) default: return "" } } - func Filter[T any](source []T, filterFunc func(T) bool) (ret []T) { var returnArray = []T{} for _, s := range source { @@ -69,7 +70,6 @@ func Filter[T any](source []T, filterFunc func(T) bool) (ret []T) { return returnArray } - func validateWindows(proposed string) string { var finalPath = "" var selected = path.Base(proposed) @@ -80,7 +80,7 @@ func validateWindows(proposed string) string { if err != nil { return "" } - + var candidates = Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && len(strings.Split(file.Name(), ".")) == 3 }) sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) var versionDir = candidates[len(candidates)-1].Name() @@ -90,7 +90,9 @@ func validateWindows(proposed string) string { if err != nil { return "" } - candidates = Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") }) + candidates = Filter(dFiles, func(file fs.DirEntry) bool { + return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") + }) var coreWrap = candidates[len(candidates)-1].Name() finalPath = path.Join(proposed, versionDir, "modules", coreWrap, "discord_desktop_core") @@ -102,7 +104,9 @@ func validateWindows(proposed string) string { if err != nil { return "" } - var candidates = Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") }) + var candidates = Filter(dFiles, func(file fs.DirEntry) bool { + return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") + }) var coreWrap = candidates[len(candidates)-1].Name() finalPath = path.Join(proposed, coreWrap, "discord_desktop_core") } @@ -119,7 +123,6 @@ func validateWindows(proposed string) string { return "" } - func validateMacLinux(proposed string) string { if strings.Contains(proposed, "/snap") { return "" @@ -133,7 +136,7 @@ func validateMacLinux(proposed string) string { if err != nil { return "" } - + var candidates = Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && len(strings.Split(file.Name(), ".")) == 3 }) sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) var versionDir = candidates[len(candidates)-1].Name() diff --git a/utils/exe.go b/utils/exe.go deleted file mode 100644 index a51e6e1..0000000 --- a/utils/exe.go +++ /dev/null @@ -1,25 +0,0 @@ -package utils - -import ( - "github.com/shirou/gopsutil/v3/process" -) - -func GetProcessExe(name string) string { - var exe = "" - processes, err := process.Processes() - if err != nil { - return exe - } - for _, p := range processes { - n, err := p.Name() - if err != nil { - continue - } - if n == name { - if len(exe) == 0 { - exe, _ = p.Exe() - } - } - } - return exe -} diff --git a/utils/kill.go b/utils/kill.go deleted file mode 100644 index 3b15aec..0000000 --- a/utils/kill.go +++ /dev/null @@ -1,39 +0,0 @@ -package utils - -import ( - "fmt" - - "github.com/shirou/gopsutil/v3/process" -) - -func KillProcess(name string) error { - processes, err := process.Processes() - - // If we can't even list processes, bail out - if err != nil { - return fmt.Errorf("Could not list processes") - } - - // Search for desired processe(s) - for _, p := range processes { - n, err := p.Name() - - // Ignore processes requiring Admin/Sudo - if err != nil { - continue - } - - // We found our target, kill it - if n == name { - var killErr = p.Kill() - - // We found it but can't kill it, bail out - if killErr != nil { - return killErr - } - } - } - - // If we got here, everything was killed without error - return nil -} From 377a90c1e5fe815fe478b4069b6041391854adc9 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 29 Nov 2025 03:45:12 -0500 Subject: [PATCH 2/4] Add installer modules --- go.mod | 11 ++- go.sum | 15 +++ internal/betterdiscord/download.go | 64 ++++++++++++ internal/betterdiscord/install.go | 97 ++++++++++++++++++ internal/betterdiscord/setup.go | 60 +++++++++++ internal/discord/assets/injection.js | 21 ++++ internal/discord/injection.go | 58 +++++++++++ internal/discord/install.go | 103 +++++++++++++++++++ internal/discord/paths.go | 100 +++++++++++++++++++ internal/discord/paths_darwin.go | 79 +++++++++++++++ internal/discord/paths_linux.go | 97 ++++++++++++++++++ internal/discord/paths_windows.go | 91 +++++++++++++++++ internal/discord/process.go | 127 ++++++++++++++++++++++++ internal/models/channel.go | 79 +++++++++++++-- internal/models/github.go | 4 +- internal/utils/download.go | 14 +-- internal/utils/paths.go | 143 --------------------------- 17 files changed, 997 insertions(+), 166 deletions(-) create mode 100644 internal/betterdiscord/download.go create mode 100644 internal/betterdiscord/install.go create mode 100644 internal/betterdiscord/setup.go create mode 100644 internal/discord/assets/injection.js create mode 100644 internal/discord/injection.go create mode 100644 internal/discord/install.go create mode 100644 internal/discord/paths.go create mode 100644 internal/discord/paths_darwin.go create mode 100644 internal/discord/paths_linux.go create mode 100644 internal/discord/paths_windows.go create mode 100644 internal/discord/process.go diff --git a/go.mod b/go.mod index 35487de..07882f9 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/betterdiscord/cli go 1.19 require ( - github.com/shirou/gopsutil/v3 v3.22.10 + github.com/shirou/gopsutil/v3 v3.24.5 github.com/spf13/cobra v1.6.1 ) @@ -12,9 +12,10 @@ require ( github.com/inconshreveable/mousetrap v1.0.1 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/tklauser/go-sysconf v0.3.10 // indirect - github.com/tklauser/numcpus v0.4.0 // indirect - github.com/yusufpapurcu/wmi v1.2.2 // indirect - golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/sys v0.20.0 // indirect ) diff --git a/go.sum b/go.sum index 612f5b6..4e5abd9 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,10 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:Om github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shirou/gopsutil/v3 v3.22.10 h1:4KMHdfBRYXGF9skjDWiL4RA2N+E8dRdodU/bOZpPoVg= github.com/shirou/gopsutil/v3 v3.22.10/go.mod h1:QNza6r4YQoydyCfo6rH0blGfKahgibh4dQmV5xdFkQk= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -29,17 +33,28 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/tklauser/go-sysconf v0.3.10 h1:IJ1AZGZRWbY8T5Vfk04D9WOA5WSejdflXxP03OUqALw= github.com/tklauser/go-sysconf v0.3.10/go.mod h1:C8XykCvCb+Gn0oNCWPIlcb0RuglQTYaQ2hGm7jmxEFk= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.4.0 h1:E53Dm1HjH1/R2/aoCtXtPgzmElmn51aOkhCFSuZq//o= github.com/tklauser/numcpus v0.4.0/go.mod h1:1+UI3pD8NW14VMwdgJNJ1ESk2UnwhAnz5hMwiKKqXCQ= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg= github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/betterdiscord/download.go b/internal/betterdiscord/download.go new file mode 100644 index 0000000..757fdc3 --- /dev/null +++ b/internal/betterdiscord/download.go @@ -0,0 +1,64 @@ +package betterdiscord + +import ( + "log" + + "github.com/betterdiscord/cli/internal/models" + "github.com/betterdiscord/cli/internal/utils" +) + +func (i *BDInstall) download() error { + if i.hasDownloaded { + log.Printf("✅ Already downloaded to %s", i.asar) + return nil + } + + resp, err := utils.DownloadFile("https://betterdiscord.app/Download/betterdiscord.asar", i.asar) + if err == nil { + version := resp.Header.Get("x-bd-version") + log.Printf("✅ Downloaded BetterDiscord version %s from the official website", version) + i.hasDownloaded = true + return nil + } else { + log.Printf("❌ Failed to download BetterDiscord from official website") + log.Printf("❌ %s", err.Error()) + log.Printf("") + log.Printf("#### Falling back to GitHub...") + } + + // Get download URL from GitHub API + apiData, err := utils.DownloadJSON[models.GitHubRelease]("https://api.github.com/repos/BetterDiscord/BetterDiscord/releases/latest") + if err != nil { + log.Printf("❌ Failed to get asset url from GitHub") + log.Printf("❌ %s", err.Error()) + return err + } + + var index = 0 + for i, asset := range apiData.Assets { + if asset.Name == "betterdiscord.asar" { + index = i + break + } + } + + var downloadUrl = apiData.Assets[index].URL + var version = apiData.TagName + + if downloadUrl != "" { + log.Printf("✅ Found BetterDiscord: %s", downloadUrl) + } + + // Download asar into the BD folder + _, err = utils.DownloadFile(downloadUrl, i.asar) + if err != nil { + log.Printf("❌ Failed to download BetterDiscord from GitHub") + log.Printf("❌ %s", err.Error()) + return err + } + + log.Printf("✅ Downloaded BetterDiscord version %s from GitHub", version) + i.hasDownloaded = true + + return nil +} diff --git a/internal/betterdiscord/install.go b/internal/betterdiscord/install.go new file mode 100644 index 0000000..81c5173 --- /dev/null +++ b/internal/betterdiscord/install.go @@ -0,0 +1,97 @@ +package betterdiscord + +import ( + "os" + "path/filepath" + "sync" + + "github.com/betterdiscord/cli/internal/models" +) + +type BDInstall struct { + root string + data string + asar string + plugins string + themes string + hasDownloaded bool +} + +// Root returns the root directory path of the BetterDiscord installation +func (i *BDInstall) Root() string { + return i.root +} + +// Data returns the data directory path +func (i *BDInstall) Data() string { + return i.data +} + +// Asar returns the path to the BetterDiscord asar file +func (i *BDInstall) Asar() string { + return i.asar +} + +// Plugins returns the plugins directory path +func (i *BDInstall) Plugins() string { + return i.plugins +} + +// Themes returns the themes directory path +func (i *BDInstall) Themes() string { + return i.themes +} + +// HasDownloaded returns whether BetterDiscord has been downloaded +func (i *BDInstall) HasDownloaded() bool { + return i.hasDownloaded +} + +// Download downloads the BetterDiscord asar file +func (i *BDInstall) Download() error { + return i.download() +} + +// Prepare creates all necessary directories for BetterDiscord +func (i *BDInstall) Prepare() error { + return i.prepare() +} + +// Repair disables plugins for a specific Discord channel +func (i *BDInstall) Repair(channel models.DiscordChannel) error { + return i.repair(channel) +} + +var lock = &sync.Mutex{} +var globalInstance *BDInstall + +func GetInstallation(base ...string) *BDInstall { + if len(base) == 0 { + if globalInstance != nil { + return globalInstance + } + + lock.Lock() + defer lock.Unlock() + if globalInstance != nil { + return globalInstance + } + + configDir, _ := os.UserConfigDir() + globalInstance = New(configDir) + + return globalInstance + } + + return New(filepath.Join(base[0], "BetterDiscord")) +} + +func New(root string) *BDInstall { + return &BDInstall{ + root: root, + data: filepath.Join(root, "data"), + asar: filepath.Join(root, "data", "betterdiscord.asar"), + plugins: filepath.Join(root, "plugins"), + themes: filepath.Join(root, "themes"), + } +} diff --git a/internal/betterdiscord/setup.go b/internal/betterdiscord/setup.go new file mode 100644 index 0000000..774843a --- /dev/null +++ b/internal/betterdiscord/setup.go @@ -0,0 +1,60 @@ +package betterdiscord + +import ( + "log" + "os" + "path/filepath" + + "github.com/betterdiscord/cli/internal/models" + "github.com/betterdiscord/cli/internal/utils" +) + +func makeDirectory(folder string) error { + exists := utils.Exists(folder) + + if exists { + log.Printf("✅ Directory exists: %s", folder) + return nil + } + + if err := os.MkdirAll(folder, 0755); err != nil { + log.Printf("❌ Failed to create directory: %s", folder) + log.Printf("❌ %s", err.Error()) + return err + } + + log.Printf("✅ Directory created: %s", folder) + return nil +} + +func (i *BDInstall) prepare() error { + if err := makeDirectory(i.data); err != nil { + return err + } + if err := makeDirectory(i.plugins); err != nil { + return err + } + if err := makeDirectory(i.themes); err != nil { + return err + } + return nil +} + +func (i *BDInstall) repair(channel models.DiscordChannel) error { + channelFolder := filepath.Join(i.data, channel.String()) + pluginsJson := filepath.Join(channelFolder, "plugins.json") + + if !utils.Exists(pluginsJson) { + log.Printf("✅ No plugins enabled for %s", channel.Name()) + return nil + } + + if err := os.Remove(pluginsJson); err != nil { + log.Printf("❌ Unable to remove file %s", pluginsJson) + log.Printf("❌ %s", err.Error()) + return err + } + + log.Printf("✅ Plugins disabled for %s", channel.Name()) + return nil +} diff --git a/internal/discord/assets/injection.js b/internal/discord/assets/injection.js new file mode 100644 index 0000000..3d176be --- /dev/null +++ b/internal/discord/assets/injection.js @@ -0,0 +1,21 @@ +// BetterDiscord's Injection Script +const path = require("path"); +const electron = require("electron"); + +// Windows and macOS both use the fixed global BetterDiscord folder but +// Electron gives the postfixed version of userData, so go up a directory +let userConfig = path.join(electron.app.getPath("userData"), ".."); + +// If we're on Linux there are a couple cases to deal with +if (process.platform !== "win32" && process.platform !== "darwin") { + // Use || instead of ?? because a falsey value of "" is invalid per XDG spec + userConfig = process.env.XDG_CONFIG_HOME || path.join(process.env.HOME, ".config"); + + // HOST_XDG_CONFIG_HOME is set by flatpak, so use without validation if set + if (process.env.HOST_XDG_CONFIG_HOME) userConfig = process.env.HOST_XDG_CONFIG_HOME; +} + +require(path.join(userConfig, "BetterDiscord", "data", "betterdiscord.asar")); + +// Discord's Default Export +module.exports = require("./core.asar"); \ No newline at end of file diff --git a/internal/discord/injection.go b/internal/discord/injection.go new file mode 100644 index 0000000..734aa6f --- /dev/null +++ b/internal/discord/injection.go @@ -0,0 +1,58 @@ +package discord + +import ( + _ "embed" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/betterdiscord/cli/internal/betterdiscord" +) + +//go:embed assets/injection.js +var injectionScript string + +func (discord *DiscordInstall) inject(bd *betterdiscord.BDInstall) error { + if discord.isFlatpak { + cmd := exec.Command("flatpak", "--user", "override", "com.discordapp."+discord.channel.Exe(), "--filesystem="+bd.Root()) + if err := cmd.Run(); err != nil { + log.Printf("❌ Could not give flatpak access to %s", bd.Root()) + log.Printf("❌ %s", err.Error()) + return err + } + } + + if err := os.WriteFile(filepath.Join(discord.corePath, "index.js"), []byte(injectionScript), 0755); err != nil { + log.Printf("❌ Unable to write index.js in %s", discord.corePath) + log.Printf("❌ %s", err.Error()) + return err + } + + log.Printf("✅ Injected into %s", discord.corePath) + return nil +} + +func (discord *DiscordInstall) uninject() error { + indexFile := filepath.Join(discord.corePath, "index.js") + + contents, err := os.ReadFile(indexFile) + + // First try to check the file, but if there's an issue we try to blindly overwrite below + if err == nil { + if !strings.Contains(strings.ToLower(string(contents)), "betterdiscord") { + log.Printf("✅ No injection found for %s", discord.channel.Name()) + return nil + } + } + + if err := os.WriteFile(indexFile, []byte(`module.exports = require("./core.asar");`), 0o644); err != nil { + log.Printf("❌ Unable to write file %s", indexFile) + log.Printf("❌ %s", err.Error()) + return err + } + log.Printf("✅ Removed from %s", discord.channel.Name()) + + return nil +} diff --git a/internal/discord/install.go b/internal/discord/install.go new file mode 100644 index 0000000..3cadf18 --- /dev/null +++ b/internal/discord/install.go @@ -0,0 +1,103 @@ +package discord + +import ( + "log" + "path/filepath" + + "github.com/betterdiscord/cli/internal/betterdiscord" + "github.com/betterdiscord/cli/internal/models" +) + +type DiscordInstall struct { + corePath string `json:"corePath"` + channel models.DiscordChannel `json:"channel"` + version string `json:"version"` + isFlatpak bool `json:"isFlatpak"` + isSnap bool `json:"isSnap"` +} + +func (discord *DiscordInstall) GetPath() string { + return discord.corePath +} + +// InstallBD installs BetterDiscord into this Discord installation +func (discord *DiscordInstall) InstallBD() error { + // Gets the global BetterDiscord install + bd := betterdiscord.GetInstallation() + + // Snaps get their own local BD install + if discord.isSnap { + bd = betterdiscord.GetInstallation(filepath.Clean(filepath.Join(discord.corePath, "..", "..", "..", ".."))) + } + + // Make BetterDiscord folders + log.Printf("## Preparing BetterDiscord...") + if err := bd.Prepare(); err != nil { + return err + } + log.Printf("✅ BetterDiscord prepared for install") + log.Printf("") + + // Download and write betterdiscord.asar + log.Printf("## Downloading BetterDiscord...") + if err := bd.Download(); err != nil { + return err + } + log.Printf("✅ BetterDiscord downloaded") + log.Printf("") + + // Write injection script to discord_desktop_core/index.js + log.Printf("## Injecting into Discord...") + if err := discord.inject(bd); err != nil { + return err + } + log.Printf("✅ Injection successful") + log.Printf("") + + // Terminate and restart Discord if possible + log.Printf("## Restarting %s...", discord.channel.Name()) + if err := discord.restart(); err != nil { + return err + } + log.Printf("") + + return nil +} + +// UninstallBD removes BetterDiscord from this Discord installation +func (discord *DiscordInstall) UninstallBD() error { + log.Printf("## Removing injection...") + if err := discord.uninject(); err != nil { + return err + } + log.Printf("") + + log.Printf("## Restarting %s...", discord.channel.Name()) + if err := discord.restart(); err != nil { + return err + } + log.Printf("") + + return nil +} + +// RepairBD repairs BetterDiscord for this Discord installation +func (discord *DiscordInstall) RepairBD() error { + if err := discord.UninstallBD(); err != nil { + return err + } + + // Gets the global BetterDiscord install + bd := betterdiscord.GetInstallation() + + // Snaps get their own local BD install + if discord.isSnap { + bd = betterdiscord.GetInstallation(filepath.Clean(filepath.Join(discord.corePath, "..", "..", "..", ".."))) + } + + if err := bd.Repair(discord.channel); err != nil { + return err + } + + return nil +} diff --git a/internal/discord/paths.go b/internal/discord/paths.go new file mode 100644 index 0000000..121c467 --- /dev/null +++ b/internal/discord/paths.go @@ -0,0 +1,100 @@ +package discord + +import ( + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/betterdiscord/cli/internal/models" +) + +var searchPaths []string +var versionRegex = regexp.MustCompile(`[0-9]+\.[0-9]+\.[0-9]+`) +var allDiscordInstalls map[models.DiscordChannel][]*DiscordInstall + +func GetAllInstalls() map[models.DiscordChannel][]*DiscordInstall { + var installs = map[models.DiscordChannel][]*DiscordInstall{} + + for _, path := range searchPaths { + if result := Validate(path); result != nil { + installs[result.channel] = append(installs[result.channel], result) + } + } + + sortInstalls() + + return installs +} + +func GetVersion(proposed string) string { + for _, folder := range strings.Split(proposed, string(filepath.Separator)) { + if version := versionRegex.FindString(folder); version != "" { + return version + } + } + return "" +} + +func GetChannel(proposed string) models.DiscordChannel { + for _, folder := range strings.Split(proposed, string(filepath.Separator)) { + for _, channel := range models.Channels { + if strings.ToLower(folder) == strings.ReplaceAll(strings.ToLower(channel.Name()), " ", "") { + return channel + } + } + } + return models.Stable +} + +func GetSuggestedPath(channel models.DiscordChannel) string { + if len(allDiscordInstalls[channel]) > 0 { + return allDiscordInstalls[channel][0].corePath + } + return "" +} + +func AddCustomPath(proposed string) *DiscordInstall { + result := Validate(proposed) + if result == nil { + return nil + } + + // Check if this already exists in our list and return reference + index := slices.IndexFunc(allDiscordInstalls[result.channel], func(d *DiscordInstall) bool { return d.corePath == result.corePath }) + if index >= 0 { + return allDiscordInstalls[result.channel][index] + } + + allDiscordInstalls[result.channel] = append(allDiscordInstalls[result.channel], result) + + sortInstalls() + + return result +} + +func ResolvePath(proposed string) *DiscordInstall { + for channel := range allDiscordInstalls { + index := slices.IndexFunc(allDiscordInstalls[channel], func(d *DiscordInstall) bool { return d.corePath == proposed }) + if index >= 0 { + return allDiscordInstalls[channel][index] + } + } + + // If it wasn't found as an existing install, try to add it + return AddCustomPath(proposed) +} + +func sortInstalls() { + for channel := range allDiscordInstalls { + slices.SortFunc(allDiscordInstalls[channel], func(a, b *DiscordInstall) int { + switch { + case a.version > b.version: + return -1 + case b.version > a.version: + return 1 + } + return 0 + }) + } +} diff --git a/internal/discord/paths_darwin.go b/internal/discord/paths_darwin.go new file mode 100644 index 0000000..a97886e --- /dev/null +++ b/internal/discord/paths_darwin.go @@ -0,0 +1,79 @@ +package discord + +import ( + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/betterdiscord/cli/internal/models" + "github.com/betterdiscord/cli/internal/utils" +) + +func init() { + config, _ := os.UserConfigDir() + paths := []string{ + filepath.Join(config, "{channel}"), + } + + for _, channel := range models.Channels { + for _, path := range paths { + folder := strings.ReplaceAll(strings.ToLower(channel.Name()), " ", "") + searchPaths = append( + searchPaths, + strings.ReplaceAll(path, "{channel}", folder), + ) + } + } + + allDiscordInstalls = GetAllInstalls() +} + +/** + * Currently nearly the same as linux validation however + * it is kept separate in case of future changes to + * either system, it is likely that linux will require + * more advanced validation for snap and flatpak. + */ +func Validate(proposed string) *DiscordInstall { + var finalPath = "" + var selected = filepath.Base(proposed) + if strings.HasPrefix(selected, "discord") { + // Get version dir like 1.0.9002 + var dFiles, err = os.ReadDir(proposed) + if err != nil { + return nil + } + + var candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && versionRegex.MatchString(file.Name()) }) + sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) + var versionDir = candidates[len(candidates)-1].Name() + finalPath = filepath.Join(proposed, versionDir, "modules", "discord_desktop_core") + } + + if len(strings.Split(selected, ".")) == 3 { + finalPath = filepath.Join(proposed, "modules", "discord_desktop_core") + } + + if selected == "modules" { + finalPath = filepath.Join(proposed, "discord_desktop_core") + } + + if selected == "discord_desktop_core" { + finalPath = proposed + } + + // If the path and the asar exist, all good + if utils.Exists(finalPath) && utils.Exists(filepath.Join(finalPath, "core.asar")) { + return &DiscordInstall{ + corePath: finalPath, + channel: GetChannel(finalPath), + version: GetVersion(finalPath), + isFlatpak: false, + isSnap: false, + } + } + + return nil +} diff --git a/internal/discord/paths_linux.go b/internal/discord/paths_linux.go new file mode 100644 index 0000000..fd4990d --- /dev/null +++ b/internal/discord/paths_linux.go @@ -0,0 +1,97 @@ +package discord + +import ( + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/betterdiscord/cli/internal/models" + "github.com/betterdiscord/cli/internal/utils" +) + +func init() { + config, _ := os.UserConfigDir() + home, _ := os.UserHomeDir() + paths := []string{ + // Native. Data is stored under `~/.config`. + // Example: `~/.config/discordcanary`. + // Core: `~/.config/discordcanary/0.0.90/modules/discord_desktop_core/core.asar`. + filepath.Join(config, "{channel}"), + + // Flatpak. These user data paths are universal for all Flatpak installations on all machines. + // Example: `.var/app/com.discordapp.DiscordCanary/config/discordcanary`. + // Core: `.var/app/com.discordapp.DiscordCanary/config/discordcanary/0.0.90/modules/discord_desktop_core/core.asar` + filepath.Join(home, ".var", "app", "com.discordapp.{CHANNEL}", "config", "{channel}"), + + // Snap. Just like with Flatpaks, these paths are universal for all Snap installations. + // Example: `snap/discord/current/.config/discord`. + // Example: `snap/discord-canary/current/.config/discordcanary`. + // Core: `snap/discord-canary/current/.config/discordcanary/0.0.90/modules/discord_desktop_core/core.asar`. + // NOTE: Snap user data always exists, even when the Snap isn't mounted/running. + filepath.Join(home, "snap", "{channel-}", "current", ".config", "{channel}"), + } + + for _, channel := range models.Channels { + for _, path := range paths { + upper := strings.ReplaceAll(channel.Name(), " ", "") + lower := strings.ReplaceAll(strings.ToLower(channel.Name()), " ", "") + dash := strings.ReplaceAll(strings.ToLower(channel.Name()), " ", "-") + folder := strings.ReplaceAll(path, "{CHANNEL}", upper) + folder = strings.ReplaceAll(folder, "{channel}", lower) + folder = strings.ReplaceAll(folder, "{channel-}", dash) + searchPaths = append(searchPaths, folder) + } + } + + allDiscordInstalls = GetAllInstalls() +} + +/** + * Currently nearly the same as darwin validation however + * it is kept separate in case of future changes to + * either system, it is likely that linux will require + * more advanced validation for snap and flatpak. + */ +func Validate(proposed string) *DiscordInstall { + var finalPath = "" + var selected = filepath.Base(proposed) + if strings.HasPrefix(selected, "discord") { + // Get version dir like 1.0.9002 + var dFiles, err = os.ReadDir(proposed) + if err != nil { + return nil + } + + var candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && versionRegex.MatchString(file.Name()) }) + sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) + var versionDir = candidates[len(candidates)-1].Name() + finalPath = filepath.Join(proposed, versionDir, "modules", "discord_desktop_core") + } + + if len(strings.Split(selected, ".")) == 3 { + finalPath = filepath.Join(proposed, "modules", "discord_desktop_core") + } + + if selected == "modules" { + finalPath = filepath.Join(proposed, "discord_desktop_core") + } + + if selected == "discord_desktop_core" { + finalPath = proposed + } + + // If the path and the asar exist, all good + if utils.Exists(finalPath) && utils.Exists(filepath.Join(finalPath, "core.asar")) { + return &DiscordInstall{ + corePath: finalPath, + channel: GetChannel(finalPath), + version: GetVersion(finalPath), + isFlatpak: strings.Contains(finalPath, "com.discordapp."), + isSnap: strings.Contains(finalPath, "snap/"), + } + } + + return nil +} diff --git a/internal/discord/paths_windows.go b/internal/discord/paths_windows.go new file mode 100644 index 0000000..b2449bd --- /dev/null +++ b/internal/discord/paths_windows.go @@ -0,0 +1,91 @@ +package discord + +import ( + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/betterdiscord/cli/internal/models" + "github.com/betterdiscord/cli/internal/utils" +) + +func init() { + paths := []string{ + filepath.Join(os.Getenv("LOCALAPPDATA"), "{channel}"), + filepath.Join(os.Getenv("PROGRAMDATA"), os.Getenv("USERNAME"), "{channel}"), + } + + for _, channel := range models.Channels { + for _, path := range paths { + searchPaths = append( + searchPaths, + strings.ReplaceAll(path, "{channel}", strings.ReplaceAll(channel.Name(), " ", "")), + ) + } + } + + allDiscordInstalls = GetAllInstalls() +} + +func Validate(proposed string) *DiscordInstall { + var finalPath = "" + var selected = filepath.Base(proposed) + + if strings.HasPrefix(selected, "Discord") { + + // Get version dir like 1.0.9002 + var dFiles, err = os.ReadDir(proposed) + if err != nil { + return nil + } + + var candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && len(strings.Split(file.Name(), ".")) == 3 }) + sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) + var versionDir = candidates[len(candidates)-1].Name() + + // Get core wrap like discord_desktop_core-1 + dFiles, err = os.ReadDir(filepath.Join(proposed, versionDir, "modules")) + if err != nil { + return nil + } + candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { + return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") + }) + var coreWrap = candidates[len(candidates)-1].Name() + + finalPath = filepath.Join(proposed, versionDir, "modules", coreWrap, "discord_desktop_core") + } + + // Use a separate if statement because forcing same-line } else if { is gross + if strings.HasPrefix(selected, "app-") { + var dFiles, err = os.ReadDir(filepath.Join(proposed, "modules")) + if err != nil { + return nil + } + + var candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { + return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") + }) + var coreWrap = candidates[len(candidates)-1].Name() + finalPath = filepath.Join(proposed, "modules", coreWrap, "discord_desktop_core") + } + + if selected == "discord_desktop_core" { + finalPath = proposed + } + + // If the path and the asar exist, all good + if utils.Exists(finalPath) && utils.Exists(filepath.Join(finalPath, "core.asar")) { + return &DiscordInstall{ + corePath: finalPath, + channel: GetChannel(finalPath), + version: GetVersion(finalPath), + isFlatpak: false, + isSnap: false, + } + } + + return nil +} diff --git a/internal/discord/process.go b/internal/discord/process.go new file mode 100644 index 0000000..f5a8fc4 --- /dev/null +++ b/internal/discord/process.go @@ -0,0 +1,127 @@ +package discord + +import ( + "fmt" + "log" + "os" + "os/exec" + + "github.com/shirou/gopsutil/v3/process" +) + +func (discord *DiscordInstall) restart() error { + exeName := discord.getFullExe() + + if running, _ := discord.isRunning(); !running { + log.Printf("✅ %s not running", discord.channel.Name()) + return nil + } + + if err := discord.kill(); err != nil { + log.Printf("❌ Unable to restart %s, please do so manually!", discord.channel.Name()) + log.Printf("❌ %s", err.Error()) + return err + } + + // Use binary found in killing process + cmd := exec.Command(exeName) + if discord.isFlatpak { + cmd = exec.Command("flatpak", "run", "com.discordapp."+discord.channel.Exe()) + } else if discord.isSnap { + cmd = exec.Command("snap", "run", discord.channel.Exe()) + } + + // Set working directory to user home + cmd.Dir, _ = os.UserHomeDir() + + if err := cmd.Start(); err != nil { + log.Printf("❌ Unable to restart %s, please do so manually!", discord.channel.Name()) + log.Printf("❌ %s", err.Error()) + return err + } + log.Printf("✅ Restarted %s", discord.channel.Name()) + return nil +} + +func (discord *DiscordInstall) isRunning() (bool, error) { + name := discord.channel.Exe() + processes, err := process.Processes() + + // If we can't even list processes, bail out + if err != nil { + return false, fmt.Errorf("could not list processes") + } + + // Search for desired processe(s) + for _, p := range processes { + n, err := p.Name() + + // Ignore processes requiring Admin/Sudo + if err != nil { + continue + } + + // We found our target return + if n == name { + return true, nil + } + } + + // If we got here, process was not found + return false, nil +} + +func (discord *DiscordInstall) kill() error { + name := discord.channel.Exe() + processes, err := process.Processes() + + // If we can't even list processes, bail out + if err != nil { + return fmt.Errorf("could not list processes") + } + + // Search for desired processe(s) + for _, p := range processes { + n, err := p.Name() + + // Ignore processes requiring Admin/Sudo + if err != nil { + continue + } + + // We found our target, kill it + if n == name { + var killErr = p.Kill() + + // We found it but can't kill it, bail out + if killErr != nil { + return killErr + } + } + } + + // If we got here, everything was killed without error + return nil +} + +func (discord *DiscordInstall) getFullExe() string { + name := discord.channel.Exe() + + var exe = "" + processes, err := process.Processes() + if err != nil { + return exe + } + for _, p := range processes { + n, err := p.Name() + if err != nil { + continue + } + if n == name { + if len(exe) == 0 { + exe, _ = p.Exe() + } + } + } + return exe +} diff --git a/internal/models/channel.go b/internal/models/channel.go index 08b848c..34b4fc7 100644 --- a/internal/models/channel.go +++ b/internal/models/channel.go @@ -1,16 +1,77 @@ -package utils +package models -import "strings" +import ( + "runtime" + "strings" +) -func GetChannelName(channel string) string { - switch strings.ToLower(channel) { - case "stable": +// DiscordChannel represents a Discord release channel (Stable, PTB, Canary) +type DiscordChannel int + +const ( + Stable DiscordChannel = iota + Canary + PTB +) + +// All available Discord channels +var Channels = []DiscordChannel{Stable, Canary, PTB} + +// Used for logging, etc +func (channel DiscordChannel) String() string { + switch channel { + case Stable: + return "stable" + case Canary: + return "canary" + case PTB: + return "ptb" + } + return "" +} + +// Used for user display +func (channel DiscordChannel) Name() string { + switch channel { + case Stable: return "Discord" + case Canary: + return "Discord Canary" + case PTB: + return "Discord PTB" + } + return "" +} + +// Exe returns the executable name for the release channel +func (channel DiscordChannel) Exe() string { + name := channel.Name() + + if runtime.GOOS != "darwin" { + name = strings.ReplaceAll(name, " ", "") + } + + if runtime.GOOS == "windows" { + name = name + ".exe" + } + + return name +} + +// ParseChannel converts a string input to a DiscordChannel type +func ParseChannel(input string) DiscordChannel { + switch strings.ToLower(input) { + case "stable": + return Stable case "canary": - return "DiscordCanary" + return Canary case "ptb": - return "DiscordPTB" - default: - return "" + return PTB } + return Stable +} + +// Used by Wails for type serialization +func (channel DiscordChannel) TSName() string { + return strings.ToUpper(channel.String()) } diff --git a/internal/models/github.go b/internal/models/github.go index 4fb9c2f..a9b3c1a 100644 --- a/internal/models/github.go +++ b/internal/models/github.go @@ -1,10 +1,10 @@ -package utils +package models import ( "time" ) -type Release struct { +type GitHubRelease struct { URL string `json:"url"` AssetsURL string `json:"assets_url"` UploadURL string `json:"upload_url"` diff --git a/internal/utils/download.go b/internal/utils/download.go index 2297aa9..eb12f0a 100644 --- a/internal/utils/download.go +++ b/internal/utils/download.go @@ -13,19 +13,19 @@ var client = &http.Client{ Timeout: 10 * time.Second, } -func DownloadFile(url string, filepath string) (err error) { +func DownloadFile(url string, filepath string) (response *http.Response, err error) { // Create the file out, err := os.Create(filepath) if err != nil { - return err + return nil, err } defer out.Close() // Setup the request req, err := http.NewRequest("GET", url, nil) if err != nil { - return err + return nil, err } req.Header.Add("User-Agent", "BetterDiscord/cli") req.Header.Add("Accept", "application/octet-stream") @@ -33,22 +33,22 @@ func DownloadFile(url string, filepath string) (err error) { // Get the data resp, err := client.Do(req) if err != nil { - return err + return resp, err } defer resp.Body.Close() // Check server response if resp.StatusCode != http.StatusOK { - return fmt.Errorf("Bad status: %s", resp.Status) + return resp, fmt.Errorf("bad status code: %s", resp.Status) } // Writer the body to file _, err = io.Copy(out, resp.Body) if err != nil { - return err + return resp, err } - return nil + return resp, nil } func DownloadJSON[T any](url string) (T, error) { diff --git a/internal/utils/paths.go b/internal/utils/paths.go index 75e6d62..8d86ebb 100644 --- a/internal/utils/paths.go +++ b/internal/utils/paths.go @@ -1,65 +1,14 @@ package utils import ( - "io/fs" "os" - "path" - "runtime" - "sort" - "strings" - - models "github.com/betterdiscord/cli/internal/models" ) -var Roaming string -var BetterDiscord string -var Data string -var Plugins string -var Themes string - -func init() { - var configDir, err = os.UserConfigDir() - if err != nil { - return - } - Roaming = configDir - BetterDiscord = path.Join(configDir, "BetterDiscord") - Data = path.Join(BetterDiscord, "data") - Plugins = path.Join(BetterDiscord, "plugins") - Themes = path.Join(BetterDiscord, "themes") -} - func Exists(path string) bool { var _, err = os.Stat(path) return err == nil } -func DiscordPath(channel string) string { - var channelName = models.GetChannelName(channel) - - switch op := runtime.GOOS; op { - case "windows": - return ValidatePath(path.Join(os.Getenv("LOCALAPPDATA"), channelName)) - case "darwin": - return ValidatePath(path.Join("/", "Applications", channelName+".app")) - case "linux": - return ValidatePath(path.Join(Roaming, strings.ToLower(channelName))) - default: - return "" - } -} - -func ValidatePath(proposed string) string { - switch op := runtime.GOOS; op { - case "windows": - return validateWindows(proposed) - case "darwin", "linux": - return validateMacLinux(proposed) - default: - return "" - } -} - func Filter[T any](source []T, filterFunc func(T) bool) (ret []T) { var returnArray = []T{} for _, s := range source { @@ -69,95 +18,3 @@ func Filter[T any](source []T, filterFunc func(T) bool) (ret []T) { } return returnArray } - -func validateWindows(proposed string) string { - var finalPath = "" - var selected = path.Base(proposed) - if strings.HasPrefix(selected, "Discord") { - - // Get version dir like 1.0.9002 - var dFiles, err = os.ReadDir(proposed) - if err != nil { - return "" - } - - var candidates = Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && len(strings.Split(file.Name(), ".")) == 3 }) - sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) - var versionDir = candidates[len(candidates)-1].Name() - - // Get core wrap like discord_desktop_core-1 - dFiles, err = os.ReadDir(path.Join(proposed, versionDir, "modules")) - if err != nil { - return "" - } - candidates = Filter(dFiles, func(file fs.DirEntry) bool { - return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") - }) - var coreWrap = candidates[len(candidates)-1].Name() - - finalPath = path.Join(proposed, versionDir, "modules", coreWrap, "discord_desktop_core") - } - - // Use a separate if statement because forcing same-line } else if { is gross - if strings.HasPrefix(proposed, "app-") { - var dFiles, err = os.ReadDir(path.Join(proposed, "modules")) - if err != nil { - return "" - } - var candidates = Filter(dFiles, func(file fs.DirEntry) bool { - return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") - }) - var coreWrap = candidates[len(candidates)-1].Name() - finalPath = path.Join(proposed, coreWrap, "discord_desktop_core") - } - - if selected == "discord_desktop_core" { - finalPath = proposed - } - - // If the path and the asar exist, all good - if Exists(finalPath) && Exists(path.Join(finalPath, "core.asar")) { - return finalPath - } - - return "" -} - -func validateMacLinux(proposed string) string { - if strings.Contains(proposed, "/snap") { - return "" - } - - var finalPath = "" - var selected = path.Base(proposed) - if strings.HasPrefix(selected, "discord") { - // Get version dir like 1.0.9002 - var dFiles, err = os.ReadDir(proposed) - if err != nil { - return "" - } - - var candidates = Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && len(strings.Split(file.Name(), ".")) == 3 }) - sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) - var versionDir = candidates[len(candidates)-1].Name() - finalPath = path.Join(proposed, versionDir, "modules", "discord_desktop_core") - } - - if len(strings.Split(selected, ".")) == 3 { - finalPath = path.Join(proposed, "modules", "discord_desktop_core") - } - - if selected == "modules" { - finalPath = path.Join(proposed, "discord_desktop_core") - } - - if selected == "discord_desktop_core" { - finalPath = proposed - } - - if Exists(finalPath) && Exists(path.Join(finalPath, "core.asar")) { - return finalPath - } - - return "" -} From bb4132c606a48812089e2a1cc93a0dcc9af8aef8 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 29 Nov 2025 23:50:27 -0500 Subject: [PATCH 3/4] Update commands --- cmd/install.go | 90 ++++--------------------------- cmd/uninstall.go | 46 ++++------------ internal/betterdiscord/install.go | 2 +- main.go | 3 ++ 4 files changed, 23 insertions(+), 118 deletions(-) diff --git a/cmd/install.go b/cmd/install.go index e8878e4..a7b808c 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -2,15 +2,12 @@ package cmd import ( "fmt" - "os" - "os/exec" "path" - "strings" "github.com/spf13/cobra" - models "github.com/betterdiscord/cli/internal/models" - utils "github.com/betterdiscord/cli/internal/utils" + "github.com/betterdiscord/cli/internal/discord" + "github.com/betterdiscord/cli/internal/models" ) func init() { @@ -25,88 +22,19 @@ var installCmd = &cobra.Command{ Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), Run: func(cmd *cobra.Command, args []string) { var releaseChannel = args[0] - var targetExe = "" - switch releaseChannel { - case "stable": - targetExe = "Discord.exe" - break - case "canary": - targetExe = "DiscordCanary.exe" - break - case "ptb": - targetExe = "DiscordPTB.exe" - break - default: - targetExe = "" - } - - // Kill Discord if it's running - var exe = utils.GetProcessExe(targetExe) - if len(exe) > 0 { - if err := utils.KillProcess(targetExe); err != nil { - fmt.Println("Could not kill Discord") - return - } - } - - // Make BD directories - if err := os.MkdirAll(utils.Data, 0755); err != nil { - fmt.Println("Could not create BetterDiscord folder") - return - } + var corePath = discord.GetSuggestedPath(models.ParseChannel(releaseChannel)) + var install = discord.ResolvePath(corePath) - if err := os.MkdirAll(utils.Plugins, 0755); err != nil { - fmt.Println("Could not create plugins folder") + if install == nil { + fmt.Printf("❌ Could not find a valid %s installation to install to.\n", releaseChannel) return } - if err := os.MkdirAll(utils.Themes, 0755); err != nil { - fmt.Println("Could not create theme folder") + if err := install.InstallBD(); err != nil { + fmt.Printf("❌ Installation failed: %s\n", err.Error()) return } - // Get download URL from GitHub API - var apiData, err = utils.DownloadJSON[models.Release]("https://api.github.com/repos/BetterDiscord/BetterDiscord/releases/latest") - if err != nil { - fmt.Println("Could not get API response") - fmt.Println(err) - return - } - - var index = 0 - for i, asset := range apiData.Assets { - if asset.Name == "betterdiscord.asar" { - index = i - break - } - } - - var downloadUrl = apiData.Assets[index].URL - - // Download asar into the BD folder - var asarPath = path.Join(utils.Data, "betterdiscord.asar") - err = utils.DownloadFile(downloadUrl, asarPath) - if err != nil { - fmt.Println("Could not download asar") - return - } - - // Inject shim loader - var corePath = utils.DiscordPath(releaseChannel) - - var indString = `require("` + asarPath + `");` - indString = strings.ReplaceAll(indString, `\`, "/") - indString = indString + "\nmodule.exports = require(\"./core.asar\");" - - if err := os.WriteFile(path.Join(corePath, "index.js"), []byte(indString), 0755); err != nil { - fmt.Println("Could not write index.js in discord_desktop_core!") - return - } - - // Launch Discord if we killed it - if len(exe) > 0 { - var cmd = exec.Command(exe) - cmd.Start() - } + fmt.Printf("✅ BetterDiscord installed to %s\n", path.Dir(install.GetPath())) }, } diff --git a/cmd/uninstall.go b/cmd/uninstall.go index 0bfeacf..1b058ee 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -2,13 +2,10 @@ package cmd import ( "fmt" - "os" - "os/exec" - "path" + "github.com/betterdiscord/cli/internal/discord" + "github.com/betterdiscord/cli/internal/models" "github.com/spf13/cobra" - - utils "github.com/betterdiscord/cli/internal/utils" ) func init() { @@ -23,42 +20,19 @@ var uninstallCmd = &cobra.Command{ Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), Run: func(cmd *cobra.Command, args []string) { var releaseChannel = args[0] - var corePath = utils.DiscordPath(releaseChannel) - var indString = "module.exports = require(\"./core.asar\");" + var corePath = discord.GetSuggestedPath(models.ParseChannel(releaseChannel)) + var install = discord.ResolvePath(corePath) - if err := os.WriteFile(path.Join(corePath, "index.js"), []byte(indString), 0755); err != nil { - fmt.Println("Could not write index.js in discord_desktop_core!") + if install == nil { + fmt.Printf("❌ Could not find a valid %s installation to uninstall.\n", releaseChannel) return } - var targetExe = "" - switch releaseChannel { - case "stable": - targetExe = "Discord.exe" - break - case "canary": - targetExe = "DiscordCanary.exe" - break - case "ptb": - targetExe = "DiscordPTB.exe" - break - default: - targetExe = "" - } - - // Kill Discord if it's running - var exe = utils.GetProcessExe(targetExe) - if len(exe) > 0 { - if err := utils.KillProcess(targetExe); err != nil { - fmt.Println("Could not kill Discord") - return - } + if err := install.UninstallBD(); err != nil { + fmt.Printf("❌ Uninstallation failed: %s\n", err.Error()) + return } - // Launch Discord if we killed it - if len(exe) > 0 { - var cmd = exec.Command(exe) - cmd.Start() - } + fmt.Printf("✅ BetterDiscord uninstalled from %s\n", corePath) }, } diff --git a/internal/betterdiscord/install.go b/internal/betterdiscord/install.go index 81c5173..12e85b4 100644 --- a/internal/betterdiscord/install.go +++ b/internal/betterdiscord/install.go @@ -78,7 +78,7 @@ func GetInstallation(base ...string) *BDInstall { } configDir, _ := os.UserConfigDir() - globalInstance = New(configDir) + globalInstance = GetInstallation(configDir) return globalInstance } diff --git a/main.go b/main.go index fd486d0..f34436d 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,12 @@ package main import ( + "log" + "github.com/betterdiscord/cli/cmd" ) func main() { + log.SetFlags(0) cmd.Execute() } From 0bf795f3f7b415f0652325cdd2f14c58afd51b06 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sun, 30 Nov 2025 05:46:33 -0500 Subject: [PATCH 4/4] Updates tooling and some new commands - CI/CD workflows for testing, linting, and building - An updated release process using GoReleaser - New basic commands list, paths, reinstall, and repair --- .github/workflows/ci.yml | 84 ++++++++++ .github/workflows/release.yml | 43 +++++ .gitignore | 5 + .goreleaser.yaml | 106 +++++++++++-- README.md | 226 +++++++++++++++++++++++++++ Taskfile.yml | 285 ++++++++++++++++++++++++++++++++++ cmd/list.go | 34 ++++ cmd/paths.go | 31 ++++ cmd/reinstall.go | 42 +++++ cmd/repair.go | 35 +++++ cmd/root.go | 1 + 11 files changed, 883 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 README.md create mode 100644 Taskfile.yml create mode 100644 cmd/list.go create mode 100644 cmd/paths.go create mode 100644 cmd/reinstall.go create mode 100644 cmd/repair.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8a2e9de --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,84 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + go-version: ['1.19', '1.20', '1.21'] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + cache: true + + - name: Download dependencies + run: go mod download + + - name: Verify dependencies + run: go mod verify + + - name: Run go vet + run: go vet ./... + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./... + + - name: Upload coverage to Codecov + if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.21' + uses: codecov/codecov-action@v4 + with: + file: ./coverage.out + flags: unittests + name: codecov-umbrella + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + cache: true + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v4 + with: + version: latest + args: --timeout=5m + + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + cache: true + + - name: Build + run: go build -v ./... + + - name: Test build with GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: latest + args: build --snapshot --clean diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ff4f03d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,43 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + packages: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.19' + cache: true + + - name: Run tests + run: go test -v -race ./... + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ diff --git a/.gitignore b/.gitignore index b947077..4db8cbf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ node_modules/ dist/ +.task/ + +# Test coverage +coverage.out +coverage.html diff --git a/.goreleaser.yaml b/.goreleaser.yaml index c9f8df7..0bec3fb 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,7 +1,17 @@ # yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# GoReleaser v2 configuration +version: 2 + +# Before hook - runs before the build +before: + hooks: + - go mod tidy + - go mod download builds: - - binary: bdcli + - id: bdcli + binary: bdcli + main: ./main.go env: - CGO_ENABLED=0 goos: @@ -13,22 +23,93 @@ builds: - arm64 - arm - '386' + goarm: + - '6' + - '7' ignore: - goos: darwin goarch: '386' + - goos: darwin + goarch: arm + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.Commit}} + - -X main.date={{.Date}} + mod_timestamp: '{{ .CommitTimestamp }}' archives: - - format: tar.gz - name_template: "{{.Binary}}_{{.Os}}_{{.Arch}}" + - id: default + format: tar.gz + name_template: "{{ .Binary }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" format_overrides: - - goos: windows - format: zip + - goos: windows + format: zip + files: + - LICENSE + - README.md + checksum: name_template: 'bdcli_checksums.txt' + algorithm: sha256 + snapshot: - name_template: "{{ incpatch .Version }}-next" + version_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + use: github + filters: + exclude: + - '^docs:' + - '^test:' + - '^chore:' + - Merge pull request + - Merge branch + groups: + - title: 'Features' + regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' + order: 0 + - title: 'Bug Fixes' + regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' + order: 1 + - title: 'Performance Improvements' + regexp: '^.*?perf(\([[:word:]]+\))??!?:.+$' + order: 2 + - title: 'Others' + order: 999 + release: draft: true + replace_existing_draft: true + target_commitish: '{{ .Commit }}' + prerelease: auto + mode: replace + header: | + ## BetterDiscord CLI {{ .Tag }} + + Install with npm: `npm install -g @betterdiscord/cli@{{ .Version }}` + footer: | + **Full Changelog**: https://github.com/BetterDiscord/cli/compare/{{ .PreviousTag }}...{{ .Tag }} + +# NPM publishing via go-npm +nfpms: + - id: packages + package_name: betterdiscord-cli + vendor: BetterDiscord + homepage: https://betterdiscord.app/ + maintainer: BetterDiscord Team + description: A cross-platform CLI for managing BetterDiscord + license: Apache-2.0 + formats: + - deb + - rpm + - apk + bindir: /usr/bin + contents: + - src: LICENSE + dst: /usr/share/doc/betterdiscord-cli/LICENSE + chocolateys: - name: betterdiscordcli owners: BetterDiscord @@ -37,13 +118,20 @@ chocolateys: project_url: https://betterdiscord.app/ url_template: "https://github.com/BetterDiscord/cli/releases/download/{{ .Tag }}/{{ .ArtifactName }}" icon_url: https://betterdiscord.app/resources/branding/logo_solid.png - copyright: 2023 BetterDiscord Limited + copyright: 2025 BetterDiscord Limited license_url: https://github.com/BetterDiscord/cli/blob/main/LICENSE project_source_url: https://github.com/BetterDiscord/cli docs_url: https://github.com/BetterDiscord/cli/wiki bug_tracker_url: https://github.com/BetterDiscord/cli/issues - tags: "betterdiscord cli" + tags: "betterdiscord cli discord" summary: A cross-platform CLI for managing BetterDiscord - description: A cross-platform CLI for managing BetterDiscord + description: | + A cross-platform CLI for managing BetterDiscord. + Provides commands to install, uninstall, and manage BetterDiscord on your system. release_notes: "https://github.com/BetterDiscord/cli/releases/tag/v{{ .Version }}" skip_publish: true + +# Git configuration +git: + ignore_tags: + - 'nightly' diff --git a/README.md b/README.md new file mode 100644 index 0000000..b6673c3 --- /dev/null +++ b/README.md @@ -0,0 +1,226 @@ +# BetterDiscord CLI + +[![Go Version](https://img.shields.io/github/go-mod/go-version/BetterDiscord/cli)](https://go.dev/) +[![Release](https://img.shields.io/github/v/release/BetterDiscord/cli)](https://github.com/BetterDiscord/cli/releases) +[![License](https://img.shields.io/github/license/BetterDiscord/cli)](LICENSE) +[![npm](https://img.shields.io/npm/v/@betterdiscord/cli)](https://www.npmjs.com/package/@betterdiscord/cli) + +A cross-platform command-line interface for installing, updating, and managing [BetterDiscord](https://betterdiscord.app/). + +## Features + +- 🚀 Easy installation and uninstallation of BetterDiscord +- 🔄 Support for multiple Discord channels (Stable, PTB, Canary) +- 🖥️ Cross-platform support (Windows, macOS, Linux) +- 📦 Available via npm for easy distribution +- ⚡ Fast and lightweight Go binary + +## Installation + +### Via npm (Recommended) + +```bash +npm install -g @betterdiscord/cli +``` + +### Via Go + +```bash +go install github.com/betterdiscord/cli@latest +``` + +### Download Binary + +Download the latest release for your platform from the [releases page](https://github.com/BetterDiscord/cli/releases). + +## Usage + +### Install BetterDiscord + +Install BetterDiscord to a specific Discord channel: + +```bash +bdcli install stable # Install to Discord Stable +bdcli install ptb # Install to Discord PTB +bdcli install canary # Install to Discord Canary +``` + +### Uninstall BetterDiscord + +Uninstall BetterDiscord from a specific Discord channel: + +```bash +bdcli uninstall stable # Uninstall from Discord Stable +bdcli uninstall ptb # Uninstall from Discord PTB +bdcli uninstall canary # Uninstall from Discord Canary +``` + +### Check Version + +```bash +bdcli version +``` + +### Help + +```bash +bdcli --help +bdcli --help +``` + +## Supported Platforms + +- **Windows** (x64, ARM64, x86) +- **macOS** (x64, ARM64/M1/M2) +- **Linux** (x64, ARM64, ARM) + +## Development + +### Prerequisites + +- [Go](https://go.dev/) 1.19 or higher +- [Task](https://taskfile.dev/) (optional, for task automation) +- [GoReleaser](https://goreleaser.com/) (for releases) + +### Setup + +Clone the repository and install dependencies: + +```bash +git clone https://github.com/BetterDiscord/cli.git +cd cli +task setup # Or: go mod download +``` + +### Available Tasks + +Run `task --list` to see all available tasks: + +```bash +# Development +task run # Run the CLI +task run:install # Test install command +task run:uninstall # Test uninstall command + +# Building +task build # Build for current platform +task build:all # Build for all platforms +task install # Install to $GOPATH/bin + +# Testing +task test # Run tests +task test:coverage # Run tests with coverage + +# Code Quality +task lint # Run linter +task fmt # Format code +task vet # Run go vet +task check # Run all checks + +# Release +task release:snapshot # Test release build +task release # Create release (requires tag) +``` + +### Running Locally + +```bash +# Run directly +go run main.go install stable + +# Or use Task +task run -- install stable +``` + +### Building + +```bash +# Build for current platform +task build + +# Build for all platforms +task build:all + +# Output will be in ./dist/ +``` + +### Testing + +```bash +# Run all tests +task test + +# Run with coverage +task test:coverage +``` + +### Releasing + +1. Create and push a new tag: + + ```bash + git tag -a v0.2.0 -m "Release v0.2.0" + git push origin v0.2.0 + ``` + +2. GitHub Actions will automatically build and create a draft release + +3. Edit the release notes and publish + +4. Publish to npm: + + ```bash + npm publish + ``` + +## Project Structure + +```py +. +├── cmd/ # Cobra commands +│ ├── install.go # Install command +│ ├── uninstall.go # Uninstall command +│ ├── version.go # Version command +│ └── root.go # Root command +├── internal/ # Internal packages +│ ├── betterdiscord/ # BetterDiscord installation logic +│ ├── discord/ # Discord path resolution and injection +│ ├── models/ # Data models +│ └── utils/ # Utility functions +├── main.go # Entry point +├── Taskfile.yml # Task automation +└── .goreleaser.yaml # Release configuration +``` + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'feat: add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. + +## Links + +- [BetterDiscord Website](https://betterdiscord.app/) +- [BetterDiscord Documentation](https://docs.betterdiscord.app/) +- [Issue Tracker](https://github.com/BetterDiscord/cli/issues) +- [npm Package](https://www.npmjs.com/package/@betterdiscord/cli) + +## Acknowledgments + +Built with: + +- [Cobra](https://github.com/spf13/cobra) - CLI framework +- [GoReleaser](https://goreleaser.com/) - Release automation +- [Task](https://taskfile.dev/) - Task runner + +--- + +Made with ❤️ by the BetterDiscord Team diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..939d81f --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,285 @@ +# https://taskfile.dev +version: '3' + +vars: + BINARY_NAME: bdcli + MAIN_PACKAGE: ./main.go + BUILD_DIR: ./dist + GO_VERSION: '1.19' + +env: + CGO_ENABLED: 0 + GOOS: '{{OS}}' + GOARCH: '{{ARCH}}' + +tasks: + default: + desc: List all available tasks + cmds: + - task --list-all + silent: true + + # Development tasks + run: + desc: Run the CLI application + cmds: + - go run {{.MAIN_PACKAGE}} {{.CLI_ARGS}} + sources: + - '**/*.go' + - go.mod + - go.sum + + run:install: + desc: Run the install command + cmds: + - go run {{.MAIN_PACKAGE}} install {{.CLI_ARGS}} + + run:uninstall: + desc: Run the uninstall command + cmds: + - go run {{.MAIN_PACKAGE}} uninstall {{.CLI_ARGS}} + + run:version: + desc: Run the version command + cmds: + - go run {{.MAIN_PACKAGE}} version {{.CLI_ARGS}} + + # Build tasks + build: + desc: Build the binary for current platform + cmds: + - go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}{{if eq OS "windows"}}.exe{{end}} {{.MAIN_PACKAGE}} + sources: + - '**/*.go' + - go.mod + - go.sum + generates: + - '{{.BUILD_DIR}}/{{.BINARY_NAME}}{{if eq OS "windows"}}.exe{{end}}' + + build:all: + desc: Build binaries for all platforms using GoReleaser + cmds: + - goreleaser build --snapshot --clean + sources: + - '**/*.go' + - go.mod + - go.sum + - .goreleaser.yaml + + build:linux: + desc: Build for Linux (amd64 and arm64) + cmds: + - GOOS=linux GOARCH=amd64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-linux-amd64 {{.MAIN_PACKAGE}} + - GOOS=linux GOARCH=arm64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-linux-arm64 {{.MAIN_PACKAGE}} + + build:windows: + desc: Build for Windows (amd64 and arm64) + cmds: + - GOOS=windows GOARCH=amd64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-windows-amd64.exe {{.MAIN_PACKAGE}} + - GOOS=windows GOARCH=arm64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-windows-arm64.exe {{.MAIN_PACKAGE}} + + build:darwin: + desc: Build for macOS (amd64 and arm64) + cmds: + - GOOS=darwin GOARCH=amd64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-darwin-amd64 {{.MAIN_PACKAGE}} + - GOOS=darwin GOARCH=arm64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-darwin-arm64 {{.MAIN_PACKAGE}} + + install: + desc: Install the binary to $GOPATH/bin + deps: [build] + cmds: + - go install {{.MAIN_PACKAGE}} + + # Testing tasks + test: + desc: Run all tests + cmds: + - go test -v -race ./... + + test:coverage: + desc: Run tests with coverage + cmds: + - go test -v -race -coverprofile=coverage.out -covermode=atomic ./... + - go tool cover -html=coverage.out -o coverage.html + generates: + - coverage.out + - coverage.html + + test:bench: + desc: Run benchmark tests + cmds: + - go test -bench=. -benchmem ./... + + # Code quality tasks + lint: + desc: Run golangci-lint + cmds: + - golangci-lint run ./... + + lint:fix: + desc: Run golangci-lint with auto-fix + cmds: + - golangci-lint run --fix ./... + + fmt: + desc: Format Go code + cmds: + - go fmt ./... + - gofumpt -l -w . + preconditions: + - sh: command -v gofumpt + msg: 'gofumpt not installed. Run: go install mvdan.cc/gofumpt@latest' + + fmt:check: + desc: Check if code is formatted + cmds: + - | + UNFORMATTED=$(gofmt -l .) + if [ -n "$UNFORMATTED" ]; then + echo "The following files are not gofmt-formatted:" >&2 + echo "$UNFORMATTED" >&2 + exit 1 + fi + # Also check gofumpt stricter formatting if available + if command -v gofumpt >/dev/null 2>&1; then + STRICT=$(gofumpt -l .) + if [ -n "$STRICT" ]; then + echo "The following files need gofumpt formatting:" >&2 + echo "$STRICT" >&2 + echo "Run: gofumpt -l -w ." >&2 + exit 1 + fi + fi + echo "Formatting OK" + preconditions: + - sh: command -v gofmt + msg: 'gofmt not found' + + vet: + desc: Run go vet + cmds: + - go vet ./... + + # Dependency management + deps: + desc: Download Go dependencies + cmds: + - go mod download + + deps:tidy: + desc: Tidy Go dependencies + cmds: + - go mod tidy + + deps:verify: + desc: Verify Go dependencies + cmds: + - go mod verify + + deps:upgrade: + desc: Upgrade all Go dependencies + cmds: + - go get -u ./... + - go mod tidy + + # Release tasks + release: + desc: Create a new release (runs GoReleaser) + cmds: + - goreleaser release --clean + preconditions: + - sh: command -v goreleaser + msg: 'goreleaser not installed. See: https://goreleaser.com/install/' + - sh: git diff-index --quiet HEAD + msg: 'Git working directory is not clean. Commit or stash changes first.' + + release:snapshot: + desc: Create a snapshot release (no publish) + cmds: + - goreleaser release --snapshot --clean + + release:test: + desc: Test the release process without publishing + cmds: + - goreleaser release --skip=publish --clean + + release:npm: + desc: Publish to npm after GitHub release + cmds: + - npm publish + preconditions: + - sh: test -f package.json + msg: 'package.json not found' + + # Cleanup tasks + clean: + desc: Clean build artifacts and caches + cmds: + - rm -rf {{.BUILD_DIR}} + - rm -rf coverage.out coverage.html + - go clean -cache -testcache -modcache + + clean:build: + desc: Clean only build artifacts + cmds: + - rm -rf {{.BUILD_DIR}} + + # Setup and validation tasks + setup: + desc: Setup development environment + cmds: + - task: deps + - task: tools + + tools: + desc: Install development tools + cmds: + - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + - go install mvdan.cc/gofumpt@latest + - go install github.com/goreleaser/goreleaser/v2@latest + + check: + desc: Run all checks (fmt, vet, lint, test) + cmds: + - task: fmt:check + - task: vet + - task: lint + - task: test + + validate: + desc: Validate goreleaser configuration + cmds: + - goreleaser check + preconditions: + - sh: command -v goreleaser + msg: 'goreleaser not installed. See: https://goreleaser.com/install/' + + # CI/CD tasks + ci: + desc: Run CI checks (used in CI/CD pipelines) + cmds: + - task: deps:verify + - task: fmt:check + - task: vet + - task: test:coverage + - task: build + + # Documentation tasks + docs: + desc: Generate CLI documentation + cmds: + - go run {{.MAIN_PACKAGE}} --help > docs/CLI.md + - echo "CLI documentation generated in docs/CLI.md" + + # Info tasks + info: + desc: Display project information + cmds: + - echo "Binary Name:{{.BINARY_NAME}}" + - echo "Go Version:{{.GO_VERSION}}" + - echo "Current OS:{{OS}}" + - echo "Current Arch:{{ARCH}}" + - go version + - echo "Git Branch:" && git branch --show-current + - echo "Git Commit:" && git rev-parse --short HEAD + silent: true diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..d59ee11 --- /dev/null +++ b/cmd/list.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "fmt" + + "github.com/betterdiscord/cli/internal/discord" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(listCmd) +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List detected Discord installations", + Long: "Scans common locations and lists detected Discord installations grouped by channel.", + Run: func(cmd *cobra.Command, args []string) { + installs := discord.GetAllInstalls() + if len(installs) == 0 { + fmt.Println("No Discord installations detected.") + return + } + for channel, arr := range installs { + if len(arr) == 0 { + continue + } + fmt.Printf("%s:\n", channel.Name()) + for _, inst := range arr { + fmt.Printf(" - %s (version %s)\n", inst.GetPath(), discord.GetVersion(inst.GetPath())) + } + } + }, +} diff --git a/cmd/paths.go b/cmd/paths.go new file mode 100644 index 0000000..b741eba --- /dev/null +++ b/cmd/paths.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "fmt" + + "github.com/betterdiscord/cli/internal/discord" + "github.com/betterdiscord/cli/internal/models" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(pathsCmd) +} + +var pathsCmd = &cobra.Command{ + Use: "paths", + Short: "Show suggested Discord install paths", + Long: "Displays the suggested core installation path per Discord channel detected on this system.", + Run: func(cmd *cobra.Command, args []string) { + channels := []models.DiscordChannel{models.Stable, models.PTB, models.Canary} + for _, ch := range channels { + p := discord.GetSuggestedPath(ch) + name := ch.Name() + if p == "" { + fmt.Printf("%s: (none detected)\n", name) + } else { + fmt.Printf("%s: %s\n", name, p) + } + } + }, +} diff --git a/cmd/reinstall.go b/cmd/reinstall.go new file mode 100644 index 0000000..af1ac32 --- /dev/null +++ b/cmd/reinstall.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "fmt" + + "github.com/betterdiscord/cli/internal/discord" + "github.com/betterdiscord/cli/internal/models" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(reinstallCmd) +} + +var reinstallCmd = &cobra.Command{ + Use: "reinstall ", + Short: "Uninstall and then reinstall BetterDiscord", + Long: "Performs an uninstall followed by an install of BetterDiscord for the specified Discord channel.", + ValidArgs: []string{"canary", "stable", "ptb"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + Run: func(cmd *cobra.Command, args []string) { + releaseChannel := args[0] + corePath := discord.GetSuggestedPath(models.ParseChannel(releaseChannel)) + inst := discord.ResolvePath(corePath) + if inst == nil { + fmt.Printf("❌ Could not find a valid %s installation to reinstall.\n", releaseChannel) + return + } + + if err := inst.UninstallBD(); err != nil { + fmt.Printf("❌ Uninstall failed: %s\n", err.Error()) + return + } + + if err := inst.InstallBD(); err != nil { + fmt.Printf("❌ Install failed: %s\n", err.Error()) + return + } + + fmt.Println("✅ BetterDiscord reinstalled successfully") + }, +} diff --git a/cmd/repair.go b/cmd/repair.go new file mode 100644 index 0000000..db6dc48 --- /dev/null +++ b/cmd/repair.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "fmt" + + "github.com/betterdiscord/cli/internal/discord" + "github.com/betterdiscord/cli/internal/models" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(repairCmd) +} + +var repairCmd = &cobra.Command{ + Use: "repair ", + Short: "Repairs the BetterDiscord installation", + Long: "Attempts to repair the BetterDiscord setup for the specified Discord channel (e.g., disables problematic plugins).", + ValidArgs: []string{"canary", "stable", "ptb"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + Run: func(cmd *cobra.Command, args []string) { + releaseChannel := args[0] + corePath := discord.GetSuggestedPath(models.ParseChannel(releaseChannel)) + inst := discord.ResolvePath(corePath) + if inst == nil { + fmt.Printf("❌ Could not find a valid %s installation to repair.\n", releaseChannel) + return + } + if err := inst.RepairBD(); err != nil { + fmt.Printf("❌ Repair failed: %s\n", err.Error()) + return + } + fmt.Println("✅ Repair completed successfully") + }, +} diff --git a/cmd/root.go b/cmd/root.go index 57d70c6..b74e7df 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,6 +17,7 @@ var rootCmd = &cobra.Command{ Long: `A cross-platform CLI for installing, updating, and managing BetterDiscord.`, Run: func(cmd *cobra.Command, args []string) { // Do Stuff Here + fmt.Println("You should probably use a subcommand") }, }