Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 18 additions & 1 deletion 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 @@ -49,7 +61,7 @@ var installCmd = &cobra.Command{
}
}

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

Expand Down Expand Up @@ -83,3 +95,8 @@ var installCmd = &cobra.Command{
return nil
},
}

func isDevBuildEnvEnabled() bool {
value := strings.TrimSpace(strings.ToLower(os.Getenv("BDCLI_DEV_BUILD")))
return value == "1" || value == "true" || value == "yes"
}
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)
Comment thread
zerebos marked this conversation as resolved.
}

Expand Down
44 changes: 33 additions & 11 deletions internal/betterdiscord/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,27 @@ import (
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() error {
func (i *BDInstall) download(useDevBuild bool) error {
if i.hasDownloaded {
output.Printf("✅ Already downloaded to %s\n", i.asar)
return nil
}

// 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")
Expand All @@ -38,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](githubLatestReleaseURL)
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 @@ -55,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 @@ -69,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
91 changes: 86 additions & 5 deletions internal/betterdiscord/download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ func withURLs(t *testing.T, website, github string) {
})
}

// withCanaryURL temporarily overrides the canary (development build) endpoint for
// a test and restores it on cleanup.
func withCanaryURL(t *testing.T, canary string) {
t.Helper()
orig := githubCanaryReleaseURL
githubCanaryReleaseURL = canary
t.Cleanup(func() {
githubCanaryReleaseURL = orig
})
}

func newBDInstallWithDataDir(t *testing.T) *BDInstall {
t.Helper()
install := New(filepath.Join(t.TempDir(), "BetterDiscord"))
Expand Down Expand Up @@ -59,7 +70,7 @@ func TestDownload_FromWebsite(t *testing.T) {
withURLs(t, website.URL, github.URL)

install := newBDInstallWithDataDir(t)
if err := install.download(); err != nil {
if err := install.download(false); err != nil {
t.Fatalf("download() failed: %v", err)
}
if !install.HasDownloaded() {
Expand Down Expand Up @@ -89,7 +100,7 @@ func TestDownload_FallsBackToGitHub(t *testing.T) {
withURLs(t, website.URL, github.URL)

install := newBDInstallWithDataDir(t)
if err := install.download(); err != nil {
if err := install.download(false); err != nil {
t.Fatalf("download() failed: %v", err)
}
if !install.HasDownloaded() {
Expand All @@ -112,11 +123,81 @@ func TestDownload_GitHubMissingAsset(t *testing.T) {
withURLs(t, website.URL, github.URL)

install := newBDInstallWithDataDir(t)
if err := install.download(); err == nil {
if err := install.download(false); err == nil {
t.Fatal("expected an error when the betterdiscord.asar asset is missing")
}
}

func TestDownload_DevBuildUsesCanaryAndSkipsWebsite(t *testing.T) {
const body = "asar-from-canary"
asset := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, body)
}))
defer asset.Close()

// Neither the website nor the "latest" endpoint should be touched for a dev build.
website := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("website should not be called for the development build")
http.Error(w, "unexpected", http.StatusInternalServerError)
}))
defer website.Close()

github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("the GitHub latest endpoint should not be called for the development build")
http.Error(w, "unexpected", http.StatusInternalServerError)
}))
defer github.Close()

canary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{"tag_name":"canary","assets":[{"name":"betterdiscord.asar","url":%q}]}`, asset.URL)
}))
defer canary.Close()

withURLs(t, website.URL, github.URL)
withCanaryURL(t, canary.URL)

install := newBDInstallWithDataDir(t)
if err := install.download(true); err != nil {
t.Fatalf("download(true) failed: %v", err)
}
if !install.HasDownloaded() {
t.Error("expected HasDownloaded() to be true after canary download")
}
assertFileContents(t, install.Asar(), body)
}

func TestDownload_DevBuildHardFailsWithoutStableFallback(t *testing.T) {
// The canary release is unreachable. The dev build must fail rather than
// silently falling back to the website or the stable GitHub release.
website := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("website should not be called as a fallback for the development build")
http.Error(w, "unexpected", http.StatusInternalServerError)
}))
defer website.Close()

github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("the GitHub latest endpoint should not be called as a fallback for the development build")
http.Error(w, "unexpected", http.StatusInternalServerError)
}))
defer github.Close()

canary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "down", http.StatusInternalServerError)
}))
defer canary.Close()

withURLs(t, website.URL, github.URL)
withCanaryURL(t, canary.URL)

install := newBDInstallWithDataDir(t)
if err := install.download(true); err == nil {
t.Fatal("expected an error when the canary release is unreachable")
}
if install.HasDownloaded() {
t.Error("expected HasDownloaded() to remain false after a failed dev build download")
}
}

func TestDownload_SkipsWhenAlreadyDownloaded(t *testing.T) {
install := newBDInstallWithDataDir(t)
install.hasDownloaded = true
Expand All @@ -125,7 +206,7 @@ func TestDownload_SkipsWhenAlreadyDownloaded(t *testing.T) {
// never touched when the asar is already downloaded.
withURLs(t, "http://127.0.0.1:0", "http://127.0.0.1:0")

if err := install.download(); err != nil {
if err := install.download(false); err != nil {
t.Fatalf("download() should be a no-op when already downloaded: %v", err)
}
}
}
7 changes: 4 additions & 3 deletions internal/betterdiscord/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@ func (i *BDInstall) HasDownloaded() bool {
return i.hasDownloaded
}

// Download downloads the BetterDiscord asar file
func (i *BDInstall) Download() error {
return i.download()
// Download downloads the BetterDiscord asar file. When useDevBuild is true it
// pulls the rolling "canary" pre-release from GitHub instead of the stable asar.
func (i *BDInstall) Download(useDevBuild bool) error {
return i.download(useDevBuild)
}

// Prepare creates all necessary directories for BetterDiscord
Expand Down
2 changes: 1 addition & 1 deletion internal/discord/injection.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,4 +232,4 @@ func (discord *DiscordInstall) IsInjected() bool {
return false
}
return utils.Exists(filepath.Join(resources, "app", "index.js")) && utils.Exists(filepath.Join(resources, "betterdiscord.app.asar"))
}
}
2 changes: 1 addition & 1 deletion internal/discord/injection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -389,4 +389,4 @@ func TestInject_RollbackOnMidOpFailure(t *testing.T) {
if utils.Exists(filepath.Join(resources, "betterdiscord.app.asar")) {
t.Error("preserved asar should be gone after rollback")
}
}
}
12 changes: 6 additions & 6 deletions internal/discord/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ import (
)

type DiscordInstall struct {
ResourcesPath string `json:"resourcesPath"`
Channel models.DiscordChannel `json:"channel"`
Version string `json:"version"`
IsFlatpak bool `json:"isFlatpak"`
IsSnap bool `json:"isSnap"`
ResourcesPath string `json:"resourcesPath"`
Channel models.DiscordChannel `json:"channel"`
Version string `json:"version"`
IsFlatpak bool `json:"isFlatpak"`
IsSnap bool `json:"isSnap"`
}

// InstallBD installs BetterDiscord into this Discord installation
Expand All @@ -41,7 +41,7 @@ func (discord *DiscordInstall) InstallBD(options models.InstallOptions) error {

// Download and write betterdiscord.asar
output.Println("📥 Downloading BetterDiscord...")
if err := bd.Download(); err != nil {
if err := bd.Download(options.UseDevBuild); err != nil {
return err
}
output.Println("✅ BetterDiscord downloaded")
Expand Down
2 changes: 1 addition & 1 deletion internal/discord/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,4 @@ func TestGetBetterDiscordInstall_FlatpakRecomputesDataRoot(t *testing.T) {
t.Errorf("channel %v: Root() = %s, expected %s", tc.channel, bd.Root(), want)
}
}
}
}
Loading