Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
30b6bfc
fix: use new injection assets
zerebos Jul 30, 2026
ac98528
fix: embed assets
zerebos Jul 30, 2026
b3676e4
feat: add options to different operations
zerebos Jul 30, 2026
00b89ae
fix: better error handling
zerebos Jul 30, 2026
8bc0da4
feat: switch to tracking resources dir
zerebos Jul 30, 2026
1b01758
fix: use resources path everywhere
zerebos Jul 30, 2026
c9825bc
feat: add more testing
zerebos Jul 30, 2026
4b9b529
feat: more tests before refactoring
zerebos Jul 30, 2026
8148b1f
refactor: simplify the path resolution
zerebos Jul 30, 2026
cf7b35b
feat: rework injection to match app.asar
zerebos Jul 30, 2026
3549c30
chore: update related tests
zerebos Jul 30, 2026
ae23d8b
feat: update paths for macos + linux + flatpak
zerebos Jul 30, 2026
56b6f94
fix: remove references to corepath
zerebos Jul 31, 2026
845219b
fix: resolve injected folders
zerebos Jul 31, 2026
1830764
fix: reinject should always work
zerebos Jul 31, 2026
af10aa8
fix: add additional sanity checks
zerebos Jul 31, 2026
f2b8df2
fix: add protection for empty resources dir
zerebos Jul 31, 2026
f1d8f0c
chore: update injection js
zerebos Jul 31, 2026
1e1336a
fix: more edge cases
zerebos Jul 31, 2026
a480b9c
feat: kill discord before injection
zerebos Jul 31, 2026
900ad1d
fix: yet more sanity checking
zerebos Aug 1, 2026
812d894
fix: add note for wsl and kill timeout
zerebos Aug 1, 2026
f65b892
fix: more protection edge cases
zerebos Aug 1, 2026
f88bb24
chore: more protection for snap installs
zerebos Aug 1, 2026
2f7bfef
fix: edge case handling
zerebos Aug 1, 2026
bf87423
fix: cleanly resolve flaky enumeration
zerebos Aug 1, 2026
14baf96
feat: support downloading rolling release
zerebos Aug 2, 2026
b0bea8c
feat: add dev build options
zerebos Aug 2, 2026
8fcfb53
fix: run gofmt
zerebos Aug 3, 2026
b074497
fix: make dev flag install only
zerebos Aug 3, 2026
96778e3
fix: fix version folder sorting
zerebos Aug 3, 2026
62d94c5
Merge pull request #11 from BetterDiscord/feat/bd-canary
zerebos Aug 4, 2026
1d2c425
fix: path should be labelled as resources
zerebos Aug 4, 2026
4028ca3
feat: additional rollback protection
zerebos Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ var discoverInstallsCmd = &cobra.Command{
if inst.IsInjected() {
bdStatus = "yes"
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", ch.Name(), inst.Version, typeLabel, bdStatus, inst.CorePath)
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", ch.Name(), inst.Version, typeLabel, bdStatus, inst.ResourcesPath)
}
}

Expand Down
37 changes: 31 additions & 6 deletions cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package cmd

import (
"fmt"
"os"
"path"
"strings"

"github.com/spf13/cobra"

Expand All @@ -14,6 +16,7 @@ import (
func init() {
installCmd.Flags().StringP("path", "p", "", "Path to a Discord installation")
installCmd.Flags().StringP("channel", "c", "stable", "Discord release channel (stable|ptb|canary)")
installCmd.Flags().Bool("dev", false, "Use the development build of BetterDiscord")
rootCmd.AddCommand(installCmd)
}

Expand All @@ -23,6 +26,7 @@ var installCmd = &cobra.Command{
Short: "Installs BetterDiscord to your Discord",
Long: "Install BetterDiscord by specifying either --path to a Discord install or --channel to auto-detect (default: stable).",
RunE: func(cmd *cobra.Command, args []string) error {
// Handle path and channel flags, ensuring they are mutually exclusive
pathFlag, _ := cmd.Flags().GetString("path")
channelFlag, _ := cmd.Flags().GetString("channel")

Expand All @@ -33,6 +37,14 @@ var installCmd = &cobra.Command{
return fmt.Errorf("--path and --channel are mutually exclusive")
}

// Check if the --dev flag is set or if the BDCLI_DEV_BUILD environment variable is enabled
useDevBuild := false
devFlag, _ := cmd.Flags().GetBool("dev")
if devFlag || isDevBuildEnvEnabled() {
useDevBuild = true
output.Println("⚠️ Using development build of BetterDiscord")
}

var install *discord.DiscordInstall

if pathProvided {
Expand All @@ -42,18 +54,18 @@ var installCmd = &cobra.Command{
}
} else {
channel := models.ParseChannel(channelFlag)
corePath := discord.GetSuggestedPath(channel)
install = discord.ResolvePath(corePath)
resourcesPath := discord.GetSuggestedPath(channel)
install = discord.ResolvePath(resourcesPath)
if install == nil {
return fmt.Errorf("could not find a valid %s installation to install to", channelFlag)
}
}

if err := install.InstallBD(); err != nil {
if err := install.InstallBD(models.InstallOptions{RestartDiscord: true, UseDevBuild: useDevBuild}); err != nil {
return fmt.Errorf("installation failed: %w", err)
}

output.Printf("✅ BetterDiscord installed to %s\n", path.Dir(install.CorePath))
output.Printf("✅ BetterDiscord installed to %s\n", path.Dir(install.ResourcesPath))
output.Blank()
output.Printf("📋 Installation Summary:\n")
output.Blank()
Expand All @@ -67,11 +79,24 @@ var installCmd = &cobra.Command{
}
return "native"
}())
output.Printf(" Core Path: %s\n", path.Dir(install.CorePath))
output.Printf(" Resources Path: %s\n", path.Dir(install.ResourcesPath))
output.Blank()

bdinstall := install.GetBetterDiscordInstall()
bdinstall, err := install.GetBetterDiscordInstall()
if err != nil {
output.Printf("failed to get BetterDiscord install info: %s\n", err.Error())
return nil
}
if bdinstall == nil {
output.Printf("BetterDiscord install info is nil\n")
return nil
}
Comment thread
zerebos marked this conversation as resolved.
bdinstall.LogBuildinfo()
return nil
},
}

func isDevBuildEnvEnabled() bool {
value := strings.TrimSpace(strings.ToLower(os.Getenv("BDCLI_DEV_BUILD")))
return value == "1" || value == "true" || value == "yes"
}
18 changes: 9 additions & 9 deletions cmd/uninstall.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,11 @@ var uninstallCmd = &cobra.Command{
}
}

if err := install.UninstallBD(); err != nil {
if err := install.UninstallBD(models.UninstallOptions{FullUninstall: false, RestartDiscord: true}); err != nil {
return fmt.Errorf("uninstallation failed: %w", err)
}

output.Printf("✅ BetterDiscord uninstalled from %s\n", path.Dir(install.CorePath))
output.Printf("✅ BetterDiscord uninstalled from %s\n", path.Dir(install.ResourcesPath))
return nil
},
}
Expand All @@ -105,7 +105,7 @@ func getAllInstalls() []*discord.DiscordInstall {
seen := map[string]bool{}
var installs []*discord.DiscordInstall

// Flatten the map of installs and filter out duplicates based on CorePath
// Flatten the map of installs and filter out duplicates based on ResourcesPath
// Honestly, probably should have just returned a flat list from GetAllInstalls in the first place, but whatever
// And also the chance of actually having duplicates is pretty much zero, but this is just in case
// If you are reading this and you do have duplicates, please tell me because that would be very interesting and I would like to know how that happened
Expand All @@ -115,10 +115,10 @@ func getAllInstalls() []*discord.DiscordInstall {
if inst == nil {
continue
}
if seen[inst.CorePath] {
if seen[inst.ResourcesPath] {
continue
}
seen[inst.CorePath] = true
seen[inst.ResourcesPath] = true
installs = append(installs, inst)
}
}
Expand All @@ -129,11 +129,11 @@ func getAllInstalls() []*discord.DiscordInstall {
func uninstallAll(installs []*discord.DiscordInstall) error {
var firstErr error
for _, inst := range installs {
if err := inst.UninstallBD(); err != nil {
if err := inst.UninstallBD(models.UninstallOptions{FullUninstall: false, RestartDiscord: true}); err != nil {
if firstErr == nil {
firstErr = err
}
output.Printf("❌ Failed to uninstall from %s\n", path.Dir(inst.CorePath))
output.Printf("❌ Failed to uninstall from %s\n", path.Dir(inst.ResourcesPath))
output.Printf(" %s\n", err.Error())
}
}
Expand All @@ -148,8 +148,8 @@ func removeAllBetterDiscord(installs []*discord.DiscordInstall) error {
// so we need to filter them out to avoid trying to delete the same
// folder multiple times
for _, inst := range installs {
bd := inst.GetBetterDiscordInstall()
if bd == nil {
bd, err := inst.GetBetterDiscordInstall()
if err != nil || bd == nil {
continue
}
roots[bd.Root()] = bd
Expand Down
7 changes: 6 additions & 1 deletion cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ func init() {
rootCmd.AddCommand(updateCmd)
}

// This currently only checks for updates to the BetterDiscord loader (betterdiscord.asar).
// In the future, we may also want to check for updates to the CLI itself.
// This also only checks for updates to the stable release as the check is cheap (tag name)
// the canary version is a rolling release and is not versioned, so it is not as easily
// possible to check for updates to it.
var updateCmd = &cobra.Command{
Use: "update",
Short: "Update BetterDiscord to the latest version",
Expand Down Expand Up @@ -62,7 +67,7 @@ var updateCmd = &cobra.Command{

// Download the latest version
output.Println("📥 Downloading update...")
if err := bdinstall.Download(); err != nil {
if err := bdinstall.Download(false); err != nil {
return fmt.Errorf("failed to download update: %w", err)
}

Expand Down
53 changes: 41 additions & 12 deletions internal/betterdiscord/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,33 @@ import (
"github.com/betterdiscord/cli/internal/utils"
)

func (i *BDInstall) download() error {
// Endpoints for fetching the BetterDiscord asar. Declared as package vars so
// tests can point them at a local httptest server.
var (
websiteAsarURL = "https://betterdiscord.app/Download/betterdiscord.asar"
githubLatestReleaseURL = "https://api.github.com/repos/BetterDiscord/BetterDiscord/releases/latest"
// githubCanaryReleaseURL is the rolling pre-release tagged "canary" (rebuilt
// on every merge to the development branch). It is GitHub-only — the website
// has no mirror — so the dev-build path fetches it by tag rather than via the
// "latest" endpoint, which excludes pre-releases by design.
githubCanaryReleaseURL = "https://api.github.com/repos/BetterDiscord/BetterDiscord/releases/tags/canary"
)

func (i *BDInstall) download(useDevBuild bool) error {
if i.hasDownloaded {
output.Printf("✅ Already downloaded to %s\n", i.asar)
return nil
}

resp, err := utils.DownloadFile("https://betterdiscord.app/Download/betterdiscord.asar", i.asar)
// The development build lives only on GitHub, so skip the website leg and go
// straight to the canary release. A failure here must NOT fall back to the
// stable asar: a developer who asked for the dev build silently receiving
// stable is a confusing, near-undetectable footgun.
if useDevBuild {
return i.downloadFromGitHubRelease(githubCanaryReleaseURL, "GitHub (development build)")
}

resp, err := utils.DownloadFile(websiteAsarURL, i.asar)
if err == nil {
version := resp.Header.Get("x-bd-version")
if version == "" {
Expand All @@ -31,10 +51,16 @@ func (i *BDInstall) download() error {
output.Println("🔁 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")
return i.downloadFromGitHubRelease(githubLatestReleaseURL, "GitHub")
}

// downloadFromGitHubRelease fetches the release metadata at apiURL, locates the
// betterdiscord.asar asset, and downloads it into the BD folder. sourceLabel is
// used only for logging (e.g. "GitHub" or "GitHub (development build)").
func (i *BDInstall) downloadFromGitHubRelease(apiURL, sourceLabel string) error {
apiData, err := utils.DownloadJSON[models.GitHubRelease](apiURL)
if err != nil {
output.Println("❌ Failed to get asset url from GitHub")
output.Printf("❌ Failed to get asset url from %s\n", sourceLabel)
output.Printf("❌ %s\n", err.Error())
return err
}
Expand All @@ -48,8 +74,8 @@ func (i *BDInstall) download() error {
}

if index == -1 {
output.Println("❌ Failed to find the BetterDiscord asar on GitHub")
return fmt.Errorf("failed to find betterdiscord.asar asset in GitHub release")
output.Printf("❌ Failed to find the BetterDiscord asar on %s\n", sourceLabel)
return fmt.Errorf("failed to find betterdiscord.asar asset in %s release", sourceLabel)
}

var downloadUrl = apiData.Assets[index].URL
Expand All @@ -62,15 +88,18 @@ func (i *BDInstall) download() error {
// Download asar into the BD folder
_, err = utils.DownloadFile(downloadUrl, i.asar)
if err != nil {
output.Println("❌ Failed to download BetterDiscord from GitHub")
output.Printf("❌ Failed to download BetterDiscord from %s\n", sourceLabel)
output.Printf("❌ %s\n", err.Error())
return err
}

if version == "" {
output.Println("✅ Downloaded BetterDiscord from GitHub")
} else {
output.Printf("✅ Downloaded BetterDiscord version %s from GitHub\n", output.FormatVersion(version))
switch version {
case "":
output.Printf("✅ Downloaded BetterDiscord from %s\n", sourceLabel)
case "canary":
output.Printf("✅ Downloaded BetterDiscord development build from %s\n", sourceLabel)
default:
output.Printf("✅ Downloaded BetterDiscord version %s from %s\n", output.FormatVersion(version), sourceLabel)
}
i.hasDownloaded = true

Expand Down
Loading