diff --git a/cmd/install.go b/cmd/install.go index d889e12..7916426 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -2,7 +2,9 @@ package cmd import ( "fmt" + "os" "path" + "strings" "github.com/spf13/cobra" @@ -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) } @@ -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") @@ -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 { @@ -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) } @@ -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" +} diff --git a/cmd/update.go b/cmd/update.go index 94b0d2c..eca9f90 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -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", @@ -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) } diff --git a/internal/betterdiscord/download.go b/internal/betterdiscord/download.go index e959e62..4582309 100644 --- a/internal/betterdiscord/download.go +++ b/internal/betterdiscord/download.go @@ -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") @@ -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 } @@ -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 @@ -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 diff --git a/internal/betterdiscord/download_test.go b/internal/betterdiscord/download_test.go index dde2214..ad94150 100644 --- a/internal/betterdiscord/download_test.go +++ b/internal/betterdiscord/download_test.go @@ -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")) @@ -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() { @@ -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() { @@ -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 @@ -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) } -} \ No newline at end of file +} diff --git a/internal/betterdiscord/install.go b/internal/betterdiscord/install.go index 7bce735..bec02ec 100644 --- a/internal/betterdiscord/install.go +++ b/internal/betterdiscord/install.go @@ -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 diff --git a/internal/discord/injection.go b/internal/discord/injection.go index 35b2cdf..a81ed6c 100644 --- a/internal/discord/injection.go +++ b/internal/discord/injection.go @@ -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")) -} \ No newline at end of file +} diff --git a/internal/discord/injection_test.go b/internal/discord/injection_test.go index e831955..967d9e8 100644 --- a/internal/discord/injection_test.go +++ b/internal/discord/injection_test.go @@ -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") } -} \ No newline at end of file +} diff --git a/internal/discord/install.go b/internal/discord/install.go index 54fbe83..67449cb 100644 --- a/internal/discord/install.go +++ b/internal/discord/install.go @@ -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 @@ -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") diff --git a/internal/discord/install_test.go b/internal/discord/install_test.go index 215ed25..3db2a9f 100644 --- a/internal/discord/install_test.go +++ b/internal/discord/install_test.go @@ -88,4 +88,4 @@ func TestGetBetterDiscordInstall_FlatpakRecomputesDataRoot(t *testing.T) { t.Errorf("channel %v: Root() = %s, expected %s", tc.channel, bd.Root(), want) } } -} \ No newline at end of file +} diff --git a/internal/discord/paths.go b/internal/discord/paths.go index 3351df9..623e536 100644 --- a/internal/discord/paths.go +++ b/internal/discord/paths.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/betterdiscord/cli/internal/models" + "github.com/betterdiscord/cli/internal/utils" ) var searchPaths []string @@ -28,7 +29,7 @@ func GetAllInstalls() map[models.DiscordChannel][]*DiscordInstall { } func GetVersion(proposed string) string { - for _, folder := range strings.Split(filepath.ToSlash(proposed), "/") { + for folder := range strings.SplitSeq(filepath.ToSlash(proposed), "/") { if version := versionRegex.FindString(folder); version != "" { return version } @@ -46,11 +47,11 @@ func GetChannel(proposed string) models.DiscordChannel { // interchangeably) still segments cleanly. segments := strings.Split(filepath.ToSlash(proposed), "/") - for i := len(segments) - 1; i >= 0; i-- { + for _, segment := range slices.Backward(segments) { // Normalize the segment so macOS bundle names ("Discord Canary.app") and // flatpak channel dirs ("discord-canary") both match the channel names // ("discordcanary"). - normalized := strings.ToLower(segments[i]) + normalized := strings.ToLower(segment) normalized = strings.TrimSuffix(normalized, ".app") normalized = strings.ReplaceAll(normalized, " ", "") normalized = strings.ReplaceAll(normalized, "-", "") @@ -104,13 +105,9 @@ func ResolvePath(proposed string) *DiscordInstall { 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 + // Descending (highest version first) with a numeric compare so + // e.g. 1.0.10000 sorts above 1.0.9999. + return utils.CompareVersions(b.Version, a.Version) }) } } diff --git a/internal/discord/paths_common.go b/internal/discord/paths_common.go index 24db97b..63bf890 100644 --- a/internal/discord/paths_common.go +++ b/internal/discord/paths_common.go @@ -189,4 +189,4 @@ func validateUnixStyleInstall(proposed string, detectFlatpak bool, detectSnap bo } return install -} \ No newline at end of file +} diff --git a/internal/discord/paths_common_test.go b/internal/discord/paths_common_test.go index b51986e..9d3077b 100644 --- a/internal/discord/paths_common_test.go +++ b/internal/discord/paths_common_test.go @@ -320,4 +320,4 @@ func TestNewResourcesInstall_FallsBackToPath(t *testing.T) { if install.Version != "0.0.90" { t.Errorf("Version = %q, expected 0.0.90 from path", install.Version) } -} \ No newline at end of file +} diff --git a/internal/discord/paths_test.go b/internal/discord/paths_test.go index d62970a..420c2a1 100644 --- a/internal/discord/paths_test.go +++ b/internal/discord/paths_test.go @@ -142,9 +142,9 @@ func TestGetChannel(t *testing.T) { expected: models.Stable, }, { - name: "Multiple Discord mentions (first wins)", + name: "Multiple Discord mentions (nearest wins)", path: filepath.Join("discordcanary", "discord", "modules"), - expected: models.Canary, + expected: models.Stable, // The nearest segment is "discord", which maps to Stable }, { name: "Empty path defaults to Stable", @@ -184,7 +184,7 @@ func TestGetChannel(t *testing.T) { t.Run(tt.name, func(t *testing.T) { result := GetChannel(tt.path) if result != tt.expected { - t.Errorf("GetChannel(%s) = %v (%s), expected %v (%s)", + t.Errorf("GetChannel(%q) = %v (%s), expected %v (%s)", tt.path, result, result.String(), tt.expected, tt.expected.String()) } }) @@ -289,8 +289,8 @@ func TestResolvePath(t *testing.T) { // Add a test install with new path format testInstall := &DiscordInstall{ ResourcesPath: "/home/user/.config/discord/app-1.0.0/modules/discord_desktop_core-1/discord_desktop_core/core.asar", - Channel: models.Stable, - Version: "1.0.0", + Channel: models.Stable, + Version: "1.0.0", } allDiscordInstalls[models.Stable] = []*DiscordInstall{testInstall} diff --git a/internal/discord/process.go b/internal/discord/process.go index af0e295..b78aeed 100644 --- a/internal/discord/process.go +++ b/internal/discord/process.go @@ -139,10 +139,11 @@ func (discord *DiscordInstall) kill() error { if killErr != nil { return killErr } + signaled = true } } - if !signaled { + if !signaled { return nil } diff --git a/internal/utils/strings.go b/internal/utils/strings.go index abd4cff..c97814e 100644 --- a/internal/utils/strings.go +++ b/internal/utils/strings.go @@ -79,4 +79,4 @@ func SplitVersion(v string) []string { parts = append(parts, current) } return parts -} \ No newline at end of file +} diff --git a/internal/utils/strings_test.go b/internal/utils/strings_test.go index 8f0b0de..0deb2ae 100644 --- a/internal/utils/strings_test.go +++ b/internal/utils/strings_test.go @@ -132,4 +132,4 @@ func TestCompareVersions(t *testing.T) { } }) } -} \ No newline at end of file +} diff --git a/internal/wsl/wsl_test.go b/internal/wsl/wsl_test.go index c020c85..e347bd0 100644 --- a/internal/wsl/wsl_test.go +++ b/internal/wsl/wsl_test.go @@ -47,4 +47,4 @@ func TestInfo_WSLDistroName(t *testing.T) { if info.InteropPath != "interop-path" { t.Fatalf("Expected InteropPath interop-path, got %q", info.InteropPath) } -} \ No newline at end of file +}