From 30b6bfcd7bee1a79a4b73c25c4f37c5b3854ed8e Mon Sep 17 00:00:00 2001 From: Zerebos Date: Thu, 30 Jul 2026 09:27:57 -0400 Subject: [PATCH 01/36] fix: use new injection assets --- internal/discord/assets/{injection.js => app_index.js} | 10 +++++----- internal/discord/assets/app_package.json | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) rename internal/discord/assets/{injection.js => app_index.js} (61%) create mode 100644 internal/discord/assets/app_package.json diff --git a/internal/discord/assets/injection.js b/internal/discord/assets/app_index.js similarity index 61% rename from internal/discord/assets/injection.js rename to internal/discord/assets/app_index.js index 55f2f3b..8a6c621 100644 --- a/internal/discord/assets/injection.js +++ b/internal/discord/assets/app_index.js @@ -1,9 +1,9 @@ -// BetterDiscord's Injection Script +// BetterDiscord's Injection Script (app.asar method) 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 +// The global BetterDiscord folder lives one directory above userData (the +// appData root). Electron gives the postfixed 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 @@ -14,5 +14,5 @@ if (process.platform !== "win32" && process.platform !== "darwin") { require(path.join(userConfig, "BetterDiscord", "data", "betterdiscord.asar")); -// Discord's Default Export -module.exports = require("./core.asar"); \ No newline at end of file +// Hand off to Discord's real (renamed) app entry point +module.exports = require("../betterdiscord.app.asar"); \ No newline at end of file diff --git a/internal/discord/assets/app_package.json b/internal/discord/assets/app_package.json new file mode 100644 index 0000000..c86ce6c --- /dev/null +++ b/internal/discord/assets/app_package.json @@ -0,0 +1 @@ +{"main": "./index.js"} \ No newline at end of file From ac985283e57234bb589e547f7c017c09a4a1a2df Mon Sep 17 00:00:00 2001 From: Zerebos Date: Thu, 30 Jul 2026 09:38:53 -0400 Subject: [PATCH 02/36] fix: embed assets --- internal/discord/injection.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/discord/injection.go b/internal/discord/injection.go index 905b7c8..ce75407 100644 --- a/internal/discord/injection.go +++ b/internal/discord/injection.go @@ -10,12 +10,15 @@ import ( "github.com/betterdiscord/cli/internal/output" ) -//go:embed assets/injection.js -var injectionScript string +//go:embed assets/app_index.js +var appIndexScript string + +//go:embed assets/app_package.json +var appPackageJSON string func (discord *DiscordInstall) inject(bd *betterdiscord.BDInstall) error { - if err := os.WriteFile(filepath.Join(discord.CorePath, "index.js"), []byte(injectionScript), 0755); err != nil { + if err := os.WriteFile(filepath.Join(discord.CorePath, "index.js"), []byte(appIndexScript), 0755); err != nil { output.Printf("โŒ Unable to write index.js in %s\n", discord.CorePath) output.Printf(" %s\n", err.Error()) return err From b3676e4f00f63c0bcab8562ee61e73268edc8809 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Thu, 30 Jul 2026 09:46:51 -0400 Subject: [PATCH 03/36] feat: add options to different operations --- cmd/install.go | 2 +- cmd/uninstall.go | 4 +-- internal/discord/install.go | 55 ++++++++++++++++++------------------- internal/models/options.go | 20 ++++++++++++++ 4 files changed, 49 insertions(+), 32 deletions(-) create mode 100644 internal/models/options.go diff --git a/cmd/install.go b/cmd/install.go index cf88141..2816d09 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -49,7 +49,7 @@ var installCmd = &cobra.Command{ } } - if err := install.InstallBD(); err != nil { + if err := install.InstallBD(models.InstallOptions{RestartDiscord: true}); err != nil { return fmt.Errorf("installation failed: %w", err) } diff --git a/cmd/uninstall.go b/cmd/uninstall.go index ce667ad..881e4ba 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -90,7 +90,7 @@ 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) } @@ -129,7 +129,7 @@ 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 } diff --git a/internal/discord/install.go b/internal/discord/install.go index 9771804..06385aa 100644 --- a/internal/discord/install.go +++ b/internal/discord/install.go @@ -16,7 +16,7 @@ type DiscordInstall struct { } // InstallBD installs BetterDiscord into this Discord installation -func (discord *DiscordInstall) InstallBD() error { +func (discord *DiscordInstall) InstallBD(options models.InstallOptions) error { bd := discord.GetBetterDiscordInstall() // Make BetterDiscord folders @@ -43,55 +43,52 @@ func (discord *DiscordInstall) InstallBD() error { output.Println("โœ… Injection successful") output.Blank() - // Terminate and restart Discord if possible - output.Printf("๐Ÿ”„ Restarting %s...\n", discord.Channel.Name()) - if err := discord.restart(); err != nil { - return err + if options.RestartDiscord { + // Terminate and restart Discord if possible + output.Printf("๐Ÿ”„ Restarting %s...\n", discord.Channel.Name()) + if err := discord.restart(); err != nil { + return err + } + output.Blank() } - output.Blank() return nil } // UninstallBD removes BetterDiscord from this Discord installation -func (discord *DiscordInstall) UninstallBD() error { +func (discord *DiscordInstall) UninstallBD(options models.UninstallOptions) error { output.Println("๐Ÿงน Removing injection...") if err := discord.uninject(); err != nil { return err } output.Blank() - output.Printf("๐Ÿ”„ Restarting %s...\n", discord.Channel.Name()) - if err := discord.restart(); err != nil { - return err + if options.FullUninstall { + install := discord.GetBetterDiscordInstall() + if err := install.RemoveAll(); err != nil { + return err + } + output.Blank() + } + + if options.RestartDiscord { + output.Printf("๐Ÿ”„ Restarting %s...\n", discord.Channel.Name()) + if err := discord.restart(); err != nil { + return err + } + output.Blank() } - output.Blank() return nil } // RepairBD repairs BetterDiscord for this Discord installation -func (discord *DiscordInstall) RepairBD() error { - if err := discord.UninstallBD(); err != nil { +func (discord *DiscordInstall) RepairBD(options models.RepairOptions) error { + if err := discord.UninstallBD(models.UninstallOptions{FullUninstall: false}); err != nil { return err } - // Gets the global BetterDiscord install - bd := betterdiscord.GetInstallation() - - // Snaps and flatpaks get their own local BD install - if discord.IsFlatpak || discord.IsSnap { - segment := "config" - if discord.IsSnap { - segment = ".config" - } - - configPath, err := utils.FindSegment(discord.CorePath, segment) - if err != nil { - return err - } - bd = betterdiscord.GetInstallation(configPath) - } + bd := discord.GetBetterDiscordInstall() if err := bd.Repair(discord.Channel); err != nil { return err diff --git a/internal/models/options.go b/internal/models/options.go new file mode 100644 index 0000000..8a6469c --- /dev/null +++ b/internal/models/options.go @@ -0,0 +1,20 @@ +package models + +type InstallOptions struct { + RestartDiscord bool `json:"restartDiscord"` + UseDevBuild bool `json:"useDevBuild"` +} + +type RepairOptions struct { + DisablePlugins bool `json:"disablePlugins"` + DisableThemes bool `json:"disableThemes"` + ClearCustomCSS bool `json:"clearCustomCSS"` + ClearWebpackCache bool `json:"clearWebpackCache"` + ClearAddonStoreCache bool `json:"clearAddonStoreCache"` + ResetSettings bool `json:"resetSettings"` +} + +type UninstallOptions struct { + FullUninstall bool `json:"fullUninstall"` + RestartDiscord bool `json:"restartDiscord"` +} From 00b89ae780d3cece0a3fb981e0e279cdd1ac036a Mon Sep 17 00:00:00 2001 From: Zerebos Date: Thu, 30 Jul 2026 09:48:29 -0400 Subject: [PATCH 04/36] fix: better error handling --- internal/discord/install.go | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/internal/discord/install.go b/internal/discord/install.go index 06385aa..cd9c201 100644 --- a/internal/discord/install.go +++ b/internal/discord/install.go @@ -17,7 +17,10 @@ type DiscordInstall struct { // InstallBD installs BetterDiscord into this Discord installation func (discord *DiscordInstall) InstallBD(options models.InstallOptions) error { - bd := discord.GetBetterDiscordInstall() + bd, err := discord.GetBetterDiscordInstall() + if err != nil { + return err + } // Make BetterDiscord folders output.Println("๐Ÿ›  Preparing BetterDiscord...") @@ -64,7 +67,10 @@ func (discord *DiscordInstall) UninstallBD(options models.UninstallOptions) erro output.Blank() if options.FullUninstall { - install := discord.GetBetterDiscordInstall() + install, err := discord.GetBetterDiscordInstall() + if err != nil { + return err + } if err := install.RemoveAll(); err != nil { return err } @@ -84,11 +90,14 @@ func (discord *DiscordInstall) UninstallBD(options models.UninstallOptions) erro // RepairBD repairs BetterDiscord for this Discord installation func (discord *DiscordInstall) RepairBD(options models.RepairOptions) error { - if err := discord.UninstallBD(models.UninstallOptions{FullUninstall: false}); err != nil { + if err := discord.UninstallBD(models.UninstallOptions{FullUninstall: false, RestartDiscord: false}); err != nil { return err } - bd := discord.GetBetterDiscordInstall() + bd, err := discord.GetBetterDiscordInstall() + if err != nil { + return err + } if err := bd.Repair(discord.Channel); err != nil { return err @@ -97,7 +106,7 @@ func (discord *DiscordInstall) RepairBD(options models.RepairOptions) error { return nil } -func (discord *DiscordInstall) GetBetterDiscordInstall() *betterdiscord.BDInstall { +func (discord *DiscordInstall) GetBetterDiscordInstall() (*betterdiscord.BDInstall, error) { // Gets the global BetterDiscord install bd := betterdiscord.GetInstallation() @@ -110,10 +119,10 @@ func (discord *DiscordInstall) GetBetterDiscordInstall() *betterdiscord.BDInstal configPath, err := utils.FindSegment(discord.CorePath, segment) if err != nil { - return nil + return nil, err } bd = betterdiscord.GetInstallation(configPath) } - return bd + return bd, nil } From 8bc0da4abab6d5b6ed2be11e8086aa4772ac3a6d Mon Sep 17 00:00:00 2001 From: Zerebos Date: Thu, 30 Jul 2026 09:51:50 -0400 Subject: [PATCH 05/36] feat: switch to tracking resources dir --- internal/discord/injection.go | 10 +++++----- internal/discord/install.go | 4 ++-- internal/discord/paths.go | 6 +++--- internal/discord/paths_common.go | 4 ++-- internal/discord/paths_test.go | 26 +++++++++++++------------- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/internal/discord/injection.go b/internal/discord/injection.go index ce75407..6463de0 100644 --- a/internal/discord/injection.go +++ b/internal/discord/injection.go @@ -18,18 +18,18 @@ var appPackageJSON string func (discord *DiscordInstall) inject(bd *betterdiscord.BDInstall) error { - if err := os.WriteFile(filepath.Join(discord.CorePath, "index.js"), []byte(appIndexScript), 0755); err != nil { - output.Printf("โŒ Unable to write index.js in %s\n", discord.CorePath) + if err := os.WriteFile(filepath.Join(discord.ResourcesPath, "index.js"), []byte(appIndexScript), 0755); err != nil { + output.Printf("โŒ Unable to write index.js in %s\n", discord.ResourcesPath) output.Printf(" %s\n", err.Error()) return err } - output.Printf("โœ… Injected into %s\n", discord.CorePath) + output.Printf("โœ… Injected into %s\n", discord.ResourcesPath) return nil } func (discord *DiscordInstall) uninject() error { - indexFile := filepath.Join(discord.CorePath, "index.js") + indexFile := filepath.Join(discord.ResourcesPath, "index.js") contents, err := os.ReadFile(indexFile) @@ -53,7 +53,7 @@ func (discord *DiscordInstall) uninject() error { // TODO: consider putting this in the betterdiscord package func (discord *DiscordInstall) IsInjected() bool { - indexFile := filepath.Join(discord.CorePath, "index.js") + indexFile := filepath.Join(discord.ResourcesPath, "index.js") contents, err := os.ReadFile(indexFile) if err != nil { return false diff --git a/internal/discord/install.go b/internal/discord/install.go index cd9c201..cb178b8 100644 --- a/internal/discord/install.go +++ b/internal/discord/install.go @@ -8,7 +8,7 @@ import ( ) type DiscordInstall struct { - CorePath string `json:"corePath"` + ResourcesPath string `json:"corePath"` Channel models.DiscordChannel `json:"channel"` Version string `json:"version"` IsFlatpak bool `json:"isFlatpak"` @@ -117,7 +117,7 @@ func (discord *DiscordInstall) GetBetterDiscordInstall() (*betterdiscord.BDInsta segment = ".config" } - configPath, err := utils.FindSegment(discord.CorePath, segment) + configPath, err := utils.FindSegment(discord.ResourcesPath, segment) if err != nil { return nil, err } diff --git a/internal/discord/paths.go b/internal/discord/paths.go index 870af2b..745646e 100644 --- a/internal/discord/paths.go +++ b/internal/discord/paths.go @@ -49,7 +49,7 @@ func GetChannel(proposed string) models.DiscordChannel { func GetSuggestedPath(channel models.DiscordChannel) string { if len(allDiscordInstalls[channel]) > 0 { - return allDiscordInstalls[channel][0].CorePath + return allDiscordInstalls[channel][0].ResourcesPath } return "" } @@ -61,7 +61,7 @@ func AddCustomPath(proposed string) *DiscordInstall { } // 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 }) + index := slices.IndexFunc(allDiscordInstalls[result.Channel], func(d *DiscordInstall) bool { return d.ResourcesPath == result.ResourcesPath }) if index >= 0 { return allDiscordInstalls[result.Channel][index] } @@ -75,7 +75,7 @@ func AddCustomPath(proposed string) *DiscordInstall { func ResolvePath(proposed string) *DiscordInstall { for channel := range allDiscordInstalls { - index := slices.IndexFunc(allDiscordInstalls[channel], func(d *DiscordInstall) bool { return d.CorePath == proposed }) + index := slices.IndexFunc(allDiscordInstalls[channel], func(d *DiscordInstall) bool { return d.ResourcesPath == proposed }) if index >= 0 { return allDiscordInstalls[channel][index] } diff --git a/internal/discord/paths_common.go b/internal/discord/paths_common.go index 32a60e0..0e34f12 100644 --- a/internal/discord/paths_common.go +++ b/internal/discord/paths_common.go @@ -75,7 +75,7 @@ func validateWindowsStyleInstall(proposed string) *DiscordInstall { // Verify the path and core.asar exist if utils.Exists(finalPath) && utils.Exists(filepath.Join(finalPath, "core.asar")) { return &DiscordInstall{ - CorePath: finalPath, + ResourcesPath: finalPath, Channel: GetChannel(finalPath), Version: GetVersion(finalPath), IsFlatpak: false, @@ -199,7 +199,7 @@ func validateUnixStyleInstall(proposed string, detectFlatpak bool, detectSnap bo } return &DiscordInstall{ - CorePath: finalPath, + ResourcesPath: finalPath, Channel: GetChannel(finalPath), Version: GetVersion(finalPath), IsFlatpak: isFlatpak, diff --git a/internal/discord/paths_test.go b/internal/discord/paths_test.go index f84c459..b2f7c75 100644 --- a/internal/discord/paths_test.go +++ b/internal/discord/paths_test.go @@ -211,12 +211,12 @@ func TestGetSuggestedPath(t *testing.T) { newCorePath := "/home/user/.config/discord/app-0.0.35/modules/discord_desktop_core-1/discord_desktop_core/core.asar" allDiscordInstalls[models.Stable] = []*DiscordInstall{ - {CorePath: oldCorePath, Version: "0.0.35"}, - {CorePath: "/usr/share/discord/0.0.34", Version: "0.0.34"}, + {ResourcesPath: oldCorePath, Version: "0.0.35"}, + {ResourcesPath: "/usr/share/discord/0.0.34", Version: "0.0.34"}, } allDiscordInstalls[models.Canary] = []*DiscordInstall{ - {CorePath: newCorePath, Version: "0.0.200"}, // New format + {ResourcesPath: newCorePath, Version: "0.0.200"}, // New format } // Test that it returns the first install (old format) @@ -261,14 +261,14 @@ func TestResolvePath(t *testing.T) { // Add a test install with new path format testInstall := &DiscordInstall{ - CorePath: "/home/user/.config/discord/app-1.0.0/modules/discord_desktop_core-1/discord_desktop_core/core.asar", + 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", } allDiscordInstalls[models.Stable] = []*DiscordInstall{testInstall} // Test resolving existing path - result := ResolvePath(testInstall.CorePath) + result := ResolvePath(testInstall.ResourcesPath) if result != testInstall { t.Error("ResolvePath should return the existing install") } @@ -394,9 +394,9 @@ func TestSortInstalls(t *testing.T) { // Add unsorted installs - mix of old and new path formats allDiscordInstalls[models.Stable] = []*DiscordInstall{ - {CorePath: "/path1", Version: "0.0.34", Channel: models.Stable}, // Old format - {CorePath: "/home/user/.config/discord/app-0.0.36/modules/discord_desktop_core-1/discord_desktop_core/core.asar", Version: "0.0.36", Channel: models.Stable}, // New format - {CorePath: "/path3", Version: "0.0.35", Channel: models.Stable}, // Old format + {ResourcesPath: "/path1", Version: "0.0.34", Channel: models.Stable}, // Old format + {ResourcesPath: "/home/user/.config/discord/app-0.0.36/modules/discord_desktop_core-1/discord_desktop_core/core.asar", Version: "0.0.36", Channel: models.Stable}, // New format + {ResourcesPath: "/path3", Version: "0.0.35", Channel: models.Stable}, // Old format } // Sort them @@ -425,14 +425,14 @@ func TestSortInstalls_MultipleChannels(t *testing.T) { // Add unsorted installs for multiple channels - mix of old and new formats allDiscordInstalls[models.Stable] = []*DiscordInstall{ - {CorePath: "/stable1", Version: "1.0.0", Channel: models.Stable}, - {CorePath: "/home/user/.config/discord/app-1.0.2/modules/discord_desktop_core-1/discord_desktop_core/core.asar", Version: "1.0.2", Channel: models.Stable}, // New format + {ResourcesPath: "/stable1", Version: "1.0.0", Channel: models.Stable}, + {ResourcesPath: "/home/user/.config/discord/app-1.0.2/modules/discord_desktop_core-1/discord_desktop_core/core.asar", Version: "1.0.2", Channel: models.Stable}, // New format } allDiscordInstalls[models.Canary] = []*DiscordInstall{ - {CorePath: "/canary1", Version: "0.0.100", Channel: models.Canary}, - {CorePath: "/home/user/.config/discordcanary/app-0.0.150/modules/discord_desktop_core-1/discord_desktop_core/core.asar", Version: "0.0.150", Channel: models.Canary}, // New format - {CorePath: "/canary3", Version: "0.0.125", Channel: models.Canary}, + {ResourcesPath: "/canary1", Version: "0.0.100", Channel: models.Canary}, + {ResourcesPath: "/home/user/.config/discordcanary/app-0.0.150/modules/discord_desktop_core-1/discord_desktop_core/core.asar", Version: "0.0.150", Channel: models.Canary}, // New format + {ResourcesPath: "/canary3", Version: "0.0.125", Channel: models.Canary}, } // Sort them From 1b0175846ec0255f00d8fb6b76dac93437062a18 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Thu, 30 Jul 2026 10:08:32 -0400 Subject: [PATCH 06/36] fix: use resources path everywhere --- cmd/discover.go | 2 +- cmd/install.go | 14 +++++++++++--- cmd/uninstall.go | 14 +++++++------- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/cmd/discover.go b/cmd/discover.go index 8614e7b..1d416a2 100644 --- a/cmd/discover.go +++ b/cmd/discover.go @@ -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) } } diff --git a/cmd/install.go b/cmd/install.go index 2816d09..3548b97 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -53,7 +53,7 @@ var installCmd = &cobra.Command{ 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() @@ -67,10 +67,18 @@ var installCmd = &cobra.Command{ } return "native" }()) - output.Printf(" Core Path: %s\n", path.Dir(install.CorePath)) + output.Printf(" Core 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", err.Error()) + return nil + } + if bdinstall == nil { + output.Printf("BetterDiscord install info is nil") + return nil + } bdinstall.LogBuildinfo() return nil }, diff --git a/cmd/uninstall.go b/cmd/uninstall.go index 881e4ba..ba30202 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -94,7 +94,7 @@ var uninstallCmd = &cobra.Command{ 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 }, } @@ -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 @@ -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) } } @@ -133,7 +133,7 @@ func uninstallAll(installs []*discord.DiscordInstall) error { 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()) } } @@ -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 From c9825bc2face8e411607e62357de171c78ca37b3 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Thu, 30 Jul 2026 10:13:58 -0400 Subject: [PATCH 07/36] feat: add more testing --- internal/discord/injection_test.go | 151 +++++++++++++++++++++++++ internal/discord/install_test.go | 101 +++++++++++++++++ internal/discord/paths_common_test.go | 152 ++++++++++++++++++++++++++ 3 files changed, 404 insertions(+) create mode 100644 internal/discord/injection_test.go create mode 100644 internal/discord/install_test.go create mode 100644 internal/discord/paths_common_test.go diff --git a/internal/discord/injection_test.go b/internal/discord/injection_test.go new file mode 100644 index 0000000..fb4b1e4 --- /dev/null +++ b/internal/discord/injection_test.go @@ -0,0 +1,151 @@ +package discord + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/betterdiscord/cli/internal/models" +) + +const defaultIndexJS = `module.exports = require("./core.asar");` + +func TestIsInjected(t *testing.T) { + tmpDir := t.TempDir() + resourcesPath := filepath.Join(tmpDir, "discord_desktop_core") + if err := os.MkdirAll(resourcesPath, 0755); err != nil { + t.Fatalf("Failed to create core path: %v", err) + } + indexFile := filepath.Join(resourcesPath, "index.js") + + install := &DiscordInstall{ + ResourcesPath: resourcesPath, + Channel: models.Stable, + } + + if install.IsInjected() { + t.Fatalf("Expected IsInjected to be false with missing index.js") + } + + if err := os.WriteFile(indexFile, []byte(`module.exports = require("./core.asar");`), 0644); err != nil { + t.Fatalf("Failed to write index.js: %v", err) + } + if install.IsInjected() { + t.Fatalf("Expected IsInjected to be false for default index.js") + } + + if err := os.WriteFile(indexFile, []byte(`// BetterDiscord injected`), 0644); err != nil { + t.Fatalf("Failed to write injection index.js: %v", err) + } + if !install.IsInjected() { + t.Fatalf("Expected IsInjected to be true when BetterDiscord is present") + } +} + +func TestInject_WritesInjectionScript(t *testing.T) { + resourcesPath := t.TempDir() + install := &DiscordInstall{ResourcesPath: resourcesPath, Channel: models.Stable} + + // bd is unused by inject(); nil is fine. + if err := install.inject(nil); err != nil { + t.Fatalf("inject() failed: %v", err) + } + + if !install.IsInjected() { + t.Fatal("expected IsInjected() to be true after inject()") + } + + info, err := os.Stat(filepath.Join(resourcesPath, "index.js")) + if err != nil { + t.Fatalf("index.js not written: %v", err) + } + // The require target must not carry the executable bit. + if runtime.GOOS != "windows" { + if perm := info.Mode().Perm(); perm != 0o644 { + t.Errorf("index.js mode = %o, expected 644", perm) + } + } +} + +func TestUninject_RemovesInjection(t *testing.T) { + resourcesPath := t.TempDir() + install := &DiscordInstall{ResourcesPath: resourcesPath, Channel: models.Stable} + indexFile := filepath.Join(resourcesPath, "index.js") + + seed := `require("BetterDiscord/data/betterdiscord.asar");` + "\n" + defaultIndexJS + if err := os.WriteFile(indexFile, []byte(seed), 0o644); err != nil { + t.Fatalf("failed to seed injected index.js: %v", err) + } + + if err := install.uninject(); err != nil { + t.Fatalf("uninject() failed: %v", err) + } + + contents, err := os.ReadFile(indexFile) + if err != nil { + t.Fatalf("index.js missing after uninject: %v", err) + } + if string(contents) != defaultIndexJS { + t.Errorf("index.js after uninject = %q, expected %q", string(contents), defaultIndexJS) + } + if install.IsInjected() { + t.Error("expected IsInjected() to be false after uninject()") + } +} + +func TestUninject_LeavesUninjectedFileUntouched(t *testing.T) { + resourcesPath := t.TempDir() + install := &DiscordInstall{ResourcesPath: resourcesPath, Channel: models.Stable} + indexFile := filepath.Join(resourcesPath, "index.js") + + original := `module.exports = require("./some-other-core.asar");` + if err := os.WriteFile(indexFile, []byte(original), 0o644); err != nil { + t.Fatalf("failed to seed index.js: %v", err) + } + + if err := install.uninject(); err != nil { + t.Fatalf("uninject() failed: %v", err) + } + + contents, _ := os.ReadFile(indexFile) + if string(contents) != original { + t.Errorf("uninject rewrote a file with no BetterDiscord marker: got %q", string(contents)) + } +} + +func TestUninject_MissingFileWritesDefault(t *testing.T) { + resourcesPath := t.TempDir() + install := &DiscordInstall{ResourcesPath: resourcesPath, Channel: models.Stable} + + // No index.js exists; uninject falls through and writes the default stub. + if err := install.uninject(); err != nil { + t.Fatalf("uninject() failed: %v", err) + } + + contents, err := os.ReadFile(filepath.Join(resourcesPath, "index.js")) + if err != nil { + t.Fatalf("expected index.js to be created: %v", err) + } + if string(contents) != defaultIndexJS { + t.Errorf("index.js = %q, expected %q", string(contents), defaultIndexJS) + } +} + +func TestInjectUninject_RoundTrip(t *testing.T) { + resourcesPath := t.TempDir() + install := &DiscordInstall{ResourcesPath: resourcesPath, Channel: models.Stable} + + if err := install.inject(nil); err != nil { + t.Fatalf("inject() failed: %v", err) + } + if !install.IsInjected() { + t.Fatal("expected injected after inject()") + } + if err := install.uninject(); err != nil { + t.Fatalf("uninject() failed: %v", err) + } + if install.IsInjected() { + t.Fatal("expected not injected after uninject()") + } +} \ No newline at end of file diff --git a/internal/discord/install_test.go b/internal/discord/install_test.go new file mode 100644 index 0000000..c36c5d5 --- /dev/null +++ b/internal/discord/install_test.go @@ -0,0 +1,101 @@ +package discord + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/betterdiscord/cli/internal/models" +) + +// UninstallBD with neither full-uninstall nor restart should only de-inject the +// core's index.js โ€” the safe path that never touches the global BD folder or +// the running Discord process. +func TestUninstallBD_UninjectOnly(t *testing.T) { + corePath := t.TempDir() + indexFile := filepath.Join(corePath, "index.js") + seed := `require("BetterDiscord/data/betterdiscord.asar");` + "\n" + `module.exports = require("./core.asar");` + if err := os.WriteFile(indexFile, []byte(seed), 0o644); err != nil { + t.Fatalf("failed to seed injected index.js: %v", err) + } + + install := &DiscordInstall{ResourcesPath: corePath, Channel: models.Stable} + if err := install.UninstallBD(models.UninstallOptions{FullUninstall: false, RestartDiscord: false}); err != nil { + t.Fatalf("UninstallBD() failed: %v", err) + } + + if install.IsInjected() { + t.Error("expected index.js to be de-injected after UninstallBD") + } + contents, _ := os.ReadFile(indexFile) + if want := `module.exports = require("./core.asar");`; string(contents) != want { + t.Errorf("index.js after uninstall = %q, expected %q", string(contents), want) + } +} + +func TestGetBetterDiscordInstall_Global(t *testing.T) { + install := &DiscordInstall{ResourcesPath: "/some/discord/core", Channel: models.Stable} + + bd, err := install.GetBetterDiscordInstall() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if bd == nil { + t.Fatal("expected a non-nil global BD install") + } +} + +func TestGetBetterDiscordInstall_FlatpakResolvesConfig(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses POSIX-style flatpak paths") + } + // A flatpak-style core path containing a "config" segment. + configDir := filepath.Join(t.TempDir(), "config") + corePath := filepath.Join(configDir, "discord", "0.0.1", "modules", "discord_desktop_core") + install := &DiscordInstall{ResourcesPath: corePath, Channel: models.Stable, IsFlatpak: true} + + bd, err := install.GetBetterDiscordInstall() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if bd == nil { + t.Fatal("expected non-nil BD install") + } + if want := filepath.Join(configDir, "BetterDiscord"); bd.Root() != want { + t.Errorf("Root() = %s, expected %s", bd.Root(), want) + } +} + +func TestGetBetterDiscordInstall_SnapResolvesConfig(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses POSIX-style snap paths") + } + // A snap-style core path uses the ".config" segment. + configDir := filepath.Join(t.TempDir(), ".config") + corePath := filepath.Join(configDir, "discord", "0.0.1", "modules", "discord_desktop_core") + install := &DiscordInstall{ResourcesPath: corePath, Channel: models.Stable, IsSnap: true} + + bd, err := install.GetBetterDiscordInstall() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join(configDir, "BetterDiscord"); bd.Root() != want { + t.Errorf("Root() = %s, expected %s", bd.Root(), want) + } +} + +// Regression test for the nil-deref fix: a snap/flatpak core path missing the +// expected config segment must surface an error, not return a nil *BDInstall +// that callers would dereference and panic on. +func TestGetBetterDiscordInstall_FlatpakMissingSegment_Errors(t *testing.T) { + install := &DiscordInstall{ResourcesPath: "/no/matching/segment/here", Channel: models.Stable, IsFlatpak: true} + + bd, err := install.GetBetterDiscordInstall() + if err == nil { + t.Fatal("expected an error when the config segment is missing") + } + if bd != nil { + t.Errorf("expected nil BD install on error, got %+v", bd) + } +} \ No newline at end of file diff --git a/internal/discord/paths_common_test.go b/internal/discord/paths_common_test.go new file mode 100644 index 0000000..e0750ee --- /dev/null +++ b/internal/discord/paths_common_test.go @@ -0,0 +1,152 @@ +package discord + +import ( + "os" + "path/filepath" + "testing" +) + +func writeCoreAsar(t *testing.T, resourcesPath string) { + t.Helper() + if err := os.MkdirAll(resourcesPath, 0755); err != nil { + t.Fatalf("Failed to create core path: %v", err) + } + if err := os.WriteFile(filepath.Join(resourcesPath, "core.asar"), []byte("test"), 0644); err != nil { + t.Fatalf("Failed to write core.asar: %v", err) + } +} + +func TestValidateWindowsStyleInstall_FromDiscordRoot(t *testing.T) { + tmpDir := t.TempDir() + root := filepath.Join(tmpDir, "Discord") + versionDir := filepath.Join(root, "app-1.0.9002") + coreWrap := filepath.Join(versionDir, "modules", "discord_desktop_core-1", "discord_desktop_core") + + writeCoreAsar(t, coreWrap) + + result := validateWindowsStyleInstall(root) + if result == nil { + t.Fatalf("Expected install for %s", root) + } + if result.ResourcesPath != coreWrap { + t.Errorf("ResourcesPath = %s, expected %s", result.ResourcesPath, coreWrap) + } +} + +func TestValidateWindowsStyleInstall_FromAppFolder(t *testing.T) { + tmpDir := t.TempDir() + root := filepath.Join(tmpDir, "Discord") + versionDir := filepath.Join(root, "app-1.0.9002") + coreWrap := filepath.Join(versionDir, "modules", "discord_desktop_core-1", "discord_desktop_core") + + writeCoreAsar(t, coreWrap) + + result := validateWindowsStyleInstall(versionDir) + if result == nil { + t.Fatalf("Expected install for %s", versionDir) + } + if result.ResourcesPath != coreWrap { + t.Errorf("ResourcesPath = %s, expected %s", result.ResourcesPath, coreWrap) + } +} + +func TestValidateWindowsStyleInstall_FromCoreFolder(t *testing.T) { + tmpDir := t.TempDir() + resourcesPath := filepath.Join(tmpDir, "discord_desktop_core") + writeCoreAsar(t, resourcesPath) + + result := validateWindowsStyleInstall(resourcesPath) + if result == nil { + t.Fatalf("Expected install for %s", resourcesPath) + } + if result.ResourcesPath != resourcesPath { + t.Errorf("ResourcesPath = %s, expected %s", result.ResourcesPath, resourcesPath) + } +} + +func TestValidateWindowsStyleInstall_MissingAsar(t *testing.T) { + tmpDir := t.TempDir() + root := filepath.Join(tmpDir, "Discord") + versionDir := filepath.Join(root, "app-1.0.9002") + coreWrap := filepath.Join(versionDir, "modules", "discord_desktop_core-1", "discord_desktop_core") + + if err := os.MkdirAll(coreWrap, 0755); err != nil { + t.Fatalf("Failed to create core path: %v", err) + } + + result := validateWindowsStyleInstall(root) + if result != nil { + t.Fatalf("Expected no install when core.asar is missing") + } +} + +func TestValidateUnixStyleInstall_FromDiscordRoot(t *testing.T) { + tmpDir := t.TempDir() + root := filepath.Join(tmpDir, "discord") + resourcesPath := filepath.Join(root, "0.0.35", "modules", "discord_desktop_core") + + writeCoreAsar(t, resourcesPath) + + result := validateUnixStyleInstall(root, true, true) + if result == nil { + t.Fatalf("Expected install for %s", root) + } + if result.ResourcesPath != resourcesPath { + t.Errorf("ResourcesPath = %s, expected %s", result.ResourcesPath, resourcesPath) + } +} + +func TestValidateUnixStyleInstall_FromVersionFolder(t *testing.T) { + tmpDir := t.TempDir() + root := filepath.Join(tmpDir, "discord") + versionDir := filepath.Join(root, "0.0.35") + resourcesPath := filepath.Join(versionDir, "modules", "discord_desktop_core") + + writeCoreAsar(t, resourcesPath) + + result := validateUnixStyleInstall(versionDir, true, true) + if result == nil { + t.Fatalf("Expected install for %s", versionDir) + } + if result.ResourcesPath != resourcesPath { + t.Errorf("ResourcesPath = %s, expected %s", result.ResourcesPath, resourcesPath) + } +} + +func TestValidateUnixStyleInstall_FlatpakDetection(t *testing.T) { + tmpDir := t.TempDir() + root := filepath.Join(tmpDir, "com.discordapp.Discord", "config", "discord") + resourcesPath := filepath.Join(root, "0.0.35", "modules", "discord_desktop_core") + + writeCoreAsar(t, resourcesPath) + + result := validateUnixStyleInstall(root, true, false) + if result == nil { + t.Fatalf("Expected install for %s", root) + } + if !result.IsFlatpak { + t.Fatalf("Expected flatpak detection") + } + if result.IsSnap { + t.Fatalf("Did not expect snap detection") + } +} + +func TestValidateUnixStyleInstall_SnapDetection(t *testing.T) { + tmpDir := t.TempDir() + root := filepath.Join(tmpDir, "snap", "discord", "current", ".config", "discord") + resourcesPath := filepath.Join(root, "0.0.35", "modules", "discord_desktop_core") + + writeCoreAsar(t, resourcesPath) + + result := validateUnixStyleInstall(root, false, true) + if result == nil { + t.Fatalf("Expected install for %s", root) + } + if !result.IsSnap { + t.Fatalf("Expected snap detection") + } + if result.IsFlatpak { + t.Fatalf("Did not expect flatpak detection") + } +} \ No newline at end of file From 4b9b529d161afd0ee2055f7b5f24a2616e87544a Mon Sep 17 00:00:00 2001 From: Zerebos Date: Thu, 30 Jul 2026 10:20:05 -0400 Subject: [PATCH 08/36] feat: more tests before refactoring --- internal/betterdiscord/download.go | 11 +- internal/betterdiscord/download_test.go | 131 +++++++++++++++++++++++ internal/betterdiscord/meta_test.go | 2 +- internal/utils/strings.go | 75 ++++++++++++- internal/utils/strings_test.go | 135 ++++++++++++++++++++++++ internal/wsl/wsl_test.go | 50 +++++++++ 6 files changed, 400 insertions(+), 4 deletions(-) create mode 100644 internal/betterdiscord/download_test.go create mode 100644 internal/utils/strings_test.go create mode 100644 internal/wsl/wsl_test.go diff --git a/internal/betterdiscord/download.go b/internal/betterdiscord/download.go index 3704307..e959e62 100644 --- a/internal/betterdiscord/download.go +++ b/internal/betterdiscord/download.go @@ -8,13 +8,20 @@ import ( "github.com/betterdiscord/cli/internal/utils" ) +// 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" +) + func (i *BDInstall) download() 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) + resp, err := utils.DownloadFile(websiteAsarURL, i.asar) if err == nil { version := resp.Header.Get("x-bd-version") if version == "" { @@ -32,7 +39,7 @@ func (i *BDInstall) download() error { } // Get download URL from GitHub API - apiData, err := utils.DownloadJSON[models.GitHubRelease]("https://api.github.com/repos/BetterDiscord/BetterDiscord/releases/latest") + apiData, err := utils.DownloadJSON[models.GitHubRelease](githubLatestReleaseURL) if err != nil { output.Println("โŒ Failed to get asset url from GitHub") output.Printf("โŒ %s\n", err.Error()) diff --git a/internal/betterdiscord/download_test.go b/internal/betterdiscord/download_test.go new file mode 100644 index 0000000..dde2214 --- /dev/null +++ b/internal/betterdiscord/download_test.go @@ -0,0 +1,131 @@ +package betterdiscord + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +// withURLs temporarily overrides the download endpoints for a test and restores +// them on cleanup. +func withURLs(t *testing.T, website, github string) { + t.Helper() + origWebsite, origGithub := websiteAsarURL, githubLatestReleaseURL + websiteAsarURL = website + githubLatestReleaseURL = github + t.Cleanup(func() { + websiteAsarURL = origWebsite + githubLatestReleaseURL = origGithub + }) +} + +func newBDInstallWithDataDir(t *testing.T) *BDInstall { + t.Helper() + install := New(filepath.Join(t.TempDir(), "BetterDiscord")) + if err := os.MkdirAll(install.Data(), 0o755); err != nil { + t.Fatalf("failed to create data dir: %v", err) + } + return install +} + +func assertFileContents(t *testing.T, path, want string) { + t.Helper() + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read %s: %v", path, err) + } + if string(got) != want { + t.Errorf("contents of %s = %q, expected %q", path, string(got), want) + } +} + +func TestDownload_FromWebsite(t *testing.T) { + const body = "asar-from-website" + website := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("x-bd-version", "1.2.3") + fmt.Fprint(w, body) + })) + defer website.Close() + + github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("GitHub fallback should not be called when the website succeeds") + http.Error(w, "unexpected", http.StatusInternalServerError) + })) + defer github.Close() + + withURLs(t, website.URL, github.URL) + + install := newBDInstallWithDataDir(t) + if err := install.download(); err != nil { + t.Fatalf("download() failed: %v", err) + } + if !install.HasDownloaded() { + t.Error("expected HasDownloaded() to be true") + } + assertFileContents(t, install.Asar(), body) +} + +func TestDownload_FallsBackToGitHub(t *testing.T) { + const body = "asar-from-github" + asset := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, body) + })) + defer asset.Close() + + // Website fails, so download() should fall back to the GitHub release. + website := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "down", http.StatusInternalServerError) + })) + defer website.Close() + + github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"tag_name":"v9.9.9","assets":[{"name":"betterdiscord.asar","url":%q}]}`, asset.URL) + })) + defer github.Close() + + withURLs(t, website.URL, github.URL) + + install := newBDInstallWithDataDir(t) + if err := install.download(); err != nil { + t.Fatalf("download() failed: %v", err) + } + if !install.HasDownloaded() { + t.Error("expected HasDownloaded() to be true after GitHub fallback") + } + assertFileContents(t, install.Asar(), body) +} + +func TestDownload_GitHubMissingAsset(t *testing.T) { + website := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "down", http.StatusInternalServerError) + })) + defer website.Close() + + github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"tag_name":"v9.9.9","assets":[{"name":"something-else.zip","url":"http://example.invalid"}]}`) + })) + defer github.Close() + + withURLs(t, website.URL, github.URL) + + install := newBDInstallWithDataDir(t) + if err := install.download(); err == nil { + t.Fatal("expected an error when the betterdiscord.asar asset is missing") + } +} + +func TestDownload_SkipsWhenAlreadyDownloaded(t *testing.T) { + install := newBDInstallWithDataDir(t) + install.hasDownloaded = true + + // Point the endpoints at a non-routable address to prove the network is + // 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 { + t.Fatalf("download() should be a no-op when already downloaded: %v", err) + } +} \ No newline at end of file diff --git a/internal/betterdiscord/meta_test.go b/internal/betterdiscord/meta_test.go index 4041591..47a442c 100644 --- a/internal/betterdiscord/meta_test.go +++ b/internal/betterdiscord/meta_test.go @@ -253,7 +253,7 @@ func BenchmarkParseJSDoc(b *testing.B) { * @version 1.0.0 */ ` - for i := 0; i < b.N; i++ { + for b.Loop() { parseJSDoc(input) } } diff --git a/internal/utils/strings.go b/internal/utils/strings.go index 05d1c64..abd4cff 100644 --- a/internal/utils/strings.go +++ b/internal/utils/strings.go @@ -1,9 +1,82 @@ package utils -import "net/url" +import ( + "fmt" + "net/url" + "strings" +) // IsURL checks if a string is a valid URL func IsURL(input string) bool { parsed, err := url.Parse(input) return err == nil && parsed.Scheme != "" && parsed.Host != "" } + +// FormatVersion normalizes a version string with a single leading 'v'. +func FormatVersion(version string) string { + trimmed := strings.TrimSpace(version) + trimmed = strings.TrimPrefix(trimmed, "v") + if trimmed == "" { + return "v0.0.0" + } + return "v" + trimmed +} + +// CompareVersions compares two semantic versions (e.g., "1.0.156" vs "1.0.157") +// Returns -1 if v1 < v2, 0 if equal, 1 if v1 > v2 +func CompareVersions(v1, v2 string) int { + // Strip 'v' prefix if present + + if len(v1) > 0 && v1[0] == 'v' { + v1 = v1[1:] + } + if len(v2) > 0 && v2[0] == 'v' { + v2 = v2[1:] + } + + // Parse into version parts + parts1 := SplitVersion(v1) + parts2 := SplitVersion(v2) + + // Compare each part + maxLen := max(len(parts2), len(parts1)) + + for i := range maxLen { + var p1, p2 int + + if i < len(parts1) { + fmt.Sscanf(parts1[i], "%d", &p1) + } + if i < len(parts2) { + fmt.Sscanf(parts2[i], "%d", &p2) + } + + if p1 < p2 { + return -1 + } else if p1 > p2 { + return 1 + } + } + + return 0 +} + +// SplitVersion splits a version string into parts (e.g., "1.0.156" -> ["1", "0", "156"]) +func SplitVersion(v string) []string { + var parts []string + var current string + for i := 0; i < len(v); i++ { + if v[i] == '.' { + if current != "" { + parts = append(parts, current) + current = "" + } + } else if v[i] >= '0' && v[i] <= '9' { + current += string(v[i]) + } + } + if current != "" { + 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 new file mode 100644 index 0000000..8f0b0de --- /dev/null +++ b/internal/utils/strings_test.go @@ -0,0 +1,135 @@ +package utils + +import ( + "reflect" + "testing" +) + +func TestFormatVersion(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "Trims and preserves leading v", + input: " v1.2.3 ", + expected: "v1.2.3", + }, + { + name: "Adds leading v", + input: "1.2.3", + expected: "v1.2.3", + }, + { + name: "Keeps v0.0.0", + input: "v0.0.0", + expected: "v0.0.0", + }, + { + name: "Empty defaults", + input: "", + expected: "v0.0.0", + }, + { + name: "Whitespace defaults", + input: " ", + expected: "v0.0.0", + }, + { + name: "Bare v defaults", + input: "v", + expected: "v0.0.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatVersion(tt.input) + if result != tt.expected { + t.Errorf("FormatVersion(%q) = %q, expected %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestSplitVersion(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "Standard version", + input: "1.0.156", + expected: []string{"1", "0", "156"}, + }, + { + name: "Skips empty segments", + input: "1..2", + expected: []string{"1", "2"}, + }, + { + name: "Ignores non-digits", + input: "v1.2.3-beta", + expected: []string{"1", "2", "3"}, + }, + { + name: "No digits", + input: "beta", + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SplitVersion(tt.input) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("SplitVersion(%q) = %v, expected %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestCompareVersions(t *testing.T) { + tests := []struct { + name string + v1 string + v2 string + expected int + }{ + { + name: "Equal with v prefix", + v1: "v1.2.3", + v2: "1.2.3", + expected: 0, + }, + { + name: "Less than", + v1: "1.2.3", + v2: "1.2.4", + expected: -1, + }, + { + name: "Greater than", + v1: "2.0.0", + v2: "1.9.9", + expected: 1, + }, + { + name: "Missing parts default to zero", + v1: "1.2", + v2: "1.2.0", + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := CompareVersions(tt.v1, tt.v2) + if result != tt.expected { + t.Errorf("CompareVersions(%q, %q) = %d, expected %d", tt.v1, tt.v2, result, tt.expected) + } + }) + } +} \ No newline at end of file diff --git a/internal/wsl/wsl_test.go b/internal/wsl/wsl_test.go new file mode 100644 index 0000000..c020c85 --- /dev/null +++ b/internal/wsl/wsl_test.go @@ -0,0 +1,50 @@ +package wsl + +import ( + "strings" + "sync" + "testing" +) + +func resetWSLInfo() { + once = sync.Once{} + info = nil +} + +func TestInfo_NotWSLWhenNoSignals(t *testing.T) { + t.Setenv("WSL_DISTRO_NAME", "") + t.Setenv("WSL_INTEROP", "") + resetWSLInfo() + + info := Info() + if strings.Contains(info.KernelVersion, "microsoft") { + if !info.IsWSL { + t.Fatalf("Expected IsWSL true when kernel indicates WSL") + } + } else if info.IsWSL { + t.Fatalf("Expected IsWSL false when no WSL signals are set") + } + if info.DistroName != "" { + t.Fatalf("Expected empty DistroName, got %q", info.DistroName) + } + if info.InteropPath != "" { + t.Fatalf("Expected empty InteropPath, got %q", info.InteropPath) + } +} + +func TestInfo_WSLDistroName(t *testing.T) { + t.Setenv("WSL_DISTRO_NAME", "Ubuntu") + t.Setenv("WSL_INTEROP", "interop-path") + resetWSLInfo() + + info := Info() + if !info.IsWSL { + t.Fatalf("Expected IsWSL true when WSL_DISTRO_NAME is set") + } + if info.DistroName != "Ubuntu" { + t.Fatalf("Expected DistroName Ubuntu, got %q", info.DistroName) + } + if info.InteropPath != "interop-path" { + t.Fatalf("Expected InteropPath interop-path, got %q", info.InteropPath) + } +} \ No newline at end of file From 8148b1f3b4b1c636f108520b2cae084d56983835 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Thu, 30 Jul 2026 10:22:03 -0400 Subject: [PATCH 09/36] refactor: simplify the path resolution --- internal/discord/paths_common.go | 276 +++++++++++--------------- internal/discord/paths_common_test.go | 187 +++++++++++------ 2 files changed, 233 insertions(+), 230 deletions(-) diff --git a/internal/discord/paths_common.go b/internal/discord/paths_common.go index 0e34f12..a528375 100644 --- a/internal/discord/paths_common.go +++ b/internal/discord/paths_common.go @@ -3,209 +3,155 @@ package discord import ( - "io/fs" + "encoding/json" "os" "path/filepath" - "sort" "strings" + "github.com/betterdiscord/cli/internal/models" "github.com/betterdiscord/cli/internal/utils" ) -// validateWindowsStyleInstall validates a Windows-style Discord installation path. -// This is used for native Windows installs and WSL installs that point to Windows Discord. -// Windows Discord has a nested structure: Discord/app-1.0.9002/modules/discord_desktop_core-1/discord_desktop_core -func validateWindowsStyleInstall(proposed string) *DiscordInstall { - var finalPath = "" - var selected = filepath.Base(proposed) - - if strings.HasPrefix(selected, "Discord") { - // Get version dir like app-1.0.9002 - dFiles, err := os.ReadDir(proposed) - if err != nil { - return nil - } - - candidates := utils.Filter(dFiles, func(file fs.DirEntry) bool { - return file.IsDir() && versionRegex.MatchString(file.Name()) - }) - if len(candidates) == 0 { - return nil - } - sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) - versionDir := candidates[len(candidates)-1].Name() +// buildInfo mirrors the fields we care about in Discord's resources/build_info.json. +type buildInfo struct { + ReleaseChannel string `json:"releaseChannel"` + Version string `json:"version"` +} - // 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") - }) - if len(candidates) == 0 { - return nil - } - coreWrap := candidates[len(candidates)-1].Name() +// readBuildInfo reads resources/build_info.json. The second return is false when +// the file is absent or unparseable, so callers can fall back to path parsing. +func readBuildInfo(resourcesDir string) (buildInfo, bool) { + data, err := os.ReadFile(filepath.Join(resourcesDir, "build_info.json")) + if err != nil { + return buildInfo{}, false + } - finalPath = filepath.Join(proposed, versionDir, "modules", coreWrap, "discord_desktop_core") + var info buildInfo + if err := json.Unmarshal(data, &info); err != nil { + return buildInfo{}, false } - // Handle app-* directories (e.g., app-1.0.9002) - if strings.HasPrefix(selected, "app-") { - dFiles, err := os.ReadDir(filepath.Join(proposed, "modules")) - if err != nil { - return nil - } + return info, true +} - candidates := utils.Filter(dFiles, func(file fs.DirEntry) bool { - return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") - }) - if len(candidates) == 0 { - return nil - } - coreWrap := candidates[len(candidates)-1].Name() - finalPath = filepath.Join(proposed, "modules", coreWrap, "discord_desktop_core") - } +// hasAppAsar reports whether dir contains Discord's app.asar. +func hasAppAsar(dir string) bool { + return utils.Exists(filepath.Join(dir, "app.asar")) +} - if selected == "discord_desktop_core" { - finalPath = proposed +// latestAppDir returns the highest-versioned `app-{version}` child of base, or +// "" when none exist. Sorting is numeric so 1.0.10000 beats 1.0.9999. +func latestAppDir(base string) string { + entries, err := os.ReadDir(base) + if err != nil { + return "" } - // Verify the path and core.asar exist - if utils.Exists(finalPath) && utils.Exists(filepath.Join(finalPath, "core.asar")) { - return &DiscordInstall{ - ResourcesPath: finalPath, - Channel: GetChannel(finalPath), - Version: GetVersion(finalPath), - IsFlatpak: false, - IsSnap: false, + bestName := "" + bestVersion := "" + for _, entry := range entries { + if !entry.IsDir() || !strings.HasPrefix(entry.Name(), "app-") { + continue + } + version := strings.TrimPrefix(entry.Name(), "app-") + if !versionRegex.MatchString(version) { + continue + } + if bestName == "" || utils.CompareVersions(version, bestVersion) > 0 { + bestName, bestVersion = entry.Name(), version } } - return nil + return bestName } -// validateUnixStyleInstall validates a Unix-style Discord installation path (Linux native, macOS). -// Unix Discord sometimes has a flatter structure: discord/0.0.35/modules/discord_desktop_core -// But sometimes it has the same pattern as Windows. This function detects both patterns and also -// identifies Flatpak and Snap installations if requested. -func validateUnixStyleInstall(proposed string, detectFlatpak bool, detectSnap bool) *DiscordInstall { - var finalPath = "" - var selected = filepath.Base(proposed) - - // Flatpak specific handling - if strings.HasPrefix(selected, "com.discordapp") { - channelPaths, err := os.ReadDir(filepath.Join(proposed, "config")) - if err != nil { - return nil - } - - candidates := utils.Filter(channelPaths, func(file fs.DirEntry) bool { - return file.IsDir() && strings.HasPrefix(file.Name(), "discord") - }) +// resolveResources locates the `resources` directory holding app.asar from a +// variety of proposed inputs, returning "" when none is found: +// - a resources dir itself (or macOS Contents/Resources) โ€” app.asar is directly inside +// - an `app-{version}` dir โ€” drills into its `resources` +// - a dir that directly contains `resources/app.asar` (flatpak files/{channel-}) +// - a base holding `app-{version}` dirs (Discord root / channel config dir) โ€” picks latest +func resolveResources(proposed string) string { + // The proposed path already holds app.asar (resources / macOS Contents/Resources). + if hasAppAsar(proposed) { + return proposed + } - if len(candidates) == 0 { - return nil + if strings.HasPrefix(filepath.Base(proposed), "app-") { + if res := filepath.Join(proposed, "resources"); hasAppAsar(res) { + return res } + return "" + } - // Assume the first candidate is the correct one (e.g., discord or discordcanary) - // Then set proposed and select so the remaining logic can find the core.asar - // - // TODO: This entire validation function could be refactored to use this fall-through logic - // instead of trying to fully handle each pattern, but for now this is a simple way to support - // Flatpak's extra nesting without breaking existing validations - channelPath := candidates[0].Name() - proposed = filepath.Join(proposed, "config", channelPath) - selected = channelPath + // A dir with a direct `resources` child (flatpak files/{channel-}). + if res := filepath.Join(proposed, "resources"); hasAppAsar(res) { + return res } - if strings.HasPrefix(strings.ToLower(selected), "discord") { - // Get version dir like 0.0.35 - dFiles, err := os.ReadDir(proposed) - if err != nil { - return nil + // A base containing versioned app dirs (Windows Discord root, Linux channel dir). + if latest := latestAppDir(proposed); latest != "" { + if res := filepath.Join(proposed, latest, "resources"); hasAppAsar(res) { + return res } + } - candidates := utils.Filter(dFiles, func(file fs.DirEntry) bool { - return file.IsDir() && versionRegex.MatchString(file.Name()) - }) - if len(candidates) == 0 { - return nil - } - sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) - versionDir := candidates[len(candidates)-1].Name() + return "" +} - // 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") - }) +// newResourcesInstall builds a DiscordInstall for a resolved resources dir, +// preferring build_info.json for channel/version and falling back to the path. +func newResourcesInstall(resourcesDir string) *DiscordInstall { + channel := GetChannel(resourcesDir) + version := GetVersion(resourcesDir) - if len(candidates) == 0 { - return nil + if info, ok := readBuildInfo(resourcesDir); ok { + if info.ReleaseChannel != "" { + channel = models.ParseChannel(info.ReleaseChannel) } - - // If no core wrap is found, assume the structure is flatter and point directly to discord_desktop_core - coreWrap := candidates[len(candidates)-1].Name() - if coreWrap == "discord_desktop_core" { - finalPath = filepath.Join(proposed, versionDir, "modules", "discord_desktop_core") - } else { - finalPath = filepath.Join(proposed, versionDir, "modules", coreWrap, "discord_desktop_core") + if info.Version != "" { + version = info.Version } } - // Handle version directories (e.g. app-0.0.35, 0.0.35) - if strings.HasPrefix(selected, "app-") || versionRegex.MatchString(selected) { - dFiles, err := os.ReadDir(filepath.Join(proposed, "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") - }) - - if len(candidates) == 0 { - return nil - } - - // If no core wrap is found, assume the structure is flatter and point directly to discord_desktop_core - coreWrap := candidates[len(candidates)-1].Name() - if coreWrap == "discord_desktop_core" { - finalPath = filepath.Join(proposed, "modules", "discord_desktop_core") - } else { - finalPath = filepath.Join(proposed, "modules", coreWrap, "discord_desktop_core") - } + return &DiscordInstall{ + ResourcesPath: resourcesDir, + Channel: channel, + Version: version, } +} - if selected == "discord_desktop_core" { - finalPath = proposed +// validateWindowsStyleInstall validates a Windows-style install (native Windows +// and WSL pointing at Windows Discord). The new updater lays out installs as +// Discord/app-{version}/resources/app.asar. +func validateWindowsStyleInstall(proposed string) *DiscordInstall { + resources := resolveResources(proposed) + if resources == "" { + return nil } + return newResourcesInstall(resources) +} - // Verify the path and core.asar exist - if utils.Exists(finalPath) && utils.Exists(filepath.Join(finalPath, "core.asar")) { - isFlatpak := false - isSnap := false +// validateUnixStyleInstall validates a Unix-style install (Linux native, macOS). +// Linux native mirrors the Windows layout under the config dir +// (~/.config/{channel}/app-{version}/resources); macOS keeps app.asar directly in +// the bundle's Contents/Resources. Flatpak/Snap are flagged via the resolved path. +func validateUnixStyleInstall(proposed string, detectFlatpak bool, detectSnap bool) *DiscordInstall { + resources := resolveResources(proposed) + if resources == "" { + return nil + } - if detectFlatpak { - isFlatpak = strings.Contains(finalPath, "com.discordapp.") - } - if detectSnap { - isSnap = strings.Contains(finalPath, "snap/") - } + install := newResourcesInstall(resources) - return &DiscordInstall{ - ResourcesPath: finalPath, - Channel: GetChannel(finalPath), - Version: GetVersion(finalPath), - IsFlatpak: isFlatpak, - IsSnap: isSnap, - } + // Heuristic: infer packaging format from the resolved path. These substring + // checks match the real Flatpak/Snap layouts in practice. + if detectFlatpak { + install.IsFlatpak = strings.Contains(resources, "com.discordapp.") + } + if detectSnap { + install.IsSnap = strings.Contains(resources, "snap"+string(filepath.Separator)) } - return nil -} + 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 e0750ee..3aad9be 100644 --- a/internal/discord/paths_common_test.go +++ b/internal/discord/paths_common_test.go @@ -4,149 +4,206 @@ import ( "os" "path/filepath" "testing" + + "github.com/betterdiscord/cli/internal/models" ) -func writeCoreAsar(t *testing.T, resourcesPath string) { +// writeAppAsar creates a resources dir seeded with an app.asar. +func writeAppAsar(t *testing.T, resourcesDir string) { t.Helper() - if err := os.MkdirAll(resourcesPath, 0755); err != nil { - t.Fatalf("Failed to create core path: %v", err) + if err := os.MkdirAll(resourcesDir, 0755); err != nil { + t.Fatalf("Failed to create resources dir: %v", err) } - if err := os.WriteFile(filepath.Join(resourcesPath, "core.asar"), []byte("test"), 0644); err != nil { - t.Fatalf("Failed to write core.asar: %v", err) + if err := os.WriteFile(filepath.Join(resourcesDir, "app.asar"), []byte("test"), 0644); err != nil { + t.Fatalf("Failed to write app.asar: %v", err) } } func TestValidateWindowsStyleInstall_FromDiscordRoot(t *testing.T) { tmpDir := t.TempDir() root := filepath.Join(tmpDir, "Discord") - versionDir := filepath.Join(root, "app-1.0.9002") - coreWrap := filepath.Join(versionDir, "modules", "discord_desktop_core-1", "discord_desktop_core") - - writeCoreAsar(t, coreWrap) + resources := filepath.Join(root, "app-1.0.9002", "resources") + writeAppAsar(t, resources) result := validateWindowsStyleInstall(root) if result == nil { t.Fatalf("Expected install for %s", root) } - if result.ResourcesPath != coreWrap { - t.Errorf("ResourcesPath = %s, expected %s", result.ResourcesPath, coreWrap) + if result.ResourcesPath != resources { + t.Errorf("ResourcesPath = %s, expected %s", result.ResourcesPath, resources) } } -func TestValidateWindowsStyleInstall_FromAppFolder(t *testing.T) { +func TestValidateWindowsStyleInstall_PicksLatestVersion(t *testing.T) { tmpDir := t.TempDir() root := filepath.Join(tmpDir, "Discord") - versionDir := filepath.Join(root, "app-1.0.9002") - coreWrap := filepath.Join(versionDir, "modules", "discord_desktop_core-1", "discord_desktop_core") + // An older leftover version dir plus the current one. + writeAppAsar(t, filepath.Join(root, "app-1.0.9002", "resources")) + latest := filepath.Join(root, "app-1.0.10000", "resources") + writeAppAsar(t, latest) - writeCoreAsar(t, coreWrap) + result := validateWindowsStyleInstall(root) + if result == nil { + t.Fatalf("Expected install for %s", root) + } + if result.ResourcesPath != latest { + t.Errorf("ResourcesPath = %s, expected latest %s", result.ResourcesPath, latest) + } +} + +func TestValidateWindowsStyleInstall_FromAppFolder(t *testing.T) { + tmpDir := t.TempDir() + versionDir := filepath.Join(tmpDir, "Discord", "app-1.0.9002") + resources := filepath.Join(versionDir, "resources") + writeAppAsar(t, resources) result := validateWindowsStyleInstall(versionDir) if result == nil { t.Fatalf("Expected install for %s", versionDir) } - if result.ResourcesPath != coreWrap { - t.Errorf("ResourcesPath = %s, expected %s", result.ResourcesPath, coreWrap) + if result.ResourcesPath != resources { + t.Errorf("ResourcesPath = %s, expected %s", result.ResourcesPath, resources) } } -func TestValidateWindowsStyleInstall_FromCoreFolder(t *testing.T) { - tmpDir := t.TempDir() - resourcesPath := filepath.Join(tmpDir, "discord_desktop_core") - writeCoreAsar(t, resourcesPath) +func TestValidateWindowsStyleInstall_FromResourcesFolder(t *testing.T) { + resources := filepath.Join(t.TempDir(), "resources") + writeAppAsar(t, resources) - result := validateWindowsStyleInstall(resourcesPath) + result := validateWindowsStyleInstall(resources) if result == nil { - t.Fatalf("Expected install for %s", resourcesPath) + t.Fatalf("Expected install for %s", resources) } - if result.ResourcesPath != resourcesPath { - t.Errorf("ResourcesPath = %s, expected %s", result.ResourcesPath, resourcesPath) + if result.ResourcesPath != resources { + t.Errorf("ResourcesPath = %s, expected %s", result.ResourcesPath, resources) } } func TestValidateWindowsStyleInstall_MissingAsar(t *testing.T) { tmpDir := t.TempDir() root := filepath.Join(tmpDir, "Discord") - versionDir := filepath.Join(root, "app-1.0.9002") - coreWrap := filepath.Join(versionDir, "modules", "discord_desktop_core-1", "discord_desktop_core") - - if err := os.MkdirAll(coreWrap, 0755); err != nil { - t.Fatalf("Failed to create core path: %v", err) + // resources dir exists but has no app.asar. + if err := os.MkdirAll(filepath.Join(root, "app-1.0.9002", "resources"), 0755); err != nil { + t.Fatalf("Failed to create resources dir: %v", err) } - result := validateWindowsStyleInstall(root) - if result != nil { - t.Fatalf("Expected no install when core.asar is missing") + if result := validateWindowsStyleInstall(root); result != nil { + t.Fatalf("Expected no install when app.asar is missing") } } -func TestValidateUnixStyleInstall_FromDiscordRoot(t *testing.T) { +func TestValidateUnixStyleInstall_FromChannelRoot(t *testing.T) { tmpDir := t.TempDir() root := filepath.Join(tmpDir, "discord") - resourcesPath := filepath.Join(root, "0.0.35", "modules", "discord_desktop_core") - - writeCoreAsar(t, resourcesPath) + resources := filepath.Join(root, "app-0.0.90", "resources") + writeAppAsar(t, resources) result := validateUnixStyleInstall(root, true, true) if result == nil { t.Fatalf("Expected install for %s", root) } - if result.ResourcesPath != resourcesPath { - t.Errorf("ResourcesPath = %s, expected %s", result.ResourcesPath, resourcesPath) + if result.ResourcesPath != resources { + t.Errorf("ResourcesPath = %s, expected %s", result.ResourcesPath, resources) + } + if result.IsFlatpak || result.IsSnap { + t.Errorf("plain path should not flag flatpak/snap: %+v", result) } } func TestValidateUnixStyleInstall_FromVersionFolder(t *testing.T) { tmpDir := t.TempDir() - root := filepath.Join(tmpDir, "discord") - versionDir := filepath.Join(root, "0.0.35") - resourcesPath := filepath.Join(versionDir, "modules", "discord_desktop_core") - - writeCoreAsar(t, resourcesPath) + versionDir := filepath.Join(tmpDir, "discord", "app-0.0.90") + resources := filepath.Join(versionDir, "resources") + writeAppAsar(t, resources) result := validateUnixStyleInstall(versionDir, true, true) if result == nil { t.Fatalf("Expected install for %s", versionDir) } - if result.ResourcesPath != resourcesPath { - t.Errorf("ResourcesPath = %s, expected %s", result.ResourcesPath, resourcesPath) + if result.ResourcesPath != resources { + t.Errorf("ResourcesPath = %s, expected %s", result.ResourcesPath, resources) } } func TestValidateUnixStyleInstall_FlatpakDetection(t *testing.T) { tmpDir := t.TempDir() - root := filepath.Join(tmpDir, "com.discordapp.Discord", "config", "discord") - resourcesPath := filepath.Join(root, "0.0.35", "modules", "discord_desktop_core") + // Flatpak deployment layout: files/{channel-}/resources (no app-* segment). + resources := filepath.Join(tmpDir, "com.discordapp.Discord", "files", "discord", "resources") + writeAppAsar(t, resources) - writeCoreAsar(t, resourcesPath) - - result := validateUnixStyleInstall(root, true, false) + result := validateUnixStyleInstall(resources, true, false) if result == nil { - t.Fatalf("Expected install for %s", root) + t.Fatalf("Expected install for %s", resources) } if !result.IsFlatpak { - t.Fatalf("Expected flatpak detection") + t.Fatalf("Expected flatpak detection for %s", resources) } if result.IsSnap { t.Fatalf("Did not expect snap detection") } } -func TestValidateUnixStyleInstall_SnapDetection(t *testing.T) { - tmpDir := t.TempDir() - root := filepath.Join(tmpDir, "snap", "discord", "current", ".config", "discord") - resourcesPath := filepath.Join(root, "0.0.35", "modules", "discord_desktop_core") +func TestReadBuildInfo(t *testing.T) { + t.Run("present", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "build_info.json"), + []byte(`{"releaseChannel":"canary","version":"1.0.1234"}`), 0644); err != nil { + t.Fatalf("write build_info: %v", err) + } + info, ok := readBuildInfo(dir) + if !ok { + t.Fatal("expected ok=true for a present build_info.json") + } + if info.ReleaseChannel != "canary" || info.Version != "1.0.1234" { + t.Errorf("parsed = %+v", info) + } + }) + + t.Run("absent", func(t *testing.T) { + if _, ok := readBuildInfo(t.TempDir()); ok { + t.Error("expected ok=false when build_info.json is absent") + } + }) + + t.Run("malformed", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "build_info.json"), []byte("{not json"), 0644); err != nil { + t.Fatalf("write: %v", err) + } + if _, ok := readBuildInfo(dir); ok { + t.Error("expected ok=false for malformed build_info.json") + } + }) +} - writeCoreAsar(t, resourcesPath) +func TestNewResourcesInstall_PrefersBuildInfo(t *testing.T) { + // Path segments say stable/no-version, but build_info.json says canary/1.0.5. + resources := filepath.Join(t.TempDir(), "discord", "app-0.0.1", "resources") + writeAppAsar(t, resources) + if err := os.WriteFile(filepath.Join(resources, "build_info.json"), + []byte(`{"releaseChannel":"canary","version":"1.0.5"}`), 0644); err != nil { + t.Fatalf("write build_info: %v", err) + } - result := validateUnixStyleInstall(root, false, true) - if result == nil { - t.Fatalf("Expected install for %s", root) + install := newResourcesInstall(resources) + if install.Channel != models.Canary { + t.Errorf("Channel = %v, expected Canary from build_info", install.Channel) + } + if install.Version != "1.0.5" { + t.Errorf("Version = %q, expected 1.0.5 from build_info", install.Version) } - if !result.IsSnap { - t.Fatalf("Expected snap detection") +} + +func TestNewResourcesInstall_FallsBackToPath(t *testing.T) { + // No build_info.json โ†’ channel/version come from the path. + resources := filepath.Join(t.TempDir(), "discordcanary", "app-0.0.90", "resources") + writeAppAsar(t, resources) + + install := newResourcesInstall(resources) + if install.Channel != models.Canary { + t.Errorf("Channel = %v, expected Canary from path", install.Channel) } - if result.IsFlatpak { - t.Fatalf("Did not expect flatpak detection") + if install.Version != "0.0.90" { + t.Errorf("Version = %q, expected 0.0.90 from path", install.Version) } } \ No newline at end of file From cf7b35b74b369a2e1cd92b8f17c454e8bb2a5db4 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Thu, 30 Jul 2026 10:26:11 -0400 Subject: [PATCH 10/36] feat: rework injection to match app.asar --- internal/discord/injection.go | 134 +++++++++++++++++++++++++++------- 1 file changed, 108 insertions(+), 26 deletions(-) diff --git a/internal/discord/injection.go b/internal/discord/injection.go index 6463de0..bd0bc2e 100644 --- a/internal/discord/injection.go +++ b/internal/discord/injection.go @@ -2,12 +2,13 @@ package discord import ( _ "embed" + "fmt" "os" "path/filepath" - "strings" "github.com/betterdiscord/cli/internal/betterdiscord" "github.com/betterdiscord/cli/internal/output" + "github.com/betterdiscord/cli/internal/utils" ) //go:embed assets/app_index.js @@ -16,48 +17,129 @@ var appIndexScript string //go:embed assets/app_package.json var appPackageJSON string +// probeWritable verifies dir accepts writes before we perform any destructive +// operation, by creating and removing a throwaway file. This is the elevation +// trigger: a failure here means we abort before touching the bundle. +func probeWritable(dir string) error { + probe := filepath.Join(dir, ".bd-write-probe") + if err := os.WriteFile(probe, []byte{}, 0o644); err != nil { + return err + } + return os.Remove(probe) +} + +// inject shadows Discord's app.asar: it preserves the original as +// betterdiscord.app.asar and drops an `app/` entry directory that loads +// BetterDiscord and then the preserved app. The operation is transactional โ€” +// any failure after the rename rolls back to the original state. +// +// bd is accepted for call-site symmetry with the install flow but unused: the +// injection script resolves the BetterDiscord folder at runtime. func (discord *DiscordInstall) inject(bd *betterdiscord.BDInstall) error { + resources := discord.ResourcesPath + originalAsar := filepath.Join(resources, "app.asar") + preservedAsar := filepath.Join(resources, "betterdiscord.app.asar") + appDir := filepath.Join(resources, "app") - if err := os.WriteFile(filepath.Join(discord.ResourcesPath, "index.js"), []byte(appIndexScript), 0755); err != nil { - output.Printf("โŒ Unable to write index.js in %s\n", discord.ResourcesPath) + // Probe writability before the destructive rename so we never leave a + // half-modified bundle on a read-only/permission-denied target. + if err := probeWritable(resources); err != nil { + output.Printf("โŒ Cannot write to %s\n", resources) output.Printf(" %s\n", err.Error()) return err } - output.Printf("โœ… Injected into %s\n", discord.ResourcesPath) - return nil -} + // Preserve the original app.asar (idempotent, guarded). + renamed := false + switch { + case utils.Exists(preservedAsar): + // Already preserved from a prior injection; leave the archive alone. + case utils.Exists(originalAsar): + if err := os.Rename(originalAsar, preservedAsar); err != nil { + output.Printf("โŒ Unable to preserve app.asar in %s\n", resources) + output.Printf(" %s\n", err.Error()) + return err + } + renamed = true + default: + return fmt.Errorf("no app.asar found in %s", resources) + } -func (discord *DiscordInstall) uninject() error { - indexFile := filepath.Join(discord.ResourcesPath, "index.js") + // Roll back anything done after the rename so a partial failure never + // leaves Discord without a loadable app. + rollback := func() { + os.RemoveAll(appDir) + if renamed && !utils.Exists(originalAsar) { + os.Rename(preservedAsar, originalAsar) + } + } - contents, err := os.ReadFile(indexFile) + if err := os.MkdirAll(appDir, 0755); err != nil { + output.Printf("โŒ Unable to create %s\n", appDir) + output.Printf(" %s\n", err.Error()) + rollback() + return err + } - // 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") { - output.Printf("โœ… No injection found for %s\n", discord.Channel.Name()) - return nil - } + if err := os.WriteFile(filepath.Join(appDir, "package.json"), []byte(appPackageJSON), 0o644); err != nil { + output.Printf("โŒ Unable to write package.json in %s\n", appDir) + output.Printf(" %s\n", err.Error()) + rollback() + return err } - if err := os.WriteFile(indexFile, []byte(`module.exports = require("./core.asar");`), 0o644); err != nil { - output.Printf("โŒ Unable to write file %s\n", indexFile) + if err := os.WriteFile(filepath.Join(appDir, "index.js"), []byte(appIndexScript), 0o644); err != nil { + output.Printf("โŒ Unable to write index.js in %s\n", appDir) output.Printf(" %s\n", err.Error()) + rollback() return err } - output.Printf("โœ… Removed from %s\n", discord.Channel.Name()) + if !utils.Exists(filepath.Join(appDir, "index.js")) || + !utils.Exists(filepath.Join(appDir, "package.json")) || + !utils.Exists(preservedAsar) { + rollback() + return fmt.Errorf("injection verification failed in %s", resources) + } + + output.Printf("โœ… Injected into %s\n", resources) return nil } -// TODO: consider putting this in the betterdiscord package -func (discord *DiscordInstall) IsInjected() bool { - indexFile := filepath.Join(discord.ResourcesPath, "index.js") - contents, err := os.ReadFile(indexFile) - if err != nil { - return false +// uninject reverses inject: it removes the shadow `app/` directory and restores +// Discord's original app.asar from the preserved copy. +func (discord *DiscordInstall) uninject() error { + resources := discord.ResourcesPath + originalAsar := filepath.Join(resources, "app.asar") + preservedAsar := filepath.Join(resources, "betterdiscord.app.asar") + appDir := filepath.Join(resources, "app") + + if utils.Exists(appDir) { + if err := os.RemoveAll(appDir); err != nil { + output.Printf("โŒ Unable to remove %s\n", appDir) + output.Printf(" %s\n", err.Error()) + return err + } } - lower := strings.ToLower(string(contents)) - return strings.Contains(lower, "betterdiscord") + + // Only restore when a preserved copy exists and we wouldn't clobber a live + // app.asar (crash-recovery / partial-state safety). + if utils.Exists(preservedAsar) && !utils.Exists(originalAsar) { + if err := os.Rename(preservedAsar, originalAsar); err != nil { + output.Printf("โŒ Unable to restore app.asar in %s\n", resources) + output.Printf(" %s\n", err.Error()) + return err + } + } + + output.Printf("โœ… Removed from %s\n", discord.Channel.Name()) + return nil } + +// IsInjected reports whether this install currently has the app.asar shadow in +// place: both our `app/index.js` entry and the preserved original must exist. +func (discord *DiscordInstall) IsInjected() bool { + resources := discord.ResourcesPath + return utils.Exists(filepath.Join(resources, "app", "index.js")) && + utils.Exists(filepath.Join(resources, "betterdiscord.app.asar")) +} \ No newline at end of file From 3549c30f7fbe9eba5734db8008e230072a455160 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Thu, 30 Jul 2026 10:28:15 -0400 Subject: [PATCH 11/36] chore: update related tests --- internal/discord/injection_test.go | 238 +++++++++++++++++++---------- internal/discord/install_test.go | 33 ++-- 2 files changed, 181 insertions(+), 90 deletions(-) diff --git a/internal/discord/injection_test.go b/internal/discord/injection_test.go index fb4b1e4..055ddbe 100644 --- a/internal/discord/injection_test.go +++ b/internal/discord/injection_test.go @@ -4,148 +4,230 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "github.com/betterdiscord/cli/internal/models" + "github.com/betterdiscord/cli/internal/utils" ) -const defaultIndexJS = `module.exports = require("./core.asar");` - -func TestIsInjected(t *testing.T) { - tmpDir := t.TempDir() - resourcesPath := filepath.Join(tmpDir, "discord_desktop_core") - if err := os.MkdirAll(resourcesPath, 0755); err != nil { - t.Fatalf("Failed to create core path: %v", err) +// newResourcesDir creates a resources dir seeded with an app.asar of known +// content and returns the dir plus the original content. +func newResourcesDir(t *testing.T) (string, []byte) { + t.Helper() + resources := t.TempDir() + content := []byte("original discord app.asar") + if err := os.WriteFile(filepath.Join(resources, "app.asar"), content, 0o644); err != nil { + t.Fatalf("failed to seed app.asar: %v", err) } - indexFile := filepath.Join(resourcesPath, "index.js") + return resources, content +} - install := &DiscordInstall{ - ResourcesPath: resourcesPath, - Channel: models.Stable, - } +func TestIsInjected(t *testing.T) { + resources := t.TempDir() + install := &DiscordInstall{ResourcesPath: resources, Channel: models.Stable} if install.IsInjected() { - t.Fatalf("Expected IsInjected to be false with missing index.js") + t.Fatal("expected IsInjected false for a bare resources dir") } - if err := os.WriteFile(indexFile, []byte(`module.exports = require("./core.asar");`), 0644); err != nil { - t.Fatalf("Failed to write index.js: %v", err) + // Only the app/ entry, no preserved asar โ†’ not injected. + if err := os.MkdirAll(filepath.Join(resources, "app"), 0o755); err != nil { + t.Fatalf("mkdir app: %v", err) + } + if err := os.WriteFile(filepath.Join(resources, "app", "index.js"), []byte("x"), 0o644); err != nil { + t.Fatalf("write index.js: %v", err) } if install.IsInjected() { - t.Fatalf("Expected IsInjected to be false for default index.js") + t.Fatal("expected IsInjected false without a preserved app.asar") } - if err := os.WriteFile(indexFile, []byte(`// BetterDiscord injected`), 0644); err != nil { - t.Fatalf("Failed to write injection index.js: %v", err) + // Add the preserved asar โ†’ injected. + if err := os.WriteFile(filepath.Join(resources, "betterdiscord.app.asar"), []byte("x"), 0o644); err != nil { + t.Fatalf("write preserved asar: %v", err) } if !install.IsInjected() { - t.Fatalf("Expected IsInjected to be true when BetterDiscord is present") + t.Fatal("expected IsInjected true with app/index.js + preserved asar") } } -func TestInject_WritesInjectionScript(t *testing.T) { - resourcesPath := t.TempDir() - install := &DiscordInstall{ResourcesPath: resourcesPath, Channel: models.Stable} +func TestInject_Clean(t *testing.T) { + resources, original := newResourcesDir(t) + install := &DiscordInstall{ResourcesPath: resources, Channel: models.Stable} - // bd is unused by inject(); nil is fine. if err := install.inject(nil); err != nil { t.Fatalf("inject() failed: %v", err) } - if !install.IsInjected() { - t.Fatal("expected IsInjected() to be true after inject()") + if utils.Exists(filepath.Join(resources, "app.asar")) { + t.Error("expected original app.asar to be renamed away") } - - info, err := os.Stat(filepath.Join(resourcesPath, "index.js")) + preserved, err := os.ReadFile(filepath.Join(resources, "betterdiscord.app.asar")) if err != nil { - t.Fatalf("index.js not written: %v", err) + t.Fatalf("preserved asar missing: %v", err) + } + if string(preserved) != string(original) { + t.Errorf("preserved asar content = %q, expected %q", preserved, original) + } + if !utils.Exists(filepath.Join(resources, "app", "index.js")) { + t.Error("app/index.js not written") + } + if !utils.Exists(filepath.Join(resources, "app", "package.json")) { + t.Error("app/package.json not written") } - // The require target must not carry the executable bit. - if runtime.GOOS != "windows" { - if perm := info.Mode().Perm(); perm != 0o644 { - t.Errorf("index.js mode = %o, expected 644", perm) - } + if !install.IsInjected() { + t.Error("expected IsInjected true after inject()") + } + + // index.js must reference the preserved app and the BD asar. + index, _ := os.ReadFile(filepath.Join(resources, "app", "index.js")) + if want := "../betterdiscord.app.asar"; !strings.Contains(string(index), want) { + t.Errorf("index.js missing %q", want) } } -func TestUninject_RemovesInjection(t *testing.T) { - resourcesPath := t.TempDir() - install := &DiscordInstall{ResourcesPath: resourcesPath, Channel: models.Stable} - indexFile := filepath.Join(resourcesPath, "index.js") +func TestInject_Idempotent(t *testing.T) { + resources, original := newResourcesDir(t) + install := &DiscordInstall{ResourcesPath: resources, Channel: models.Stable} - seed := `require("BetterDiscord/data/betterdiscord.asar");` + "\n" + defaultIndexJS - if err := os.WriteFile(indexFile, []byte(seed), 0o644); err != nil { - t.Fatalf("failed to seed injected index.js: %v", err) + if err := install.inject(nil); err != nil { + t.Fatalf("first inject() failed: %v", err) + } + // Corrupt the shadow index.js so we can confirm the second inject rewrites it + // without re-renaming (which would clobber the real, already-preserved asar). + if err := os.WriteFile(filepath.Join(resources, "app", "index.js"), []byte("stale"), 0o644); err != nil { + t.Fatalf("corrupt index.js: %v", err) } + if err := install.inject(nil); err != nil { + t.Fatalf("second inject() failed: %v", err) + } + + preserved, _ := os.ReadFile(filepath.Join(resources, "betterdiscord.app.asar")) + if string(preserved) != string(original) { + t.Errorf("preserved asar was clobbered on re-inject: got %q", preserved) + } + index, _ := os.ReadFile(filepath.Join(resources, "app", "index.js")) + if string(index) == "stale" { + t.Error("expected index.js to be rewritten on re-inject") + } + if utils.Exists(filepath.Join(resources, "app.asar")) { + t.Error("re-inject must not recreate a live app.asar") + } +} + +func TestUninject_RestoresExactly(t *testing.T) { + resources, original := newResourcesDir(t) + install := &DiscordInstall{ResourcesPath: resources, Channel: models.Stable} + + if err := install.inject(nil); err != nil { + t.Fatalf("inject() failed: %v", err) + } if err := install.uninject(); err != nil { t.Fatalf("uninject() failed: %v", err) } - contents, err := os.ReadFile(indexFile) + restored, err := os.ReadFile(filepath.Join(resources, "app.asar")) if err != nil { - t.Fatalf("index.js missing after uninject: %v", err) + t.Fatalf("app.asar not restored: %v", err) + } + if string(restored) != string(original) { + t.Errorf("restored app.asar = %q, expected %q", restored, original) } - if string(contents) != defaultIndexJS { - t.Errorf("index.js after uninject = %q, expected %q", string(contents), defaultIndexJS) + if utils.Exists(filepath.Join(resources, "betterdiscord.app.asar")) { + t.Error("preserved asar should be gone after uninject") + } + if utils.Exists(filepath.Join(resources, "app")) { + t.Error("shadow app/ should be removed after uninject") } if install.IsInjected() { - t.Error("expected IsInjected() to be false after uninject()") + t.Error("expected IsInjected false after uninject()") } } -func TestUninject_LeavesUninjectedFileUntouched(t *testing.T) { - resourcesPath := t.TempDir() - install := &DiscordInstall{ResourcesPath: resourcesPath, Channel: models.Stable} - indexFile := filepath.Join(resourcesPath, "index.js") +func TestUninject_NotInjectedIsNoop(t *testing.T) { + resources, original := newResourcesDir(t) + install := &DiscordInstall{ResourcesPath: resources, Channel: models.Stable} - original := `module.exports = require("./some-other-core.asar");` - if err := os.WriteFile(indexFile, []byte(original), 0o644); err != nil { - t.Fatalf("failed to seed index.js: %v", err) + if err := install.uninject(); err != nil { + t.Fatalf("uninject() on a clean install failed: %v", err) } - if err := install.uninject(); err != nil { - t.Fatalf("uninject() failed: %v", err) + // A never-injected install keeps its app.asar untouched. + got, _ := os.ReadFile(filepath.Join(resources, "app.asar")) + if string(got) != string(original) { + t.Errorf("uninject touched a clean app.asar: got %q", got) } +} - contents, _ := os.ReadFile(indexFile) - if string(contents) != original { - t.Errorf("uninject rewrote a file with no BetterDiscord marker: got %q", string(contents)) +func TestInject_NoAppAsarErrors(t *testing.T) { + resources := t.TempDir() // empty, no app.asar + install := &DiscordInstall{ResourcesPath: resources, Channel: models.Stable} + + if err := install.inject(nil); err == nil { + t.Fatal("expected an error when no app.asar is present") + } + if utils.Exists(filepath.Join(resources, "app")) { + t.Error("no shadow app/ should be created when there's nothing to inject") } } -func TestUninject_MissingFileWritesDefault(t *testing.T) { - resourcesPath := t.TempDir() - install := &DiscordInstall{ResourcesPath: resourcesPath, Channel: models.Stable} +func TestInject_ProbeFailAbortsBeforeRename(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod-based write denial is unreliable on Windows") + } + if os.Geteuid() == 0 { + t.Skip("running as root bypasses directory write permissions") + } + resources, original := newResourcesDir(t) + install := &DiscordInstall{ResourcesPath: resources, Channel: models.Stable} - // No index.js exists; uninject falls through and writes the default stub. - if err := install.uninject(); err != nil { - t.Fatalf("uninject() failed: %v", err) + if err := os.Chmod(resources, 0o555); err != nil { + t.Fatalf("chmod: %v", err) } + t.Cleanup(func() { _ = os.Chmod(resources, 0o755) }) - contents, err := os.ReadFile(filepath.Join(resourcesPath, "index.js")) + if err := install.inject(nil); err == nil { + t.Fatal("expected inject to fail the writability probe") + } + + // The bundle must be untouched: app.asar still present, nothing renamed. + _ = os.Chmod(resources, 0o755) + got, err := os.ReadFile(filepath.Join(resources, "app.asar")) if err != nil { - t.Fatalf("expected index.js to be created: %v", err) + t.Fatalf("app.asar was disturbed by a probe-failed inject: %v", err) } - if string(contents) != defaultIndexJS { - t.Errorf("index.js = %q, expected %q", string(contents), defaultIndexJS) + if string(got) != string(original) { + t.Errorf("app.asar content changed: got %q", got) + } + if utils.Exists(filepath.Join(resources, "betterdiscord.app.asar")) { + t.Error("no rename should have happened after a probe failure") } } -func TestInjectUninject_RoundTrip(t *testing.T) { - resourcesPath := t.TempDir() - install := &DiscordInstall{ResourcesPath: resourcesPath, Channel: models.Stable} +func TestInject_RollbackOnMidOpFailure(t *testing.T) { + resources, original := newResourcesDir(t) + install := &DiscordInstall{ResourcesPath: resources, Channel: models.Stable} - if err := install.inject(nil); err != nil { - t.Fatalf("inject() failed: %v", err) + // Block mkdir(resources/app) by pre-creating a regular file at that path. + // This forces a failure *after* the app.asar rename, exercising rollback. + if err := os.WriteFile(filepath.Join(resources, "app"), []byte("blocker"), 0o644); err != nil { + t.Fatalf("seed blocker file: %v", err) } - if !install.IsInjected() { - t.Fatal("expected injected after inject()") + + if err := install.inject(nil); err == nil { + t.Fatal("expected inject to fail when app/ can't be created") } - if err := install.uninject(); err != nil { - t.Fatalf("uninject() failed: %v", err) + + // Rollback must restore the original app.asar and drop the preserved copy. + restored, err := os.ReadFile(filepath.Join(resources, "app.asar")) + if err != nil { + t.Fatalf("app.asar not restored after rollback: %v", err) } - if install.IsInjected() { - t.Fatal("expected not injected after uninject()") + if string(restored) != string(original) { + t.Errorf("restored app.asar = %q, expected %q", restored, original) + } + 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_test.go b/internal/discord/install_test.go index c36c5d5..9d9c821 100644 --- a/internal/discord/install_test.go +++ b/internal/discord/install_test.go @@ -9,28 +9,37 @@ import ( "github.com/betterdiscord/cli/internal/models" ) -// UninstallBD with neither full-uninstall nor restart should only de-inject the -// core's index.js โ€” the safe path that never touches the global BD folder or +// UninstallBD with neither full-uninstall nor restart should only revert the +// app.asar shadow โ€” the safe path that never touches the global BD folder or // the running Discord process. func TestUninstallBD_UninjectOnly(t *testing.T) { - corePath := t.TempDir() - indexFile := filepath.Join(corePath, "index.js") - seed := `require("BetterDiscord/data/betterdiscord.asar");` + "\n" + `module.exports = require("./core.asar");` - if err := os.WriteFile(indexFile, []byte(seed), 0o644); err != nil { - t.Fatalf("failed to seed injected index.js: %v", err) + resources := t.TempDir() + // Seed an injected state: preserved asar + shadow app/ entry. + original := []byte("original app.asar") + if err := os.WriteFile(filepath.Join(resources, "betterdiscord.app.asar"), original, 0o644); err != nil { + t.Fatalf("seed preserved asar: %v", err) + } + if err := os.MkdirAll(filepath.Join(resources, "app"), 0o755); err != nil { + t.Fatalf("mkdir app: %v", err) + } + if err := os.WriteFile(filepath.Join(resources, "app", "index.js"), []byte("x"), 0o644); err != nil { + t.Fatalf("seed index.js: %v", err) } - install := &DiscordInstall{ResourcesPath: corePath, Channel: models.Stable} + install := &DiscordInstall{ResourcesPath: resources, Channel: models.Stable} if err := install.UninstallBD(models.UninstallOptions{FullUninstall: false, RestartDiscord: false}); err != nil { t.Fatalf("UninstallBD() failed: %v", err) } if install.IsInjected() { - t.Error("expected index.js to be de-injected after UninstallBD") + t.Error("expected the shadow to be reverted after UninstallBD") + } + restored, err := os.ReadFile(filepath.Join(resources, "app.asar")) + if err != nil { + t.Fatalf("app.asar not restored: %v", err) } - contents, _ := os.ReadFile(indexFile) - if want := `module.exports = require("./core.asar");`; string(contents) != want { - t.Errorf("index.js after uninstall = %q, expected %q", string(contents), want) + if string(restored) != string(original) { + t.Errorf("app.asar after uninstall = %q, expected %q", restored, original) } } From ae23d8bf9e2c1a0cbb22d0d5bedb8980ca0927b5 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Thu, 30 Jul 2026 11:02:16 -0400 Subject: [PATCH 12/36] feat: update paths for macos + linux + flatpak --- internal/discord/install.go | 22 +++++---- internal/discord/install_test.go | 69 ++++++++++----------------- internal/discord/paths.go | 6 ++- internal/discord/paths_common.go | 5 ++ internal/discord/paths_common_test.go | 28 +++++++++++ internal/discord/paths_darwin.go | 21 ++++---- internal/discord/paths_linux.go | 30 +++++------- internal/discord/paths_test.go | 12 +++++ 8 files changed, 112 insertions(+), 81 deletions(-) diff --git a/internal/discord/install.go b/internal/discord/install.go index cb178b8..26f271a 100644 --- a/internal/discord/install.go +++ b/internal/discord/install.go @@ -1,10 +1,13 @@ package discord import ( + "os" + "path/filepath" + "strings" + "github.com/betterdiscord/cli/internal/betterdiscord" "github.com/betterdiscord/cli/internal/models" "github.com/betterdiscord/cli/internal/output" - "github.com/betterdiscord/cli/internal/utils" ) type DiscordInstall struct { @@ -110,17 +113,18 @@ func (discord *DiscordInstall) GetBetterDiscordInstall() (*betterdiscord.BDInsta // Gets the global BetterDiscord install bd := betterdiscord.GetInstallation() - // Snaps and flatpaks get their own local BD install - if discord.IsSnap || discord.IsFlatpak { - segment := "config" - if discord.IsSnap { - segment = ".config" - } - - configPath, err := utils.FindSegment(discord.ResourcesPath, segment) + // Flatpaks get their own local BD folder. The resources path is in the + // read-only deployment tree, so we can't derive the sandbox config from it; + // instead we compute the stable ~/.var/app/{id}/config location from the + // channel. Inside the sandbox this dir is the app's $XDG_CONFIG_HOME, which + // is exactly where the injected index.js looks for BetterDiscord at runtime. + if discord.IsFlatpak { + home, err := os.UserHomeDir() if err != nil { return nil, err } + id := "com.discordapp." + strings.ReplaceAll(discord.Channel.Name(), " ", "") + configPath := filepath.Join(home, ".var", "app", id, "config") bd = betterdiscord.GetInstallation(configPath) } diff --git a/internal/discord/install_test.go b/internal/discord/install_test.go index 9d9c821..d781020 100644 --- a/internal/discord/install_test.go +++ b/internal/discord/install_test.go @@ -55,56 +55,37 @@ func TestGetBetterDiscordInstall_Global(t *testing.T) { } } -func TestGetBetterDiscordInstall_FlatpakResolvesConfig(t *testing.T) { +// Flatpak's BD folder is recomputed as ~/.var/app/{id}/config/BetterDiscord from +// the channel, independent of the (read-only deployment) resources path. +func TestGetBetterDiscordInstall_FlatpakRecomputesDataRoot(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("uses POSIX-style flatpak paths") } - // A flatpak-style core path containing a "config" segment. - configDir := filepath.Join(t.TempDir(), "config") - corePath := filepath.Join(configDir, "discord", "0.0.1", "modules", "discord_desktop_core") - install := &DiscordInstall{ResourcesPath: corePath, Channel: models.Stable, IsFlatpak: true} - - bd, err := install.GetBetterDiscordInstall() + home, err := os.UserHomeDir() if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if bd == nil { - t.Fatal("expected non-nil BD install") + t.Skipf("no home dir: %v", err) } - if want := filepath.Join(configDir, "BetterDiscord"); bd.Root() != want { - t.Errorf("Root() = %s, expected %s", bd.Root(), want) - } -} -func TestGetBetterDiscordInstall_SnapResolvesConfig(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("uses POSIX-style snap paths") - } - // A snap-style core path uses the ".config" segment. - configDir := filepath.Join(t.TempDir(), ".config") - corePath := filepath.Join(configDir, "discord", "0.0.1", "modules", "discord_desktop_core") - install := &DiscordInstall{ResourcesPath: corePath, Channel: models.Stable, IsSnap: true} + cases := []struct { + channel models.DiscordChannel + id string + }{ + {models.Stable, "com.discordapp.Discord"}, + {models.Canary, "com.discordapp.DiscordCanary"}, + {models.PTB, "com.discordapp.DiscordPTB"}, + } + for _, tc := range cases { + // A resources path in the read-only deployment tree (no "config" segment). + resources := "/var/lib/flatpak/app/" + tc.id + "/current/active/files/discord/resources" + install := &DiscordInstall{ResourcesPath: resources, Channel: tc.channel, IsFlatpak: true} - bd, err := install.GetBetterDiscordInstall() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if want := filepath.Join(configDir, "BetterDiscord"); bd.Root() != want { - t.Errorf("Root() = %s, expected %s", bd.Root(), want) - } -} - -// Regression test for the nil-deref fix: a snap/flatpak core path missing the -// expected config segment must surface an error, not return a nil *BDInstall -// that callers would dereference and panic on. -func TestGetBetterDiscordInstall_FlatpakMissingSegment_Errors(t *testing.T) { - install := &DiscordInstall{ResourcesPath: "/no/matching/segment/here", Channel: models.Stable, IsFlatpak: true} - - bd, err := install.GetBetterDiscordInstall() - if err == nil { - t.Fatal("expected an error when the config segment is missing") - } - if bd != nil { - t.Errorf("expected nil BD install on error, got %+v", bd) + bd, err := install.GetBetterDiscordInstall() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := filepath.Join(home, ".var", "app", tc.id, "config", "BetterDiscord") + if bd.Root() != want { + 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 745646e..77ed937 100644 --- a/internal/discord/paths.go +++ b/internal/discord/paths.go @@ -38,8 +38,12 @@ func GetVersion(proposed string) string { func GetChannel(proposed string) models.DiscordChannel { for folder := range strings.SplitSeq(proposed, string(filepath.Separator)) { + // Normalize the segment so macOS bundle names ("Discord Canary.app") + // match the space-stripped channel names ("discordcanary"). + normalized := strings.ReplaceAll(strings.ToLower(folder), " ", "") + normalized = strings.TrimSuffix(normalized, ".app") for _, channel := range models.Channels { - if strings.ToLower(folder) == strings.ReplaceAll(strings.ToLower(channel.Name()), " ", "") { + if normalized == strings.ReplaceAll(strings.ToLower(channel.Name()), " ", "") { return channel } } diff --git a/internal/discord/paths_common.go b/internal/discord/paths_common.go index a528375..20f5c41 100644 --- a/internal/discord/paths_common.go +++ b/internal/discord/paths_common.go @@ -89,6 +89,11 @@ func resolveResources(proposed string) string { return res } + // A macOS app bundle: app.asar lives in Contents/Resources. + if res := filepath.Join(proposed, "Contents", "Resources"); hasAppAsar(res) { + return res + } + // A base containing versioned app dirs (Windows Discord root, Linux channel dir). if latest := latestAppDir(proposed); latest != "" { if res := filepath.Join(proposed, latest, "resources"); hasAppAsar(res) { diff --git a/internal/discord/paths_common_test.go b/internal/discord/paths_common_test.go index 3aad9be..6b287b0 100644 --- a/internal/discord/paths_common_test.go +++ b/internal/discord/paths_common_test.go @@ -143,6 +143,34 @@ func TestValidateUnixStyleInstall_FlatpakDetection(t *testing.T) { } } +func TestValidateUnixStyleInstall_MacOSBundle(t *testing.T) { + // macOS: app.asar lives in {Bundle}.app/Contents/Resources; channel/version + // come from build_info.json (the bundle name has a space and no version). + tmpDir := t.TempDir() + bundle := filepath.Join(tmpDir, "Discord Canary.app") + resources := filepath.Join(bundle, "Contents", "Resources") + writeAppAsar(t, resources) + if err := os.WriteFile(filepath.Join(resources, "build_info.json"), + []byte(`{"releaseChannel":"canary","version":"1.0.1"}`), 0644); err != nil { + t.Fatalf("write build_info: %v", err) + } + + // Resolving from the bundle path (as a user browsing to Discord.app would). + result := validateUnixStyleInstall(bundle, false, false) + if result == nil { + t.Fatalf("Expected install for bundle %s", bundle) + } + if result.ResourcesPath != resources { + t.Errorf("ResourcesPath = %s, expected %s", result.ResourcesPath, resources) + } + if result.Channel != models.Canary { + t.Errorf("Channel = %v, expected Canary", result.Channel) + } + if result.Version != "1.0.1" { + t.Errorf("Version = %q, expected 1.0.1", result.Version) + } +} + func TestReadBuildInfo(t *testing.T) { t.Run("present", func(t *testing.T) { dir := t.TempDir() diff --git a/internal/discord/paths_darwin.go b/internal/discord/paths_darwin.go index 56a5f01..bf736fe 100644 --- a/internal/discord/paths_darwin.go +++ b/internal/discord/paths_darwin.go @@ -3,24 +3,25 @@ package discord import ( "os" "path/filepath" - "strings" "github.com/betterdiscord/cli/internal/models" ) func init() { - config, _ := os.UserConfigDir() - paths := []string{ - filepath.Join(config, "{channel}"), + home, _ := os.UserHomeDir() + + // On macOS the app.asar lives inside the application bundle + // (Discord.app/Contents/Resources), not under Application Support. Search the + // standard install locations for each channel's bundle. + bases := []string{ + filepath.Join("/", "Applications"), + filepath.Join(home, "Applications"), } for _, channel := range models.Channels { - for _, path := range paths { - folder := strings.ReplaceAll(strings.ToLower(channel.Name()), " ", "") - searchPaths = append( - searchPaths, - strings.ReplaceAll(path, "{channel}", folder), - ) + bundle := channel.Name() + ".app" + for _, base := range bases { + searchPaths = append(searchPaths, filepath.Join(base, bundle)) } } diff --git a/internal/discord/paths_linux.go b/internal/discord/paths_linux.go index fc84ef0..6c65253 100644 --- a/internal/discord/paths_linux.go +++ b/internal/discord/paths_linux.go @@ -13,33 +13,29 @@ func init() { config, _ := os.UserConfigDir() home, _ := os.UserHomeDir() paths := []string{ - // Native. Data is stored under `~/.config`. + // Native. The new updater lays out versioned app dirs under `~/.config`. // Example: `~/.config/discordcanary`. - // Core: `~/.config/discordcanary/0.0.90/modules/discord_desktop_core/core.asar`. - // Updated Core: `~/.config/discordcanary/app-0.0.90/modules/discord_desktop_core-1/discord_desktop_core/core.asar`. + // Resources: `~/.config/discordcanary/app-0.0.90/resources/app.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` - // Updated Core: `.var/app/com.discordapp.DiscordCanary/config/discordcanary/app-0.0.90/modules/discord_desktop_core-1/discord_desktop_core/core.asar`. - filepath.Join(home, ".var", "app", "com.discordapp.{CHANNEL}", "config", "{channel}"), + // Flatpak (global). The app.asar lives in the read-only deployment files. + // Example: `/var/lib/flatpak/app/com.discordapp.DiscordCanary/current/active/files/discord-canary/resources/app.asar`. + filepath.Join("/var", "lib", "flatpak", "app", "com.discordapp.{CHANNEL}", "current", "active", "files", "{channel-}", "resources"), - // 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`. - // Updated Core: `snap/discord-canary/current/.config/discordcanary/app-0.0.90/modules/discord_desktop_core-1/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}"), + // Flatpak (user). Same layout under the per-user flatpak tree (writable). + // Example: `~/.local/share/flatpak/app/com.discordapp.DiscordCanary/current/active/files/discord-canary/resources/app.asar`. + filepath.Join(home, ".local", "share", "flatpak", "app", "com.discordapp.{CHANNEL}", "current", "active", "files", "{channel-}", "resources"), + + // Snap is intentionally omitted: its read-only squashfs mount can't host + // the app.asar shadow, so the new injection method does not support it. } if wsl.IsWSL() { winHome, err := wsl.WindowsHome() if err == nil && winHome != "" { - // WSL. Data is stored under the Windows user's AppData folder. + // WSL. Windows Discord installs under the Windows user's AppData folder. // Example: `/mnt/c/Users/Username/AppData/Local/DiscordCanary`. - // Core: `/mnt/c/Users/Username/AppData/Local/DiscordCanary/app-1.0.9218/modules/discord_desktop_core-1/discord_desktop_core core.asar`. + // Resources: `/mnt/c/Users/Username/AppData/Local/DiscordCanary/app-1.0.9218/resources/app.asar`. paths = append(paths, filepath.Join(winHome, "AppData", "Local", "{CHANNEL}")) } } diff --git a/internal/discord/paths_test.go b/internal/discord/paths_test.go index b2f7c75..c34e50b 100644 --- a/internal/discord/paths_test.go +++ b/internal/discord/paths_test.go @@ -151,6 +151,18 @@ func TestGetChannel(t *testing.T) { path: "", expected: models.Stable, }, + + // New injection + { + name: "macOS bundle name", + path: filepath.Join("/Applications", "Discord Canary.app", "Contents", "Resources"), + expected: models.Canary, + }, + { + name: "macOS stable bundle name", + path: filepath.Join("/Applications", "Discord.app", "Contents", "Resources"), + expected: models.Stable, + }, } for _, tt := range tests { From 56b6f9449d5927724f03ff44058ac714905f4665 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Thu, 30 Jul 2026 23:51:56 -0400 Subject: [PATCH 13/36] fix: remove references to corepath --- cmd/install.go | 4 ++-- internal/discord/install.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/install.go b/cmd/install.go index 3548b97..d889e12 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -42,8 +42,8 @@ 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) } diff --git a/internal/discord/install.go b/internal/discord/install.go index 26f271a..ab07d15 100644 --- a/internal/discord/install.go +++ b/internal/discord/install.go @@ -11,7 +11,7 @@ import ( ) type DiscordInstall struct { - ResourcesPath string `json:"corePath"` + ResourcesPath string `json:"resourcesPath"` Channel models.DiscordChannel `json:"channel"` Version string `json:"version"` IsFlatpak bool `json:"isFlatpak"` From 845219b53d3fe23f04d1c2a1c76462f3a72fa850 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Fri, 31 Jul 2026 09:46:01 -0400 Subject: [PATCH 14/36] fix: resolve injected folders --- internal/discord/injection_test.go | 39 +++++++++++++++++++++++++ internal/discord/paths_common.go | 35 +++++++++++++--------- internal/discord/paths_common_test.go | 42 +++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 13 deletions(-) diff --git a/internal/discord/injection_test.go b/internal/discord/injection_test.go index 055ddbe..6d8a497 100644 --- a/internal/discord/injection_test.go +++ b/internal/discord/injection_test.go @@ -205,6 +205,45 @@ func TestInject_ProbeFailAbortsBeforeRename(t *testing.T) { } } +// Regression for the "invisible after injection" bug: injecting renames app.asar +// to betterdiscord.app.asar, so a resolver anchored only on app.asar would fail +// to find the install afterward โ€” breaking repair and, critically, uninstall. +func TestInjectThenResolve_RemainsDiscoverable(t *testing.T) { + tmpDir := t.TempDir() + root := filepath.Join(tmpDir, "Discord") + resources := filepath.Join(root, "app-1.0.9002", "resources") + writeAppAsar(t, resources) // pristine install + + if validateWindowsStyleInstall(root) == nil { + t.Fatal("precondition: pristine install should resolve") + } + + install := &DiscordInstall{ResourcesPath: resources, Channel: models.Stable} + if err := install.inject(nil); err != nil { + t.Fatalf("inject: %v", err) + } + + // The fix: it must still resolve from the top-level Discord root after injection. + resolved := validateWindowsStyleInstall(root) + if resolved == nil { + t.Fatal("injected install no longer resolves โ€” uninstall would be impossible") + } + if resolved.ResourcesPath != resources { + t.Errorf("ResourcesPath = %s, expected %s", resolved.ResourcesPath, resources) + } + if !resolved.IsInjected() { + t.Error("expected the resolved install to report IsInjected") + } + + // And uninstall works from the resolved install. + if err := resolved.uninject(); err != nil { + t.Fatalf("uninject: %v", err) + } + if !utils.Exists(filepath.Join(resources, "app.asar")) { + t.Error("app.asar not restored after uninject") + } +} + func TestInject_RollbackOnMidOpFailure(t *testing.T) { resources, original := newResourcesDir(t) install := &DiscordInstall{ResourcesPath: resources, Channel: models.Stable} diff --git a/internal/discord/paths_common.go b/internal/discord/paths_common.go index 20f5c41..075dfb9 100644 --- a/internal/discord/paths_common.go +++ b/internal/discord/paths_common.go @@ -34,9 +34,17 @@ func readBuildInfo(resourcesDir string) (buildInfo, bool) { return info, true } -// hasAppAsar reports whether dir contains Discord's app.asar. -func hasAppAsar(dir string) bool { - return utils.Exists(filepath.Join(dir, "app.asar")) +// hasDiscordApp reports whether dir is a Discord `resources` directory โ€” that +// is, it contains Discord's app archive in *either* state: +// - `app.asar` โ€” a pristine (or freshly updated) install, and +// - `betterdiscord.app.asar` โ€” the original preserved after BetterDiscord +// injects its shadow `app/` folder (at which point `app.asar` no longer +// exists). +// +// Checking both is essential: once injected, an install would otherwise stop +// resolving, so users could no longer repair or โ€” critically โ€” uninstall it. +func hasDiscordApp(dir string) bool { + return utils.Exists(filepath.Join(dir, "app.asar")) || utils.Exists(filepath.Join(dir, "betterdiscord.app.asar")) } // latestAppDir returns the highest-versioned `app-{version}` child of base, or @@ -65,38 +73,39 @@ func latestAppDir(base string) string { return bestName } -// resolveResources locates the `resources` directory holding app.asar from a -// variety of proposed inputs, returning "" when none is found: -// - a resources dir itself (or macOS Contents/Resources) โ€” app.asar is directly inside +// resolveResources locates the Discord `resources` directory (holding Discord's +// app archive โ€” see hasDiscordApp) from a variety of proposed inputs, returning +// "" when none is found: +// - a resources dir itself (or macOS Contents/Resources) โ€” the archive is directly inside // - an `app-{version}` dir โ€” drills into its `resources` -// - a dir that directly contains `resources/app.asar` (flatpak files/{channel-}) +// - a dir that directly contains a `resources` child (flatpak files/{channel-}) // - a base holding `app-{version}` dirs (Discord root / channel config dir) โ€” picks latest func resolveResources(proposed string) string { - // The proposed path already holds app.asar (resources / macOS Contents/Resources). - if hasAppAsar(proposed) { + // The proposed path is already the resources dir (or macOS Contents/Resources). + if hasDiscordApp(proposed) { return proposed } if strings.HasPrefix(filepath.Base(proposed), "app-") { - if res := filepath.Join(proposed, "resources"); hasAppAsar(res) { + if res := filepath.Join(proposed, "resources"); hasDiscordApp(res) { return res } return "" } // A dir with a direct `resources` child (flatpak files/{channel-}). - if res := filepath.Join(proposed, "resources"); hasAppAsar(res) { + if res := filepath.Join(proposed, "resources"); hasDiscordApp(res) { return res } // A macOS app bundle: app.asar lives in Contents/Resources. - if res := filepath.Join(proposed, "Contents", "Resources"); hasAppAsar(res) { + if res := filepath.Join(proposed, "Contents", "Resources"); hasDiscordApp(res) { return res } // A base containing versioned app dirs (Windows Discord root, Linux channel dir). if latest := latestAppDir(proposed); latest != "" { - if res := filepath.Join(proposed, latest, "resources"); hasAppAsar(res) { + if res := filepath.Join(proposed, latest, "resources"); hasDiscordApp(res) { return res } } diff --git a/internal/discord/paths_common_test.go b/internal/discord/paths_common_test.go index 6b287b0..468e09b 100644 --- a/internal/discord/paths_common_test.go +++ b/internal/discord/paths_common_test.go @@ -19,6 +19,48 @@ func writeAppAsar(t *testing.T, resourcesDir string) { } } +// writeInjectedResources creates a resources dir in the *injected* state: +// app.asar has been renamed to betterdiscord.app.asar and a shadow app/ exists. +func writeInjectedResources(t *testing.T, resourcesDir string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(resourcesDir, "app"), 0755); err != nil { + t.Fatalf("Failed to create app dir: %v", err) + } + if err := os.WriteFile(filepath.Join(resourcesDir, "betterdiscord.app.asar"), []byte("preserved"), 0644); err != nil { + t.Fatalf("Failed to write preserved asar: %v", err) + } + if err := os.WriteFile(filepath.Join(resourcesDir, "app", "index.js"), []byte("// bd"), 0644); err != nil { + t.Fatalf("Failed to write index.js: %v", err) + } +} + +// Regression: an install stays resolvable after injection (app.asar renamed to +// betterdiscord.app.asar). If it didn't, users couldn't repair or uninstall it. +func TestValidateWindowsStyleInstall_ResolvesInjected(t *testing.T) { + tmpDir := t.TempDir() + root := filepath.Join(tmpDir, "Discord") + resources := filepath.Join(root, "app-1.0.9002", "resources") + writeInjectedResources(t, resources) // no app.asar, only betterdiscord.app.asar + + result := validateWindowsStyleInstall(root) + if result == nil { + t.Fatalf("injected install must still resolve for %s", root) + } + if result.ResourcesPath != resources { + t.Errorf("ResourcesPath = %s, expected %s", result.ResourcesPath, resources) + } +} + +func TestResolveResources_InjectedResourcesDir(t *testing.T) { + // Browsing/uninstalling straight to an injected resources dir must resolve. + resources := filepath.Join(t.TempDir(), "resources") + writeInjectedResources(t, resources) + + if got := resolveResources(resources); got != resources { + t.Errorf("resolveResources(injected) = %q, expected %q", got, resources) + } +} + func TestValidateWindowsStyleInstall_FromDiscordRoot(t *testing.T) { tmpDir := t.TempDir() root := filepath.Join(tmpDir, "Discord") From 18307644a297686e328ad3f59ccb852dd1c1dfb7 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Fri, 31 Jul 2026 09:48:21 -0400 Subject: [PATCH 15/36] fix: reinject should always work --- internal/discord/injection.go | 20 ++++++++++++++-- internal/discord/injection_test.go | 38 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/internal/discord/injection.go b/internal/discord/injection.go index bd0bc2e..cca88a6 100644 --- a/internal/discord/injection.go +++ b/internal/discord/injection.go @@ -50,17 +50,33 @@ func (discord *DiscordInstall) inject(bd *betterdiscord.BDInstall) error { } // Preserve the original app.asar (idempotent, guarded). + // + // A live app.asar is always Discord's current app and takes priority: it + // must be renamed away or it would shadow our app/ folder (Electron loads + // app.asar before app/), silently disabling BetterDiscord. If a preserved + // copy is also present โ€” e.g. Discord repaired/reinstalled over a previous + // injection โ€” that copy is stale, so we discard it and re-preserve the live + // app. Only when there is no live app.asar do we treat an existing preserved + // copy as the (already-injected) source of truth and leave it be. renamed := false switch { - case utils.Exists(preservedAsar): - // Already preserved from a prior injection; leave the archive alone. case utils.Exists(originalAsar): + if utils.Exists(preservedAsar) { + if err := os.Remove(preservedAsar); err != nil { + output.Printf("โŒ Unable to replace stale %s\n", preservedAsar) + output.Printf(" %s\n", err.Error()) + return err + } + } if err := os.Rename(originalAsar, preservedAsar); err != nil { output.Printf("โŒ Unable to preserve app.asar in %s\n", resources) output.Printf(" %s\n", err.Error()) return err } renamed = true + case utils.Exists(preservedAsar): + // Already preserved from a prior injection and no live app.asar; the + // archive is correct โ€” only the shadow app/ needs (re)writing below. default: return fmt.Errorf("no app.asar found in %s", resources) } diff --git a/internal/discord/injection_test.go b/internal/discord/injection_test.go index 6d8a497..20a59a2 100644 --- a/internal/discord/injection_test.go +++ b/internal/discord/injection_test.go @@ -116,6 +116,44 @@ func TestInject_Idempotent(t *testing.T) { } } +// Anomalous pre-state: a live app.asar AND a leftover betterdiscord.app.asar + +// app/ (e.g. Discord repaired/reinstalled over a prior injection). inject() must +// treat the live app.asar as authoritative โ€” discard the stale preserved copy, +// preserve the live app, and rename app.asar away so our app/ shadow loads +// (Electron would otherwise load the lingering app.asar and disable BD). +func TestInject_LiveAsarWinsOverStalePreserved(t *testing.T) { + resources := t.TempDir() + live := []byte("LIVE current app.asar") + stale := []byte("stale old preserved app") + if err := os.WriteFile(filepath.Join(resources, "app.asar"), live, 0o644); err != nil { + t.Fatalf("seed live app.asar: %v", err) + } + if err := os.WriteFile(filepath.Join(resources, "betterdiscord.app.asar"), stale, 0o644); err != nil { + t.Fatalf("seed stale preserved: %v", err) + } + if err := os.MkdirAll(filepath.Join(resources, "app"), 0o755); err != nil { + t.Fatalf("seed leftover app/: %v", err) + } + + install := &DiscordInstall{ResourcesPath: resources, Channel: models.Stable} + if err := install.inject(nil); err != nil { + t.Fatalf("inject: %v", err) + } + + // app.asar must be renamed away so it can't shadow app/. + if utils.Exists(filepath.Join(resources, "app.asar")) { + t.Error("live app.asar should have been renamed away") + } + // The preserved copy must be the LIVE app, not the stale leftover. + got, _ := os.ReadFile(filepath.Join(resources, "betterdiscord.app.asar")) + if string(got) != string(live) { + t.Errorf("preserved asar = %q, expected the live app %q", got, live) + } + if !install.IsInjected() { + t.Error("expected IsInjected after re-injecting over a repaired install") + } +} + func TestUninject_RestoresExactly(t *testing.T) { resources, original := newResourcesDir(t) install := &DiscordInstall{ResourcesPath: resources, Channel: models.Stable} From af10aa8b53b61256a18212ea0578509b0236c5f6 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Fri, 31 Jul 2026 09:51:46 -0400 Subject: [PATCH 16/36] fix: add additional sanity checks --- internal/discord/injection.go | 36 ++++++++++++++++++++++++------ internal/discord/injection_test.go | 35 +++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/internal/discord/injection.go b/internal/discord/injection.go index cca88a6..a714e0b 100644 --- a/internal/discord/injection.go +++ b/internal/discord/injection.go @@ -18,14 +18,20 @@ var appIndexScript string var appPackageJSON string // probeWritable verifies dir accepts writes before we perform any destructive -// operation, by creating and removing a throwaway file. This is the elevation -// trigger: a failure here means we abort before touching the bundle. +// operation, by creating and removing a unique throwaway file. This is the +// elevation trigger: a failure here means we abort before touching the bundle. +// A unique name (os.CreateTemp) avoids colliding with or clobbering an existing +// file and is safe under concurrent probes. func probeWritable(dir string) error { - probe := filepath.Join(dir, ".bd-write-probe") - if err := os.WriteFile(probe, []byte{}, 0o644); err != nil { + f, err := os.CreateTemp(dir, ".bd-write-probe-*") + if err != nil { return err } - return os.Remove(probe) + // Writability is already proven by the successful create; cleanup is + // best-effort and must not turn a writable dir into a probe failure. + _ = f.Close() + _ = os.Remove(f.Name()) + return nil } // inject shadows Discord's app.asar: it preserves the original as @@ -86,7 +92,12 @@ func (discord *DiscordInstall) inject(bd *betterdiscord.BDInstall) error { rollback := func() { os.RemoveAll(appDir) if renamed && !utils.Exists(originalAsar) { - os.Rename(preservedAsar, originalAsar) + if err := os.Rename(preservedAsar, originalAsar); err != nil { + // If this fails, Discord is left with no app.asar and no app/ โ€” + // surface it so the user can restore manually. + output.Printf("โŒ Rollback failed: unable to restore app.asar in %s\n", resources) + output.Printf(" %s\n", err.Error()) + } } } @@ -140,12 +151,23 @@ func (discord *DiscordInstall) uninject() error { // Only restore when a preserved copy exists and we wouldn't clobber a live // app.asar (crash-recovery / partial-state safety). - if utils.Exists(preservedAsar) && !utils.Exists(originalAsar) { + switch { + case utils.Exists(preservedAsar) && !utils.Exists(originalAsar): + // Normal revert: restore Discord's original app from the preserved copy. if err := os.Rename(preservedAsar, originalAsar); err != nil { output.Printf("โŒ Unable to restore app.asar in %s\n", resources) output.Printf(" %s\n", err.Error()) return err } + case utils.Exists(preservedAsar): + // A live app.asar is already present (e.g. Discord repaired/reinstalled + // over the injection), so the preserved copy is stale. Remove it to fully + // revert and reclaim the space (100MB+). A failure here only leaves a + // harmless leftover โ€” Discord still launches โ€” so don't fail the uninstall. + if err := os.Remove(preservedAsar); err != nil { + output.Printf("โš ๏ธ Unable to remove stale %s\n", preservedAsar) + output.Printf(" %s\n", err.Error()) + } } output.Printf("โœ… Removed from %s\n", discord.Channel.Name()) diff --git a/internal/discord/injection_test.go b/internal/discord/injection_test.go index 20a59a2..6e687b0 100644 --- a/internal/discord/injection_test.go +++ b/internal/discord/injection_test.go @@ -183,6 +183,41 @@ func TestUninject_RestoresExactly(t *testing.T) { } } +// If Discord repaired/reinstalled over an injection, uninject encounters a live +// app.asar alongside a now-stale betterdiscord.app.asar. It must remove the +// stale copy (reclaiming 100MB+) and leave the live app untouched. +func TestUninject_RemovesStalePreservedWhenLiveAsarPresent(t *testing.T) { + resources := t.TempDir() + if err := os.WriteFile(filepath.Join(resources, "app.asar"), []byte("live"), 0o644); err != nil { + t.Fatalf("seed live app.asar: %v", err) + } + if err := os.WriteFile(filepath.Join(resources, "betterdiscord.app.asar"), []byte("stale"), 0o644); err != nil { + t.Fatalf("seed stale preserved: %v", err) + } + if err := os.MkdirAll(filepath.Join(resources, "app"), 0o755); err != nil { + t.Fatalf("seed app/: %v", err) + } + + install := &DiscordInstall{ResourcesPath: resources, Channel: models.Stable} + if err := install.uninject(); err != nil { + t.Fatalf("uninject: %v", err) + } + + if utils.Exists(filepath.Join(resources, "betterdiscord.app.asar")) { + t.Error("stale preserved copy should be removed when a live app.asar exists") + } + got, _ := os.ReadFile(filepath.Join(resources, "app.asar")) + if string(got) != "live" { + t.Errorf("app.asar = %q, expected the untouched live app", got) + } + if utils.Exists(filepath.Join(resources, "app")) { + t.Error("shadow app/ should be removed") + } + if install.IsInjected() { + t.Error("should not report injected after uninject") + } +} + func TestUninject_NotInjectedIsNoop(t *testing.T) { resources, original := newResourcesDir(t) install := &DiscordInstall{ResourcesPath: resources, Channel: models.Stable} From f2b8df2c6a0dac79d7077933c6aee202c08eed8b Mon Sep 17 00:00:00 2001 From: Zerebos Date: Fri, 31 Jul 2026 09:58:05 -0400 Subject: [PATCH 17/36] fix: add protection for empty resources dir --- internal/discord/injection.go | 25 ++++++++++------ internal/discord/injection_test.go | 47 ++++++++++++++++++++++++++++++ internal/discord/paths_common.go | 4 +++ 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/internal/discord/injection.go b/internal/discord/injection.go index a714e0b..6b489e8 100644 --- a/internal/discord/injection.go +++ b/internal/discord/injection.go @@ -43,6 +43,9 @@ func probeWritable(dir string) error { // injection script resolves the BetterDiscord folder at runtime. func (discord *DiscordInstall) inject(bd *betterdiscord.BDInstall) error { resources := discord.ResourcesPath + if resources == "" { + return fmt.Errorf("cannot inject: resources path is empty") + } originalAsar := filepath.Join(resources, "app.asar") preservedAsar := filepath.Join(resources, "betterdiscord.app.asar") appDir := filepath.Join(resources, "app") @@ -64,7 +67,6 @@ func (discord *DiscordInstall) inject(bd *betterdiscord.BDInstall) error { // injection โ€” that copy is stale, so we discard it and re-preserve the live // app. Only when there is no live app.asar do we treat an existing preserved // copy as the (already-injected) source of truth and leave it be. - renamed := false switch { case utils.Exists(originalAsar): if utils.Exists(preservedAsar) { @@ -79,7 +81,6 @@ func (discord *DiscordInstall) inject(bd *betterdiscord.BDInstall) error { output.Printf(" %s\n", err.Error()) return err } - renamed = true case utils.Exists(preservedAsar): // Already preserved from a prior injection and no live app.asar; the // archive is correct โ€” only the shadow app/ needs (re)writing below. @@ -87,14 +88,15 @@ func (discord *DiscordInstall) inject(bd *betterdiscord.BDInstall) error { return fmt.Errorf("no app.asar found in %s", resources) } - // Roll back anything done after the rename so a partial failure never - // leaves Discord without a loadable app. + // Roll back anything done after this point so a partial failure never leaves + // Discord without a loadable app. The restore is keyed on filesystem state, + // not on whether *this* call renamed: when re-injecting an already-injected + // install we remove app/ below, so we must still restore app.asar from the + // preserved copy to keep Discord launchable. rollback := func() { os.RemoveAll(appDir) - if renamed && !utils.Exists(originalAsar) { + if !utils.Exists(originalAsar) && utils.Exists(preservedAsar) { if err := os.Rename(preservedAsar, originalAsar); err != nil { - // If this fails, Discord is left with no app.asar and no app/ โ€” - // surface it so the user can restore manually. output.Printf("โŒ Rollback failed: unable to restore app.asar in %s\n", resources) output.Printf(" %s\n", err.Error()) } @@ -137,6 +139,9 @@ func (discord *DiscordInstall) inject(bd *betterdiscord.BDInstall) error { // Discord's original app.asar from the preserved copy. func (discord *DiscordInstall) uninject() error { resources := discord.ResourcesPath + if resources == "" { + return fmt.Errorf("cannot uninject: resources path is empty") + } originalAsar := filepath.Join(resources, "app.asar") preservedAsar := filepath.Join(resources, "betterdiscord.app.asar") appDir := filepath.Join(resources, "app") @@ -178,6 +183,8 @@ func (discord *DiscordInstall) uninject() error { // place: both our `app/index.js` entry and the preserved original must exist. func (discord *DiscordInstall) IsInjected() bool { resources := discord.ResourcesPath - return utils.Exists(filepath.Join(resources, "app", "index.js")) && - utils.Exists(filepath.Join(resources, "betterdiscord.app.asar")) + if resources == "" { + 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 6e687b0..e831955 100644 --- a/internal/discord/injection_test.go +++ b/internal/discord/injection_test.go @@ -245,6 +245,53 @@ func TestInject_NoAppAsarErrors(t *testing.T) { } } +func TestInject_EmptyResourcesPathErrors(t *testing.T) { + install := &DiscordInstall{ResourcesPath: "", Channel: models.Stable} + if err := install.inject(nil); err == nil { + t.Fatal("expected an error for an empty resources path (must not touch the cwd)") + } +} + +func TestUninject_EmptyResourcesPathErrors(t *testing.T) { + install := &DiscordInstall{ResourcesPath: "", Channel: models.Stable} + if err := install.uninject(); err == nil { + t.Fatal("expected an error for an empty resources path (must not RemoveAll the cwd)") + } +} + +// Rolling back a failed *re-injection* of an already-injected install must still +// leave Discord launchable: the preserve step is a no-op (no live app.asar), but +// rollback removes app/, so it must restore app.asar from the preserved copy. +func TestInject_RollbackRestoresLaunchableOnReinject(t *testing.T) { + resources := t.TempDir() + preserved := []byte("preserved discord app") + if err := os.WriteFile(filepath.Join(resources, "betterdiscord.app.asar"), preserved, 0o644); err != nil { + t.Fatalf("seed preserved: %v", err) + } + // Already-injected: app/ exists. Make index.js a directory so the index.js + // write fails *after* the (no-op) preserve step, forcing rollback. + if err := os.MkdirAll(filepath.Join(resources, "app", "index.js"), 0o755); err != nil { + t.Fatalf("seed app/index.js dir: %v", err) + } + + install := &DiscordInstall{ResourcesPath: resources, Channel: models.Stable} + if err := install.inject(nil); err == nil { + t.Fatal("expected inject to fail when app/index.js can't be written") + } + + // Discord must remain launchable: app.asar restored from the preserved copy. + restored, err := os.ReadFile(filepath.Join(resources, "app.asar")) + if err != nil { + t.Fatalf("app.asar not restored after rollback: %v", err) + } + if string(restored) != string(preserved) { + t.Errorf("restored app.asar = %q, expected %q", restored, preserved) + } + if utils.Exists(filepath.Join(resources, "app")) { + t.Error("shadow app/ should be removed by rollback") + } +} + func TestInject_ProbeFailAbortsBeforeRename(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("chmod-based write denial is unreliable on Windows") diff --git a/internal/discord/paths_common.go b/internal/discord/paths_common.go index 075dfb9..e8dee00 100644 --- a/internal/discord/paths_common.go +++ b/internal/discord/paths_common.go @@ -81,6 +81,10 @@ func latestAppDir(base string) string { // - a dir that directly contains a `resources` child (flatpak files/{channel-}) // - a base holding `app-{version}` dirs (Discord root / channel config dir) โ€” picks latest func resolveResources(proposed string) string { + if proposed == "" { + return "" + } + // The proposed path is already the resources dir (or macOS Contents/Resources). if hasDiscordApp(proposed) { return proposed From f1d8f0cd15193ee11bee7411aa3488caec2712b6 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Fri, 31 Jul 2026 10:00:36 -0400 Subject: [PATCH 18/36] chore: update injection js --- internal/discord/assets/app_index.js | 10 +++++++++- internal/discord/injection.go | 6 +++--- internal/discord/paths_common.go | 5 ++++- internal/discord/paths_common_test.go | 18 ++++++++++++++++++ 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/internal/discord/assets/app_index.js b/internal/discord/assets/app_index.js index 8a6c621..383a169 100644 --- a/internal/discord/assets/app_index.js +++ b/internal/discord/assets/app_index.js @@ -12,7 +12,15 @@ if (process.platform !== "win32" && process.platform !== "darwin") { userConfig = process.env.XDG_CONFIG_HOME || path.join(process.env.HOME, ".config"); } -require(path.join(userConfig, "BetterDiscord", "data", "betterdiscord.asar")); +// Never let a missing or broken BetterDiscord asar keep Discord from launching: +// this file is the app entry point, so an unhandled throw here bricks the client. +// Log and fall through to Discord's real app. +try { + require(path.join(userConfig, "BetterDiscord", "data", "betterdiscord.asar")); +} +catch (error) { + console.error("Failed to load BetterDiscord:", error); +} // Hand off to Discord's real (renamed) app entry point module.exports = require("../betterdiscord.app.asar"); \ No newline at end of file diff --git a/internal/discord/injection.go b/internal/discord/injection.go index 6b489e8..21c7eb0 100644 --- a/internal/discord/injection.go +++ b/internal/discord/injection.go @@ -90,9 +90,9 @@ func (discord *DiscordInstall) inject(bd *betterdiscord.BDInstall) error { // Roll back anything done after this point so a partial failure never leaves // Discord without a loadable app. The restore is keyed on filesystem state, - // not on whether *this* call renamed: when re-injecting an already-injected - // install we remove app/ below, so we must still restore app.asar from the - // preserved copy to keep Discord launchable. + // not on whether *this* call renamed: rollback's own RemoveAll(appDir) clears + // app/ even when re-injecting an already-injected install, so we must still + // restore app.asar from the preserved copy to keep Discord launchable. rollback := func() { os.RemoveAll(appDir) if !utils.Exists(originalAsar) && utils.Exists(preservedAsar) { diff --git a/internal/discord/paths_common.go b/internal/discord/paths_common.go index e8dee00..0d8442a 100644 --- a/internal/discord/paths_common.go +++ b/internal/discord/paths_common.go @@ -168,7 +168,10 @@ func validateUnixStyleInstall(proposed string, detectFlatpak bool, detectSnap bo install.IsFlatpak = strings.Contains(resources, "com.discordapp.") } if detectSnap { - install.IsSnap = strings.Contains(resources, "snap"+string(filepath.Separator)) + // Match "snap" as a full path segment, not a substring, so paths like + // ".../mysnap/discord" don't false-positive. + sep := string(filepath.Separator) + install.IsSnap = strings.HasPrefix(resources, "snap"+sep) || strings.Contains(resources, sep+"snap"+sep) } return install diff --git a/internal/discord/paths_common_test.go b/internal/discord/paths_common_test.go index 468e09b..1310987 100644 --- a/internal/discord/paths_common_test.go +++ b/internal/discord/paths_common_test.go @@ -213,6 +213,24 @@ func TestValidateUnixStyleInstall_MacOSBundle(t *testing.T) { } } +func TestValidateUnixStyleInstall_SnapSegmentDetection(t *testing.T) { + tmpDir := t.TempDir() + + // A real "/snap/" path segment is detected. + snapRes := filepath.Join(tmpDir, "snap", "discord", "current", "resources") + writeAppAsar(t, snapRes) + if got := validateUnixStyleInstall(snapRes, false, true); got == nil || !got.IsSnap { + t.Errorf("expected IsSnap true for a /snap/ path, got %+v", got) + } + + // "snap" as a suffix of another segment must not false-positive. + fakeRes := filepath.Join(tmpDir, "mysnap", "discord", "resources") + writeAppAsar(t, fakeRes) + if got := validateUnixStyleInstall(fakeRes, false, true); got == nil || got.IsSnap { + t.Errorf("expected IsSnap false for a .../mysnap/... path, got %+v", got) + } +} + func TestReadBuildInfo(t *testing.T) { t.Run("present", func(t *testing.T) { dir := t.TempDir() From 1e1336a4a1a1b7842775acd1f9bed834f410ef1c Mon Sep 17 00:00:00 2001 From: Zerebos Date: Fri, 31 Jul 2026 10:04:26 -0400 Subject: [PATCH 19/36] fix: more edge cases --- internal/discord/assets/app_index.js | 21 ++++++------ internal/discord/paths.go | 9 +++-- internal/discord/paths_common.go | 26 +++++++++++---- internal/discord/paths_common_test.go | 48 +++++++++++++++++++++------ internal/discord/paths_test.go | 15 +++++++++ 5 files changed, 89 insertions(+), 30 deletions(-) diff --git a/internal/discord/assets/app_index.js b/internal/discord/assets/app_index.js index 383a169..b43740f 100644 --- a/internal/discord/assets/app_index.js +++ b/internal/discord/assets/app_index.js @@ -2,20 +2,21 @@ const path = require("path"); const electron = require("electron"); -// The global BetterDiscord folder lives one directory above userData (the -// appData root). Electron gives the postfixed 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"); -} // Never let a missing or broken BetterDiscord asar keep Discord from launching: // this file is the app entry point, so an unhandled throw here bricks the client. -// Log and fall through to Discord's real app. +// The whole BetterDiscord load โ€” path resolution included โ€” is wrapped so any +// failure (e.g. an unset HOME) falls through to Discord's real app below. try { + // The global BetterDiscord folder lives one directory above userData (the + // appData root). Electron gives the postfixed 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"); + } require(path.join(userConfig, "BetterDiscord", "data", "betterdiscord.asar")); } catch (error) { diff --git a/internal/discord/paths.go b/internal/discord/paths.go index 77ed937..a9c8aaa 100644 --- a/internal/discord/paths.go +++ b/internal/discord/paths.go @@ -38,10 +38,13 @@ func GetVersion(proposed string) string { func GetChannel(proposed string) models.DiscordChannel { for folder := range strings.SplitSeq(proposed, string(filepath.Separator)) { - // Normalize the segment so macOS bundle names ("Discord Canary.app") - // match the space-stripped channel names ("discordcanary"). - normalized := strings.ReplaceAll(strings.ToLower(folder), " ", "") + // 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(folder) normalized = strings.TrimSuffix(normalized, ".app") + normalized = strings.ReplaceAll(normalized, " ", "") + normalized = strings.ReplaceAll(normalized, "-", "") for _, channel := range models.Channels { if normalized == strings.ReplaceAll(strings.ToLower(channel.Name()), " ", "") { return channel diff --git a/internal/discord/paths_common.go b/internal/discord/paths_common.go index 0d8442a..24db97b 100644 --- a/internal/discord/paths_common.go +++ b/internal/discord/paths_common.go @@ -47,8 +47,11 @@ func hasDiscordApp(dir string) bool { return utils.Exists(filepath.Join(dir, "app.asar")) || utils.Exists(filepath.Join(dir, "betterdiscord.app.asar")) } -// latestAppDir returns the highest-versioned `app-{version}` child of base, or -// "" when none exist. Sorting is numeric so 1.0.10000 beats 1.0.9999. +// latestAppDir returns the highest-versioned `app-{version}` child of base whose +// resources dir actually holds a Discord app, or "" when none qualify. Skipping +// broken/incomplete version dirs (e.g. from an interrupted Discord update) lets +// resolution fall back to a slightly older but valid install instead of failing. +// Sorting is numeric so 1.0.10000 beats 1.0.9999. func latestAppDir(base string) string { entries, err := os.ReadDir(base) if err != nil { @@ -65,6 +68,9 @@ func latestAppDir(base string) string { if !versionRegex.MatchString(version) { continue } + if !hasDiscordApp(filepath.Join(base, entry.Name(), "resources")) { + continue + } if bestName == "" || utils.CompareVersions(version, bestVersion) > 0 { bestName, bestVersion = entry.Name(), version } @@ -73,6 +79,17 @@ func latestAppDir(base string) string { return bestName } +// isSnapPath reports whether a resolved resources path lives under a Snap mount +// (/snap/โ€ฆ or /var/lib/snapd/snap/โ€ฆ). Anchoring to the mount points avoids +// false-positives on unrelated paths that merely contain a "snap" segment โ€” e.g. +// the home directory of a user named "snap" (/home/snap/โ€ฆ). +func isSnapPath(path string) bool { + sep := string(filepath.Separator) + return strings.HasPrefix(path, "snap"+sep) || + strings.HasPrefix(path, sep+"snap"+sep) || + strings.HasPrefix(path, sep+"var"+sep+"lib"+sep+"snapd"+sep+"snap"+sep) +} + // resolveResources locates the Discord `resources` directory (holding Discord's // app archive โ€” see hasDiscordApp) from a variety of proposed inputs, returning // "" when none is found: @@ -168,10 +185,7 @@ func validateUnixStyleInstall(proposed string, detectFlatpak bool, detectSnap bo install.IsFlatpak = strings.Contains(resources, "com.discordapp.") } if detectSnap { - // Match "snap" as a full path segment, not a substring, so paths like - // ".../mysnap/discord" don't false-positive. - sep := string(filepath.Separator) - install.IsSnap = strings.HasPrefix(resources, "snap"+sep) || strings.Contains(resources, sep+"snap"+sep) + install.IsSnap = isSnapPath(resources) } return install diff --git a/internal/discord/paths_common_test.go b/internal/discord/paths_common_test.go index 1310987..b51986e 100644 --- a/internal/discord/paths_common_test.go +++ b/internal/discord/paths_common_test.go @@ -213,21 +213,47 @@ func TestValidateUnixStyleInstall_MacOSBundle(t *testing.T) { } } -func TestValidateUnixStyleInstall_SnapSegmentDetection(t *testing.T) { +func TestIsSnapPath(t *testing.T) { + sep := string(filepath.Separator) + tests := []struct { + name string + path string + want bool + }{ + {"snap mount", sep + filepath.Join("snap", "discord", "current", "resources"), true}, + {"snapd mount", sep + filepath.Join("var", "lib", "snapd", "snap", "discord", "resources"), true}, + {"user named snap", sep + filepath.Join("home", "snap", ".config", "discord", "resources"), false}, + {"mysnap segment", sep + filepath.Join("home", "u", "mysnap", "discord", "resources"), false}, + {"native config", sep + filepath.Join("home", "u", ".config", "discord", "app-1.0.1", "resources"), false}, + } + for _, tt := range tests { + if got := isSnapPath(tt.path); got != tt.want { + t.Errorf("%s: isSnapPath(%q) = %v, want %v", tt.name, tt.path, got, tt.want) + } + } +} + +// An interrupted Discord update can leave a higher-versioned app-* dir with a +// broken/empty resources folder next to a valid older one. Resolution must fall +// back to the valid older version rather than failing outright. +func TestValidateWindowsStyleInstall_SkipsBrokenLatestVersion(t *testing.T) { tmpDir := t.TempDir() + root := filepath.Join(tmpDir, "Discord") - // A real "/snap/" path segment is detected. - snapRes := filepath.Join(tmpDir, "snap", "discord", "current", "resources") - writeAppAsar(t, snapRes) - if got := validateUnixStyleInstall(snapRes, false, true); got == nil || !got.IsSnap { - t.Errorf("expected IsSnap true for a /snap/ path, got %+v", got) + valid := filepath.Join(root, "app-1.0.9002", "resources") + writeAppAsar(t, valid) + + // Newer version dir exists but its resources has no app.asar (broken update). + if err := os.MkdirAll(filepath.Join(root, "app-1.0.10000", "resources"), 0755); err != nil { + t.Fatalf("create broken version dir: %v", err) } - // "snap" as a suffix of another segment must not false-positive. - fakeRes := filepath.Join(tmpDir, "mysnap", "discord", "resources") - writeAppAsar(t, fakeRes) - if got := validateUnixStyleInstall(fakeRes, false, true); got == nil || got.IsSnap { - t.Errorf("expected IsSnap false for a .../mysnap/... path, got %+v", got) + result := validateWindowsStyleInstall(root) + if result == nil { + t.Fatal("expected resolution to fall back to the valid older version") + } + if result.ResourcesPath != valid { + t.Errorf("ResourcesPath = %s, expected valid older %s", result.ResourcesPath, valid) } } diff --git a/internal/discord/paths_test.go b/internal/discord/paths_test.go index c34e50b..d62970a 100644 --- a/internal/discord/paths_test.go +++ b/internal/discord/paths_test.go @@ -163,6 +163,21 @@ func TestGetChannel(t *testing.T) { path: filepath.Join("/Applications", "Discord.app", "Contents", "Resources"), expected: models.Stable, }, + { + name: "flatpak dashed canary dir", + path: filepath.Join("/var", "lib", "flatpak", "app", "com.discordapp.DiscordCanary", "current", "active", "files", "discord-canary", "resources"), + expected: models.Canary, + }, + { + name: "flatpak dashed ptb dir", + path: filepath.Join("/var", "lib", "flatpak", "app", "com.discordapp.DiscordPTB", "current", "active", "files", "discord-ptb", "resources"), + expected: models.PTB, + }, + { + name: "flatpak stable dir", + path: filepath.Join("/var", "lib", "flatpak", "app", "com.discordapp.Discord", "current", "active", "files", "discord", "resources"), + expected: models.Stable, + }, } for _, tt := range tests { From a480b9c5a4c7da0f0fc3012bb98688322d43affd Mon Sep 17 00:00:00 2001 From: Zerebos Date: Fri, 31 Jul 2026 10:14:26 -0400 Subject: [PATCH 20/36] feat: kill discord before injection --- internal/discord/install.go | 56 +++++++++++++++++++++++++++++++------ internal/discord/process.go | 34 +++++++++++++++------- 2 files changed, 72 insertions(+), 18 deletions(-) diff --git a/internal/discord/install.go b/internal/discord/install.go index ab07d15..048ed59 100644 --- a/internal/discord/install.go +++ b/internal/discord/install.go @@ -41,7 +41,15 @@ func (discord *DiscordInstall) InstallBD(options models.InstallOptions) error { output.Println("โœ… BetterDiscord downloaded") output.Blank() - // Write injection script to discord_desktop_core/index.js + // Discord locks app.asar while running, so it must be stopped before we can + // modify it. Capture the executable so it can be relaunched afterward. + exe, wasRunning, err := discord.stop() + if err != nil { + return err + } + output.Blank() + + // Shadow app.asar with our loader output.Println("๐Ÿ”Œ Injecting into Discord...") if err := discord.inject(bd); err != nil { return err @@ -49,10 +57,10 @@ func (discord *DiscordInstall) InstallBD(options models.InstallOptions) error { output.Println("โœ… Injection successful") output.Blank() - if options.RestartDiscord { - // Terminate and restart Discord if possible + // Only relaunch what we stopped: if Discord wasn't running we leave it closed. + if options.RestartDiscord && wasRunning { output.Printf("๐Ÿ”„ Restarting %s...\n", discord.Channel.Name()) - if err := discord.restart(); err != nil { + if err := discord.start(exe); err != nil { return err } output.Blank() @@ -63,12 +71,20 @@ func (discord *DiscordInstall) InstallBD(options models.InstallOptions) error { // UninstallBD removes BetterDiscord from this Discord installation func (discord *DiscordInstall) UninstallBD(options models.UninstallOptions) error { + // Discord locks app.asar while running; stop it before reverting the injection. + exe, wasRunning, err := discord.stop() + if err != nil { + return err + } + output.Blank() + output.Println("๐Ÿงน Removing injection...") if err := discord.uninject(); err != nil { return err } output.Blank() + // If full-uninstall is requested, remove the global BetterDiscord install if options.FullUninstall { install, err := discord.GetBetterDiscordInstall() if err != nil { @@ -80,9 +96,10 @@ func (discord *DiscordInstall) UninstallBD(options models.UninstallOptions) erro output.Blank() } - if options.RestartDiscord { + // Only relaunch what we stopped: if Discord wasn't running we leave it closed. + if options.RestartDiscord && wasRunning { output.Printf("๐Ÿ”„ Restarting %s...\n", discord.Channel.Name()) - if err := discord.restart(); err != nil { + if err := discord.start(exe); err != nil { return err } output.Blank() @@ -91,11 +108,22 @@ func (discord *DiscordInstall) UninstallBD(options models.UninstallOptions) erro return nil } -// RepairBD repairs BetterDiscord for this Discord installation +// RepairBD repairs BetterDiscord for this Discord installation. It reverts the +// injection and cleans the requested data files, leaving BetterDiscord +// uninstalled; the caller then offers to reinstall. func (discord *DiscordInstall) RepairBD(options models.RepairOptions) error { - if err := discord.UninstallBD(models.UninstallOptions{FullUninstall: false, RestartDiscord: false}); err != nil { + // Discord locks app.asar while running; stop it before reverting the injection. + exe, wasRunning, err := discord.stop() + if err != nil { return err } + output.Blank() + + output.Println("๐Ÿงน Removing injection...") + if err := discord.uninject(); err != nil { + return err + } + output.Blank() bd, err := discord.GetBetterDiscordInstall() if err != nil { @@ -105,6 +133,18 @@ func (discord *DiscordInstall) RepairBD(options models.RepairOptions) error { if err := bd.Repair(discord.Channel); err != nil { return err } + output.Blank() + + // Repair leaves Discord uninjected. If it was running, relaunch it (vanilla) + // so the user isn't left with a closed client; if they then accept the + // reinstall prompt, that flow stops and re-injects it. + if wasRunning { + output.Printf("๐Ÿ”„ Restarting %s...\n", discord.Channel.Name()) + if err := discord.start(exe); err != nil { + return err + } + output.Blank() + } return nil } diff --git a/internal/discord/process.go b/internal/discord/process.go index 9694860..4a8e7f4 100644 --- a/internal/discord/process.go +++ b/internal/discord/process.go @@ -9,20 +9,34 @@ import ( "github.com/shirou/gopsutil/v3/process" ) -func (discord *DiscordInstall) restart() error { - exeName := discord.getFullExe() - +// stop terminates Discord if it is running. The new injection method modifies +// app.asar, which the running Discord process holds a lock on, so Discord must +// be stopped before inject/uninject can touch it. It returns the executable path +// of the killed process (captured before the kill, for a later start) and whether +// Discord was running. Flatpak/Snap relaunch via their own run commands and don't +// use the exe. +func (discord *DiscordInstall) stop() (exe string, wasRunning bool, err error) { if running, _ := discord.isRunning(); !running { - output.Printf("โœ… %s is not running; skipping restart.\n", discord.Channel.Name()) - return nil + output.Printf("โœ… %s is not running.\n", discord.Channel.Name()) + return "", false, nil } + // Capture the executable before killing โ€” afterward the process is gone. + exe = discord.getFullExe() + if err := discord.kill(); err != nil { - output.Printf("โŒ Unable to restart %s, please do so manually.\n", discord.Channel.Name()) + output.Printf("โŒ Unable to stop %s. Please close it and try again.\n", discord.Channel.Name()) output.Printf(" %s\n", err.Error()) - return err + return exe, true, err } + output.Printf("โœ… Stopped %s\n", discord.Channel.Name()) + return exe, true, nil +} + +// start launches Discord. exe is the executable path captured by stop() and is +// used for native installs; Flatpak/Snap launch via their run commands. +func (discord *DiscordInstall) start(exe string) error { // Determine command based on installation type var cmd *exec.Cmd if discord.IsFlatpak { @@ -30,12 +44,12 @@ func (discord *DiscordInstall) restart() error { } else if discord.IsSnap { cmd = exec.Command("snap", "run", discord.Channel.Exe()) } else { - // Use binary found in killing process for non-Flatpak/Snap installs - if exeName == "" { + // Use binary found while killing the process for non-Flatpak/Snap installs + if exe == "" { output.Printf("โŒ Unable to restart %s, please do so manually.\n", discord.Channel.Name()) return fmt.Errorf("could not determine executable path for %s", discord.Channel.Name()) } - cmd = exec.Command(exeName) + cmd = exec.Command(exe) } // Set working directory to user home From 900ad1d5313049bb4960e4aba9cbf5a237db40b8 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 1 Aug 2026 19:37:23 -0400 Subject: [PATCH 21/36] fix: yet more sanity checking --- internal/discord/assets/app_index.js | 6 ++++-- internal/discord/install_test.go | 6 +++--- internal/discord/process.go | 11 ++++++++++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/internal/discord/assets/app_index.js b/internal/discord/assets/app_index.js index b43740f..ed9f932 100644 --- a/internal/discord/assets/app_index.js +++ b/internal/discord/assets/app_index.js @@ -14,8 +14,10 @@ try { // 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"); + // Use || instead of ?? because a falsey value of "" is invalid per XDG spec. + // os.homedir() resolves the home directory even if the HOME env var is unset. + const homeDir = process.env.HOME || require("os").homedir(); + userConfig = process.env.XDG_CONFIG_HOME || path.join(homeDir, ".config"); } require(path.join(userConfig, "BetterDiscord", "data", "betterdiscord.asar")); } diff --git a/internal/discord/install_test.go b/internal/discord/install_test.go index d781020..215ed25 100644 --- a/internal/discord/install_test.go +++ b/internal/discord/install_test.go @@ -9,9 +9,9 @@ import ( "github.com/betterdiscord/cli/internal/models" ) -// UninstallBD with neither full-uninstall nor restart should only revert the -// app.asar shadow โ€” the safe path that never touches the global BD folder or -// the running Discord process. +// UninstallBD with neither full-uninstall nor restart reverts the app.asar +// shadow without removing the global BD folder or relaunching Discord. (Discord +// isn't running in the test, so the stop() step is a no-op.) func TestUninstallBD_UninjectOnly(t *testing.T) { resources := t.TempDir() // Seed an injected state: preserved asar + shadow app/ entry. diff --git a/internal/discord/process.go b/internal/discord/process.go index 4a8e7f4..4edaed4 100644 --- a/internal/discord/process.go +++ b/internal/discord/process.go @@ -16,7 +16,16 @@ import ( // Discord was running. Flatpak/Snap relaunch via their own run commands and don't // use the exe. func (discord *DiscordInstall) stop() (exe string, wasRunning bool, err error) { - if running, _ := discord.isRunning(); !running { + // If we can't even determine whether Discord is running, don't gamble on + // touching app.asar โ€” it may be locked. Fail with an actionable message + // rather than letting inject/uninject surface a confusing file error. + running, err := discord.isRunning() + if err != nil { + output.Printf("โŒ Unable to determine whether %s is running. Please close it and try again.\n", discord.Channel.Name()) + output.Printf(" %s\n", err.Error()) + return "", false, err + } + if !running { output.Printf("โœ… %s is not running.\n", discord.Channel.Name()) return "", false, nil } From 812d8944d3f8f396ef1204bc2521350479f2d3b6 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 1 Aug 2026 19:40:09 -0400 Subject: [PATCH 22/36] fix: add note for wsl and kill timeout --- internal/discord/injection.go | 4 +++- internal/discord/process.go | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/internal/discord/injection.go b/internal/discord/injection.go index 21c7eb0..df2a9f2 100644 --- a/internal/discord/injection.go +++ b/internal/discord/injection.go @@ -77,7 +77,8 @@ func (discord *DiscordInstall) inject(bd *betterdiscord.BDInstall) error { } } if err := os.Rename(originalAsar, preservedAsar); err != nil { - output.Printf("โŒ Unable to preserve app.asar in %s\n", resources) + output.Printf("โŒ Unable to modify app.asar in %s\n", resources) + output.Printf(" Discord may still be running, please fully close it and try again.\n") output.Printf(" %s\n", err.Error()) return err } @@ -161,6 +162,7 @@ func (discord *DiscordInstall) uninject() error { // Normal revert: restore Discord's original app from the preserved copy. if err := os.Rename(preservedAsar, originalAsar); err != nil { output.Printf("โŒ Unable to restore app.asar in %s\n", resources) + output.Printf(" Discord may still be running, please fully close it and try again.\n") output.Printf(" %s\n", err.Error()) return err } diff --git a/internal/discord/process.go b/internal/discord/process.go index 4edaed4..590c795 100644 --- a/internal/discord/process.go +++ b/internal/discord/process.go @@ -4,11 +4,18 @@ import ( "fmt" "os" "os/exec" + "time" "github.com/betterdiscord/cli/internal/output" "github.com/shirou/gopsutil/v3/process" ) +// killWaitTimeout bounds how long kill() waits for Discord's processes to fully +// exit after being signaled. Discord runs several processes; killing only +// signals termination, so we wait for them to actually die (releasing their lock +// on app.asar) before the caller touches it. +const killWaitTimeout = 10 * time.Second + // stop terminates Discord if it is running. The new injection method modifies // app.asar, which the running Discord process holds a lock on, so Discord must // be stopped before inject/uninject can touch it. It returns the executable path @@ -111,6 +118,7 @@ func (discord *DiscordInstall) kill() error { } // Search for desired process(es) + signaled := false for _, p := range processes { n, err := p.Name() @@ -130,8 +138,29 @@ func (discord *DiscordInstall) kill() error { } } - // If we got here, everything was killed without error - return nil + if !signaled { + return nil + } + + // Kill() only signals termination; wait for the processes to actually exit so + // their lock on app.asar is released before the caller modifies it. + return discord.waitForExit(killWaitTimeout) +} + +// waitForExit blocks until no process matching the channel's executable remains, +// or the timeout elapses. A transient enumeration error is treated as +// "not yet confirmed exited" and retried rather than failing outright. +func (discord *DiscordInstall) waitForExit(timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + if running, err := discord.isRunning(); err == nil && !running { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("%s did not exit within %s", discord.Channel.Name(), timeout) + } + time.Sleep(150 * time.Millisecond) + } } func (discord *DiscordInstall) getFullExe() string { From f65b8924d9daa1de3c332d56ebdf380091bc5f7e Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 1 Aug 2026 19:47:04 -0400 Subject: [PATCH 23/36] fix: more protection edge cases --- internal/discord/injection.go | 15 +++++++++++++ internal/discord/paths.go | 10 +++++++-- internal/discord/paths_darwin.go | 9 ++++++-- internal/discord/paths_linux.go | 37 ++++++++++++++++++++------------ 4 files changed, 53 insertions(+), 18 deletions(-) diff --git a/internal/discord/injection.go b/internal/discord/injection.go index df2a9f2..b6a076d 100644 --- a/internal/discord/injection.go +++ b/internal/discord/injection.go @@ -46,6 +46,14 @@ func (discord *DiscordInstall) inject(bd *betterdiscord.BDInstall) error { if resources == "" { return fmt.Errorf("cannot inject: resources path is empty") } + // Snap installs live on a read-only squashfs mount that can't host the shadow. + // Short-circuit with an actionable message instead of surfacing the generic + // permission error the writability probe would raise below. + if discord.IsSnap { + output.Printf("โŒ Snap installs are not supported\n") + output.Printf(" The read-only Snap mount cannot host the BetterDiscord injection.\n") + return fmt.Errorf("snap installs are not supported") + } originalAsar := filepath.Join(resources, "app.asar") preservedAsar := filepath.Join(resources, "betterdiscord.app.asar") appDir := filepath.Join(resources, "app") @@ -143,6 +151,13 @@ func (discord *DiscordInstall) uninject() error { if resources == "" { return fmt.Errorf("cannot uninject: resources path is empty") } + // Snap installs are never injectable (read-only mount), so there is nothing to + // revert; report it explicitly rather than attempting filesystem mutations. + if discord.IsSnap { + output.Printf("โŒ Snap installs are not supported\n") + output.Printf(" The read-only Snap mount cannot host the BetterDiscord injection.\n") + return fmt.Errorf("snap installs are not supported") + } originalAsar := filepath.Join(resources, "app.asar") preservedAsar := filepath.Join(resources, "betterdiscord.app.asar") appDir := filepath.Join(resources, "app") diff --git a/internal/discord/paths.go b/internal/discord/paths.go index a9c8aaa..4736d93 100644 --- a/internal/discord/paths.go +++ b/internal/discord/paths.go @@ -37,11 +37,17 @@ func GetVersion(proposed string) string { } func GetChannel(proposed string) models.DiscordChannel { - for folder := range strings.SplitSeq(proposed, string(filepath.Separator)) { + // Iterate from the leaf toward the root: the channel identifier always sits + // closest to the leaf (e.g. `.../discordcanary/app-x/resources`), so scanning + // backwards avoids false matches on a parent segment that happens to contain a + // channel name (e.g. a home dir at `/home/discord`). + segments := strings.Split(proposed, string(filepath.Separator)) + + for i := len(segments) - 1; i >= 0; i-- { // 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(folder) + normalized := strings.ToLower(segments[i]) normalized = strings.TrimSuffix(normalized, ".app") normalized = strings.ReplaceAll(normalized, " ", "") normalized = strings.ReplaceAll(normalized, "-", "") diff --git a/internal/discord/paths_darwin.go b/internal/discord/paths_darwin.go index bf736fe..8e8f616 100644 --- a/internal/discord/paths_darwin.go +++ b/internal/discord/paths_darwin.go @@ -8,14 +8,19 @@ import ( ) func init() { - home, _ := os.UserHomeDir() + home, err := os.UserHomeDir() // On macOS the app.asar lives inside the application bundle // (Discord.app/Contents/Resources), not under Application Support. Search the // standard install locations for each channel's bundle. bases := []string{ filepath.Join("/", "Applications"), - filepath.Join(home, "Applications"), + } + + // Only add ~/Applications when the home dir resolved; otherwise the join would + // produce a relative "Applications" and search the current working directory. + if err == nil && home != "" { + bases = append(bases, filepath.Join(home, "Applications")) } for _, channel := range models.Channels { diff --git a/internal/discord/paths_linux.go b/internal/discord/paths_linux.go index 6c65253..8855f08 100644 --- a/internal/discord/paths_linux.go +++ b/internal/discord/paths_linux.go @@ -9,25 +9,34 @@ import ( "github.com/betterdiscord/cli/internal/wsl" ) +// Snap is intentionally omitted: its read-only squashfs mount can't host +// the app.asar shadow, so the new injection method does not support it. func init() { - config, _ := os.UserConfigDir() - home, _ := os.UserHomeDir() - paths := []string{ - // Native. The new updater lays out versioned app dirs under `~/.config`. - // Example: `~/.config/discordcanary`. - // Resources: `~/.config/discordcanary/app-0.0.90/resources/app.asar`. - filepath.Join(config, "{channel}"), + config, errConfig := os.UserConfigDir() + home, errHome := os.UserHomeDir() - // Flatpak (global). The app.asar lives in the read-only deployment files. - // Example: `/var/lib/flatpak/app/com.discordapp.DiscordCanary/current/active/files/discord-canary/resources/app.asar`. + // Flatpak (global). The app.asar lives in the read-only deployment files. + // Example: `/var/lib/flatpak/app/com.discordapp.DiscordCanary/current/active/files/discord-canary/resources/app.asar`. + // This has no home/config dependency, so it's always searched. + paths := []string{ filepath.Join("/var", "lib", "flatpak", "app", "com.discordapp.{CHANNEL}", "current", "active", "files", "{channel-}", "resources"), + } - // Flatpak (user). Same layout under the per-user flatpak tree (writable). - // Example: `~/.local/share/flatpak/app/com.discordapp.DiscordCanary/current/active/files/discord-canary/resources/app.asar`. - filepath.Join(home, ".local", "share", "flatpak", "app", "com.discordapp.{CHANNEL}", "current", "active", "files", "{channel-}", "resources"), + // Only search config/home-relative locations when those dirs resolved; + // otherwise the joins would produce relative paths anchored at the current + // working directory. + + // Native. The new updater lays out versioned app dirs under `~/.config`. + // Example: `~/.config/discordcanary`. + // Resources: `~/.config/discordcanary/app-0.0.90/resources/app.asar`. + if errConfig == nil && config != "" { + paths = append(paths, filepath.Join(config, "{channel}")) + } - // Snap is intentionally omitted: its read-only squashfs mount can't host - // the app.asar shadow, so the new injection method does not support it. + // Flatpak (user). Same layout under the per-user flatpak tree (writable). + // Example: `~/.local/share/flatpak/app/com.discordapp.DiscordCanary/current/active/files/discord-canary/resources/app.asar`. + if errHome == nil && home != "" { + paths = append(paths, filepath.Join(home, ".local", "share", "flatpak", "app", "com.discordapp.{CHANNEL}", "current", "active", "files", "{channel-}", "resources")) } if wsl.IsWSL() { From f88bb24eeea8a3695f4d49b6b7690875d0703acf Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 1 Aug 2026 19:50:19 -0400 Subject: [PATCH 24/36] chore: more protection for snap installs --- internal/discord/injection.go | 41 ++++++++++++++++++++++++----------- internal/discord/install.go | 16 ++++++++++++++ 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/internal/discord/injection.go b/internal/discord/injection.go index b6a076d..7c8bdc3 100644 --- a/internal/discord/injection.go +++ b/internal/discord/injection.go @@ -17,6 +17,20 @@ var appIndexScript string //go:embed assets/app_package.json var appPackageJSON string +// errIfSnap rejects Snap installs with a clear, actionable message: their +// read-only squashfs mount can't host the app.asar shadow. It's called at the +// start of the install/uninstall/repair flows (before Discord is stopped, so an +// unsupported install never needlessly kills a running client) and again in +// inject/uninject as a backstop for any direct callers. +func (discord *DiscordInstall) errIfSnap() error { + if !discord.IsSnap { + return nil + } + output.Printf("โŒ Snap installs are not supported\n") + output.Printf(" The read-only Snap mount cannot host the BetterDiscord injection.\n") + return fmt.Errorf("snap installs are not supported") +} + // probeWritable verifies dir accepts writes before we perform any destructive // operation, by creating and removing a unique throwaway file. This is the // elevation trigger: a failure here means we abort before touching the bundle. @@ -46,14 +60,14 @@ func (discord *DiscordInstall) inject(bd *betterdiscord.BDInstall) error { if resources == "" { return fmt.Errorf("cannot inject: resources path is empty") } - // Snap installs live on a read-only squashfs mount that can't host the shadow. - // Short-circuit with an actionable message instead of surfacing the generic - // permission error the writability probe would raise below. - if discord.IsSnap { - output.Printf("โŒ Snap installs are not supported\n") - output.Printf(" The read-only Snap mount cannot host the BetterDiscord injection.\n") - return fmt.Errorf("snap installs are not supported") + + // Backstop: the install flow rejects Snap before stopping Discord, but guard + // here too so any direct caller gets the same actionable error rather than the + // generic permission failure the writability probe would raise below. + if err := discord.errIfSnap(); err != nil { + return err } + originalAsar := filepath.Join(resources, "app.asar") preservedAsar := filepath.Join(resources, "betterdiscord.app.asar") appDir := filepath.Join(resources, "app") @@ -151,13 +165,14 @@ func (discord *DiscordInstall) uninject() error { if resources == "" { return fmt.Errorf("cannot uninject: resources path is empty") } - // Snap installs are never injectable (read-only mount), so there is nothing to - // revert; report it explicitly rather than attempting filesystem mutations. - if discord.IsSnap { - output.Printf("โŒ Snap installs are not supported\n") - output.Printf(" The read-only Snap mount cannot host the BetterDiscord injection.\n") - return fmt.Errorf("snap installs are not supported") + + // Backstop for direct callers; the uninstall/repair flows reject Snap before + // stopping Discord. Snap installs are never injectable, so there's nothing to + // revert โ€” report it explicitly rather than attempting filesystem mutations. + if err := discord.errIfSnap(); err != nil { + return err } + originalAsar := filepath.Join(resources, "app.asar") preservedAsar := filepath.Join(resources, "betterdiscord.app.asar") appDir := filepath.Join(resources, "app") diff --git a/internal/discord/install.go b/internal/discord/install.go index 048ed59..54fbe83 100644 --- a/internal/discord/install.go +++ b/internal/discord/install.go @@ -20,6 +20,12 @@ type DiscordInstall struct { // InstallBD installs BetterDiscord into this Discord installation func (discord *DiscordInstall) InstallBD(options models.InstallOptions) error { + // Reject Snap before doing anything (notably before stop()) so an unsupported + // install never needlessly kills a running Discord only to fail at inject(). + if err := discord.errIfSnap(); err != nil { + return err + } + bd, err := discord.GetBetterDiscordInstall() if err != nil { return err @@ -71,6 +77,11 @@ func (discord *DiscordInstall) InstallBD(options models.InstallOptions) error { // UninstallBD removes BetterDiscord from this Discord installation func (discord *DiscordInstall) UninstallBD(options models.UninstallOptions) error { + // Reject Snap before stop() so an unsupported install isn't needlessly killed. + if err := discord.errIfSnap(); err != nil { + return err + } + // Discord locks app.asar while running; stop it before reverting the injection. exe, wasRunning, err := discord.stop() if err != nil { @@ -112,6 +123,11 @@ func (discord *DiscordInstall) UninstallBD(options models.UninstallOptions) erro // injection and cleans the requested data files, leaving BetterDiscord // uninstalled; the caller then offers to reinstall. func (discord *DiscordInstall) RepairBD(options models.RepairOptions) error { + // Reject Snap before stop() so an unsupported install isn't needlessly killed. + if err := discord.errIfSnap(); err != nil { + return err + } + // Discord locks app.asar while running; stop it before reverting the injection. exe, wasRunning, err := discord.stop() if err != nil { From 2f7bfef7a0b58e387df901f0dbcd7221aef397ec Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 1 Aug 2026 19:54:23 -0400 Subject: [PATCH 25/36] fix: edge case handling --- internal/discord/injection.go | 29 +++++++++++++++++++++-------- internal/discord/paths.go | 7 +++++-- internal/discord/process.go | 6 ++++-- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/internal/discord/injection.go b/internal/discord/injection.go index 7c8bdc3..35b2cdf 100644 --- a/internal/discord/injection.go +++ b/internal/discord/injection.go @@ -177,16 +177,18 @@ func (discord *DiscordInstall) uninject() error { preservedAsar := filepath.Join(resources, "betterdiscord.app.asar") appDir := filepath.Join(resources, "app") - if utils.Exists(appDir) { - if err := os.RemoveAll(appDir); err != nil { - output.Printf("โŒ Unable to remove %s\n", appDir) - output.Printf(" %s\n", err.Error()) - return err - } + // A clean install (only app.asar; no shadow app/ and no preserved copy) was + // never injected โ€” report a no-op instead of claiming a removal that didn't + // happen, which would mislead anyone troubleshooting uninstall/repair. + if !utils.Exists(appDir) && !utils.Exists(preservedAsar) { + output.Printf("โ„น๏ธ No injection found in %s\n", discord.Channel.Name()) + return nil } - // Only restore when a preserved copy exists and we wouldn't clobber a live - // app.asar (crash-recovery / partial-state safety). + // Restore Discord's original app.asar *before* removing the shadow app/. If the + // restore fails (e.g. a running Discord still locks the file), the injection is + // left fully intact and loadable rather than bricked with neither app.asar nor + // app/ present. switch { case utils.Exists(preservedAsar) && !utils.Exists(originalAsar): // Normal revert: restore Discord's original app from the preserved copy. @@ -207,6 +209,17 @@ func (discord *DiscordInstall) uninject() error { } } + // Original app restored (or the preserved copy was stale); now clear the shadow + // app/. A failure here is non-bricking โ€” Electron prefers the restored app.asar + // over app/ โ€” but still surface it so the leftover can be cleaned up. + if utils.Exists(appDir) { + if err := os.RemoveAll(appDir); err != nil { + output.Printf("โŒ Unable to remove %s\n", appDir) + output.Printf(" %s\n", err.Error()) + return err + } + } + output.Printf("โœ… Removed from %s\n", discord.Channel.Name()) return nil } diff --git a/internal/discord/paths.go b/internal/discord/paths.go index 4736d93..3351df9 100644 --- a/internal/discord/paths.go +++ b/internal/discord/paths.go @@ -28,7 +28,7 @@ func GetAllInstalls() map[models.DiscordChannel][]*DiscordInstall { } func GetVersion(proposed string) string { - for folder := range strings.SplitSeq(proposed, string(filepath.Separator)) { + for _, folder := range strings.Split(filepath.ToSlash(proposed), "/") { if version := versionRegex.FindString(folder); version != "" { return version } @@ -41,7 +41,10 @@ func GetChannel(proposed string) models.DiscordChannel { // closest to the leaf (e.g. `.../discordcanary/app-x/resources`), so scanning // backwards avoids false matches on a parent segment that happens to contain a // channel name (e.g. a home dir at `/home/discord`). - segments := strings.Split(proposed, string(filepath.Separator)) + // Normalize to forward slashes before splitting so a Windows path that mixes + // separators (backslashes and forward slashes, which the OS treats + // interchangeably) still segments cleanly. + segments := strings.Split(filepath.ToSlash(proposed), "/") for i := len(segments) - 1; i >= 0; i-- { // Normalize the segment so macOS bundle names ("Discord Canary.app") and diff --git a/internal/discord/process.go b/internal/discord/process.go index 590c795..0feaff3 100644 --- a/internal/discord/process.go +++ b/internal/discord/process.go @@ -112,9 +112,11 @@ func (discord *DiscordInstall) kill() error { name := discord.Channel.Exe() processes, err := process.Processes() - // If we can't even list processes, bail out + // If we can't even list processes, bail out. Preserve the underlying error so + // a genuine enumeration failure is distinguishable from Discord still running + // (the caller's wait-for-exit surfaces the latter separately). if err != nil { - return fmt.Errorf("could not list processes") + return fmt.Errorf("could not list processes: %w", err) } // Search for desired process(es) From bf874231839f4022f5ccfa8427c01589f390243b Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 1 Aug 2026 19:56:01 -0400 Subject: [PATCH 26/36] fix: cleanly resolve flaky enumeration --- internal/discord/process.go | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/internal/discord/process.go b/internal/discord/process.go index 0feaff3..af0e295 100644 --- a/internal/discord/process.go +++ b/internal/discord/process.go @@ -84,9 +84,11 @@ func (discord *DiscordInstall) isRunning() (bool, error) { name := discord.Channel.Exe() processes, err := process.Processes() - // If we can't even list processes, bail out + // If we can't even list processes, bail out. Wrap the underlying error so + // callers (e.g. waitForExit) can surface the real cause instead of a bare + // "could not list processes". if err != nil { - return false, fmt.Errorf("could not list processes") + return false, fmt.Errorf("could not list processes: %w", err) } // Search for desired process(es) @@ -151,14 +153,32 @@ func (discord *DiscordInstall) kill() error { // waitForExit blocks until no process matching the channel's executable remains, // or the timeout elapses. A transient enumeration error is treated as -// "not yet confirmed exited" and retried rather than failing outright. +// "not yet confirmed exited" and retried rather than failing outright. If the +// most recent check couldn't enumerate processes at all, the timeout surfaces +// that underlying error instead of a misleading "did not exit" โ€” otherwise a +// persistent enumeration failure would send users chasing a lock that may not +// exist. func (discord *DiscordInstall) waitForExit(timeout time.Duration) error { deadline := time.Now().Add(timeout) + var lastErr error for { - if running, err := discord.isRunning(); err == nil && !running { + running, err := discord.isRunning() + switch { + case err != nil: + // Couldn't confirm state this round; remember why in case we time out + // with the failure still unresolved. + lastErr = err + case !running: return nil + default: + // Clean read that still shows Discord running: the process, not + // enumeration, is the holdup โ€” clear any stale earlier error. + lastErr = nil } if time.Now().After(deadline) { + if lastErr != nil { + return fmt.Errorf("could not confirm %s exited within %s: %w", discord.Channel.Name(), timeout, lastErr) + } return fmt.Errorf("%s did not exit within %s", discord.Channel.Name(), timeout) } time.Sleep(150 * time.Millisecond) From 14baf96e3b5ee06de2734ce378f20e50c0074dcb Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sun, 2 Aug 2026 11:44:07 -0400 Subject: [PATCH 27/36] feat: support downloading rolling release --- internal/betterdiscord/download.go | 37 +++++++--- internal/betterdiscord/download_test.go | 89 +++++++++++++++++++++++-- internal/betterdiscord/install.go | 7 +- 3 files changed, 117 insertions(+), 16 deletions(-) diff --git a/internal/betterdiscord/download.go b/internal/betterdiscord/download.go index e959e62..6d08d84 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,15 @@ 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") + output.Printf("โœ… Downloaded BetterDiscord from %s\n", sourceLabel) } else { - output.Printf("โœ… Downloaded BetterDiscord version %s from GitHub\n", output.FormatVersion(version)) + 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..6a3ef0a 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 From b0bea8c4a0d02e4b3982e21b5f94d5aea0bb1dee Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sun, 2 Aug 2026 11:44:39 -0400 Subject: [PATCH 28/36] feat: add dev build options --- cmd/install.go | 2 +- cmd/root.go | 12 ++++++++++++ cmd/update.go | 2 +- internal/discord/install.go | 2 +- internal/discord/paths_test.go | 6 +++--- 5 files changed, 18 insertions(+), 6 deletions(-) diff --git a/cmd/install.go b/cmd/install.go index d889e12..e447183 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -49,7 +49,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) } diff --git a/cmd/root.go b/cmd/root.go index 9fb21f7..ff9eea2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -45,9 +45,11 @@ func IsDebugBuild() bool { } var silent bool +var useDevBuild bool func init() { rootCmd.PersistentFlags().BoolVar(&silent, "silent", false, "Suppress non-error output") + rootCmd.PersistentFlags().BoolVar(&useDevBuild, "dev", useDevBuild, "Use the development build of BetterDiscord") } var rootCmd = &cobra.Command{ @@ -58,6 +60,11 @@ var rootCmd = &cobra.Command{ if silent || isSilentEnvEnabled() { output.SetWriters(io.Discard, nil) } + + if useDevBuild || isDevBuildEnvEnabled() { + useDevBuild = true + output.Println("โš ๏ธ Using development build of BetterDiscord") + } }, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, } @@ -67,6 +74,11 @@ func isSilentEnvEnabled() bool { return value != "" && value != "0" && value != "false" && value != "no" } +func isDevBuildEnvEnabled() bool { + value := strings.TrimSpace(strings.ToLower(os.Getenv("BDCLI_DEV_BUILD"))) + return value == "1" || value == "true" || value == "yes" +} + func Execute() { if err := rootCmd.Execute(); err != nil { fmt.Fprintln(output.ErrorWriter(), err) diff --git a/cmd/update.go b/cmd/update.go index 94b0d2c..5df0c00 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -62,7 +62,7 @@ var updateCmd = &cobra.Command{ // Download the latest version output.Println("๐Ÿ“ฅ Downloading update...") - if err := bdinstall.Download(); err != nil { + if err := bdinstall.Download(useDevBuild); err != nil { return fmt.Errorf("failed to download update: %w", err) } diff --git a/internal/discord/install.go b/internal/discord/install.go index 54fbe83..67fa39e 100644 --- a/internal/discord/install.go +++ b/internal/discord/install.go @@ -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/paths_test.go b/internal/discord/paths_test.go index d62970a..924a554 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()) } }) From 8fcfb532e876af550cd1a1a5c758bcb22ba27641 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Mon, 3 Aug 2026 03:13:25 -0400 Subject: [PATCH 29/36] fix: run gofmt --- internal/betterdiscord/download_test.go | 2 +- internal/discord/injection.go | 2 +- internal/discord/injection_test.go | 2 +- internal/discord/install.go | 10 +++++----- internal/discord/install_test.go | 2 +- internal/discord/paths.go | 2 +- internal/discord/paths_common.go | 2 +- internal/discord/paths_common_test.go | 2 +- internal/discord/paths_test.go | 4 ++-- internal/discord/process.go | 2 +- internal/utils/strings.go | 2 +- internal/utils/strings_test.go | 2 +- internal/wsl/wsl_test.go | 2 +- 13 files changed, 18 insertions(+), 18 deletions(-) diff --git a/internal/betterdiscord/download_test.go b/internal/betterdiscord/download_test.go index 6a3ef0a..ad94150 100644 --- a/internal/betterdiscord/download_test.go +++ b/internal/betterdiscord/download_test.go @@ -209,4 +209,4 @@ func TestDownload_SkipsWhenAlreadyDownloaded(t *testing.T) { 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/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 67fa39e..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 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..19a87e3 100644 --- a/internal/discord/paths.go +++ b/internal/discord/paths.go @@ -28,7 +28,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 } 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 924a554..420c2a1 100644 --- a/internal/discord/paths_test.go +++ b/internal/discord/paths_test.go @@ -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..d01347e 100644 --- a/internal/discord/process.go +++ b/internal/discord/process.go @@ -142,7 +142,7 @@ func (discord *DiscordInstall) kill() error { } } - 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 +} From b074497c36985896cf8c42e52dd7162035908d7a Mon Sep 17 00:00:00 2001 From: Zerebos Date: Mon, 3 Aug 2026 03:30:48 -0400 Subject: [PATCH 30/36] fix: make dev flag install only --- cmd/install.go | 17 +++++++++++++++++ cmd/root.go | 12 ------------ cmd/update.go | 7 ++++++- internal/betterdiscord/download.go | 7 +++++-- internal/discord/paths.go | 4 ++-- 5 files changed, 30 insertions(+), 17 deletions(-) diff --git a/cmd/install.go b/cmd/install.go index e447183..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 { @@ -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/root.go b/cmd/root.go index ff9eea2..9fb21f7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -45,11 +45,9 @@ func IsDebugBuild() bool { } var silent bool -var useDevBuild bool func init() { rootCmd.PersistentFlags().BoolVar(&silent, "silent", false, "Suppress non-error output") - rootCmd.PersistentFlags().BoolVar(&useDevBuild, "dev", useDevBuild, "Use the development build of BetterDiscord") } var rootCmd = &cobra.Command{ @@ -60,11 +58,6 @@ var rootCmd = &cobra.Command{ if silent || isSilentEnvEnabled() { output.SetWriters(io.Discard, nil) } - - if useDevBuild || isDevBuildEnvEnabled() { - useDevBuild = true - output.Println("โš ๏ธ Using development build of BetterDiscord") - } }, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, } @@ -74,11 +67,6 @@ func isSilentEnvEnabled() bool { return value != "" && value != "0" && value != "false" && value != "no" } -func isDevBuildEnvEnabled() bool { - value := strings.TrimSpace(strings.ToLower(os.Getenv("BDCLI_DEV_BUILD"))) - return value == "1" || value == "true" || value == "yes" -} - func Execute() { if err := rootCmd.Execute(); err != nil { fmt.Fprintln(output.ErrorWriter(), err) diff --git a/cmd/update.go b/cmd/update.go index 5df0c00..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(useDevBuild); 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 6d08d84..4582309 100644 --- a/internal/betterdiscord/download.go +++ b/internal/betterdiscord/download.go @@ -93,9 +93,12 @@ func (i *BDInstall) downloadFromGitHubRelease(apiURL, sourceLabel string) error return err } - if version == "" { + switch version { + case "": output.Printf("โœ… Downloaded BetterDiscord from %s\n", sourceLabel) - } else { + 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/discord/paths.go b/internal/discord/paths.go index 19a87e3..e361bb7 100644 --- a/internal/discord/paths.go +++ b/internal/discord/paths.go @@ -46,11 +46,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, "-", "") From 96778e371dd4922b39fbf84934c3e1964122a6ea Mon Sep 17 00:00:00 2001 From: Zerebos Date: Mon, 3 Aug 2026 09:28:12 -0400 Subject: [PATCH 31/36] fix: fix version folder sorting --- internal/discord/paths.go | 11 ++++------- internal/discord/process.go | 1 + 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/internal/discord/paths.go b/internal/discord/paths.go index e361bb7..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 @@ -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/process.go b/internal/discord/process.go index d01347e..b78aeed 100644 --- a/internal/discord/process.go +++ b/internal/discord/process.go @@ -139,6 +139,7 @@ func (discord *DiscordInstall) kill() error { if killErr != nil { return killErr } + signaled = true } } From 1d2c425b2cdc9e7cc742d2296dcc03b52244efb9 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Tue, 4 Aug 2026 02:05:17 -0400 Subject: [PATCH 32/36] fix: path should be labelled as resources --- cmd/install.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/install.go b/cmd/install.go index 7916426..f7539ce 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -79,16 +79,16 @@ var installCmd = &cobra.Command{ } return "native" }()) - output.Printf(" Core Path: %s\n", path.Dir(install.ResourcesPath)) + output.Printf(" Resources Path: %s\n", path.Dir(install.ResourcesPath)) output.Blank() bdinstall, err := install.GetBetterDiscordInstall() if err != nil { - output.Printf("failed to get BetterDiscord install info: %s", err.Error()) + output.Printf("failed to get BetterDiscord install info: %s\n", err.Error()) return nil } if bdinstall == nil { - output.Printf("BetterDiscord install info is nil") + output.Printf("BetterDiscord install info is nil\n") return nil } bdinstall.LogBuildinfo() From 4028ca36fcdd6fdc94e061bab2185cb4902bc59c Mon Sep 17 00:00:00 2001 From: Zerebos Date: Tue, 4 Aug 2026 02:14:00 -0400 Subject: [PATCH 33/36] feat: additional rollback protection --- internal/betterdiscord/download_test.go | 8 ++++---- internal/discord/injection.go | 6 +++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/internal/betterdiscord/download_test.go b/internal/betterdiscord/download_test.go index ad94150..60fff54 100644 --- a/internal/betterdiscord/download_test.go +++ b/internal/betterdiscord/download_test.go @@ -57,7 +57,7 @@ func TestDownload_FromWebsite(t *testing.T) { const body = "asar-from-website" website := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("x-bd-version", "1.2.3") - fmt.Fprint(w, body) + fmt.Fprint(w, body) //nolint this is a test file })) defer website.Close() @@ -82,7 +82,7 @@ func TestDownload_FromWebsite(t *testing.T) { func TestDownload_FallsBackToGitHub(t *testing.T) { const body = "asar-from-github" asset := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, body) + fmt.Fprint(w, body) //nolint this is a test file })) defer asset.Close() @@ -116,7 +116,7 @@ func TestDownload_GitHubMissingAsset(t *testing.T) { defer website.Close() github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `{"tag_name":"v9.9.9","assets":[{"name":"something-else.zip","url":"http://example.invalid"}]}`) + fmt.Fprint(w, `{"tag_name":"v9.9.9","assets":[{"name":"something-else.zip","url":"http://example.invalid"}]}`) //nolint this is a test file })) defer github.Close() @@ -131,7 +131,7 @@ func TestDownload_GitHubMissingAsset(t *testing.T) { 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) + fmt.Fprint(w, body) //nolint this is a test file })) defer asset.Close() diff --git a/internal/discord/injection.go b/internal/discord/injection.go index a81ed6c..bfabd3e 100644 --- a/internal/discord/injection.go +++ b/internal/discord/injection.go @@ -117,7 +117,11 @@ func (discord *DiscordInstall) inject(bd *betterdiscord.BDInstall) error { // app/ even when re-injecting an already-injected install, so we must still // restore app.asar from the preserved copy to keep Discord launchable. rollback := func() { - os.RemoveAll(appDir) + err := os.RemoveAll(appDir) + if err != nil { + output.Printf("โŒ Rollback failed: unable to remove %s\n", appDir) + output.Printf(" %s\n", err.Error()) + } if !utils.Exists(originalAsar) && utils.Exists(preservedAsar) { if err := os.Rename(preservedAsar, originalAsar); err != nil { output.Printf("โŒ Rollback failed: unable to restore app.asar in %s\n", resources) From 0113edfb7438c285d516bad93301f0560f2701b8 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Tue, 4 Aug 2026 13:07:52 -0400 Subject: [PATCH 34/36] feat: introduce AGENTS file --- AGENTS.md | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 107 insertions(+) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a1e1e0a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,106 @@ +# AGENT OPERATING NOTES + +These notes are curated for downstream agents. Treat them as binding guidance: each section funnels a different expectation for builds, style, testing, and compliance with upstream tooling. (`CLAUDE.md` is a symlink to this file.) + +## 1. Project layout recap + +A pure-Go Cobra CLI (`bdcli`) for installing, updating, and managing BetterDiscord. It is the command-line sibling of the BetterDiscord GUI installer โ€” the two repos share domain concepts (release channels, Discord discovery, asar download, injection) but no code; do not assume installer file paths or APIs exist here. + +- `cmd/` โ€” Cobra commands, one file per command (`install`, `uninstall`, `update`, `info`, `discover`, `plugins`, `themes`, `store`, `version`, `completion`). `root.go` wires the global `--silent` flag, the `BDCLI_SILENT` env fallback, and version info. +- `internal/betterdiscord/` โ€” BetterDiscord core: data-folder setup, asar download (website โ†’ GitHub fallback), install/repair, addon management, and the store client. +- `internal/discord/` โ€” Discord install discovery and injection. Per-OS path logic lives in `paths_windows.go` / `paths_darwin.go` / `paths_linux.go` with shared logic in `paths_common.go`. Injection assets (`app_index.js`, `app_package.json`) are embedded from `internal/discord/assets/` via `//go:embed`. +- `internal/models/` โ€” channels (`iota` constants: Stable, Canary, PTB), GitHub release shapes, options, store models. +- `internal/output/` โ€” the single gateway for user-facing output (`Printf`, `Println`, `Blank`, `NewTableWriter`, `SetWriters`). See ยง4. +- `internal/utils/` โ€” download, path, and string helpers. +- `internal/wsl/` โ€” WSL detection and Windows-home path mapping (lazy `sync.Once`, no `init()` cost outside WSL). +- `main.go` โ€” entry point; `-ldflags -X main.version/commit/date` feed `cmd.SetVersionInfo`. A version of `"dev"` (the default) marks a debug build (`cmd.IsDebugBuild`). +- `scripts/completions.sh` โ€” regenerates `completions/` (bash/zsh/fish); runs automatically in the GoReleaser `before` hook. +- `Taskfile.yml` โ€” dev/build/test/lint/release shortcuts. `.goreleaser.yaml` โ€” release build + publishing. `.golangci.yml` โ€” lint config (`errcheck` excludes the `fmt.Fprint*` family). +- `package.json` โ€” npm distribution wrapper (`@betterdiscord/cli` via `@go-task/go-npm`); it downloads release binaries, contains no JS logic. The version stays `0.0.0` in-repo โ€” CI stamps it during release. There is no frontend and no `node_modules` toolchain to install for development. +- Generated / gitignored, never hand-edit or commit: `dist/`, `debug/`, `completions/`, `notes/`, `.task/`. + +## 2. Build, dev & test commands + +Prereqs: Go (matches `go.mod`), plus optionally [Task](https://taskfile.dev/), golangci-lint, and GoReleaser. No CGO, no platform build tags for local dev. + +- `go run main.go ` or `task run -- ` โ€” run the CLI locally (`task run` injects dev version ldflags). +- `task build` โ€” local binary at `dist/bdcli`. +- `task test` (or `go test ./...`) โ€” all tests. +- `task check` โ€” `go fix` + `go fmt` + `go vet` + `golangci-lint` + tests, in one shot. +- `task ci` โ€” what CI actually runs: deps + fix + fmt + vet + coverage + build. The golangci-lint GitHub action runs separately in `ci.yml`. +- `task build:all` / `task release:snapshot` โ€” GoReleaser snapshot cross-builds (all OS/arch from one machine). + +Before you claim a change is validated, run at minimum `gofmt`, `go vet ./...`, and `go test ./...`; prefer `task check` when golangci-lint is available. + +## 3. Running isolated tests + +- Narrow with the `-run` regex, e.g. `go test ./internal/models -run TestDiscordChannel` or `go test ./internal/discord -run TestInject`. +- Many tests gate on `runtime.GOOS` with `t.Skipf` when the OS doesn't match โ€” run the subset aligned with your OS; these are guards, not cross-platform stubs. A green run on Linux does not prove the Windows/macOS paths. +- Network-dependent code is tested against `httptest` servers: endpoint URLs are declared as package-level `var`s (see `internal/betterdiscord/download.go`) precisely so tests can repoint them. Keep new endpoints in that pattern; never write a test that hits the real network. +- Coverage/profiling artifacts go to `debug/` (`task coverage:html`, `task bench:cpu`); that directory is gitignored. + +## 4. Style rules + +1. **Imports** + - Standard library first, blank line, then everything else (external deps and `github.com/betterdiscord/cli/internal/...` share one alphabetized block in existing files). Match the surrounding file; `gofmt` is canonical. +2. **Formatting & naming** + - Always run `gofmt`; tabs are canonical indentation. + - Exported identifiers are PascalCase with doc comments; private helpers stay lowercase. Channel constants are grouped `iota` blocks. + - Comments in this codebase explain *why* (see `internal/discord/injection.go`) โ€” keep that bar for non-obvious logic, especially anything transactional or platform-specific. +3. **Output protocol** โ€” the CLI equivalent of the installer's event protocol: + - All user-facing output goes through `internal/output`, never `fmt.Print*` to stdout directly. This is what makes `--silent` / `BDCLI_SILENT` (which swap in `io.Discard`) and output-capturing tests work. + - Status lines are emoji-prefixed: `โœ…` success, `โŒ` failure, `๐Ÿ”` retry/fallback. Follow-up detail lines are indented with three spaces (`output.Printf(" %s\n", err.Error())`). + - Tabular output uses `output.NewTableWriter()`; versions are normalized with `output.FormatVersion`. +4. **Error handling** + - Prefer early returns over nested conditionals. + - On failure: print the human-readable `โŒ` message via `output`, then `return` the error up to the Cobra `RunE` โ€” `cmd.Execute()` prints it to stderr and exits 1. Don't both print and re-wrap the same message at every level. + - Swallow errors only when a fallback genuinely handles them (e.g. website โ†’ GitHub download fallback), and always log the fallback so users can see what happened. +5. **Platform code** + - OS-specific logic belongs in `paths_*.go`-style files or explicit `runtime.GOOS` switches, with WSL handled through `internal/wsl`. Don't sprinkle ad-hoc OS conditionals through command code. +6. **Injection safety** + - The `app.asar` shadow injection is transactional: the original asar is preserved as `betterdiscord.app.asar` and any failure after the rename rolls back. Writability is probed (`probeWritable`) before any destructive step, and Snap installs are rejected up-front by design. Preserve all three properties when touching install/uninstall/repair flows. + +## 5. Release & CI context + +Releases are **tag-driven via GoReleaser**, all from a single Ubuntu runner (unlike the installer's per-OS matrix โ€” everything here cross-compiles with `CGO_ENABLED=0`). + +- **Trigger.** Pushing a `vX.Y.Z` tag runs `.github/workflows/release.yml`: `task ci`, then GoReleaser (linux/windows/darwin ร— amd64/arm64), then an npm publish. The `nightly` tag is ignored by GoReleaser; prereleases are auto-detected (`prerelease: auto`). +- **Version.** Injected via `-ldflags "-X main.version={{ .Version }}"` plus commit/date. CI also runs `npm version ` before publishing, which is why `package.json` stays at `0.0.0` in the repo. +- **Artifacts.** `bdcli___.tar.gz` (`.zip` on Windows) containing the binary, README, LICENSE, and shell completions, plus `bdcli_checksums.txt`. Completions are generated by `scripts/completions.sh` in the `before` hook. +- **Publishing.** Homebrew cask pushed directly to `BetterDiscord/homebrew-tap` `main`; winget manifest PR'd from the `betterdiscord/winget-pkgs` fork to `microsoft/winget-pkgs` โ€” both need `GH_PAT` (`GITHUB_TOKEN` can't push cross-repo). npm publish uses OIDC provenance (`id-token: write`), so there is no npm token secret. +- **CI on PRs/push** (`ci.yml`): `task ci` + the golangci-lint action, Linux only. Cross-platform correctness relies on the OS-gated tests and review โ€” flag platform-specific risk in PRs since CI won't catch it. +- Keep `Taskfile.yml`, `.goreleaser.yaml`, and `release.yml` in sync when changing build flags or artifact names; the npm wrapper's `goBinary.url` template in `package.json` must keep matching GoReleaser's archive naming. + +## 6. Documentation & collaborator expectations + +- `README.md` is the user-facing reference (command table, compatibility matrix, FAQ). If you add/rename a command or flag, update the README command reference and help-output snippet in the same PR. +- `CONTRIBUTING.md` covers commit style (present-tense imperative, 72-char subject) and the Go styleguide summary. If it conflicts with these notes or with actual code, escalate via an issue rather than silently picking one. +- Keep AGENTS focused on machine-readable operating guidance; long-form docs belong in the README. + +## 7. Cursor / Copilot / assistant config + +- `CLAUDE.md` is a symlink to this file, so Claude Code reads the same guidance. +- There are no `.cursor/rules/`, `.cursorrules`, or `.github/copilot-instructions.md` files; default assistant settings apply. +- If future agents add such rules, append them to this section so every agent reads the new constraints first. + +## 8. Security, secrets, and environment + +- **Never commit credentials, tokens, or `.env` files.** Release publishing relies solely on GitHub Actions secrets: `GITHUB_TOKEN` (release create/upload) and `GH_PAT` (homebrew tap push, winget cross-repo PR). npm uses OIDC provenance โ€” no npm token exists. No secret is needed to build locally. +- **Supply chain.** The `betterdiscord.asar` is fetched over TLS from the official site with a GitHub-release fallback; there is no checksum verification of that asar yet โ€” treat it as a known gap, not something to silently remove or quietly "fix" without discussion. +- **Dev builds must not fall back.** The `canary` development asar is GitHub-only; a failure there must never fall back to stable (a developer silently receiving stable is a near-undetectable footgun โ€” this is documented in `download.go`; preserve it). +- **Snap is unsupported on purpose.** The read-only squashfs mount can't host the injection; the code rejects Snap installs with an actionable message before touching a running client. Don't add speculative Snap support. +- Keep tracked config (`go.mod`, `package.json`, `.goreleaser.yaml`) clean; do not add untracked credential copies to the tree. + +## 9. Help & escalation + +- If you find conflicting conventions between AGENTS, `CONTRIBUTING.md`, and actual code, escalate by opening an issue so the maintainers can harmonize them. +- Use the BetterDiscord Discord (linked from the README) when platform-integration questions exceed local knowledge. +- Document any new tooling (e.g., a new Task target or script) within this file so future agents immediately know how to operate the repo. + +## 10. AI self-disclosure + +If you are an autonomous agent submitting a PR where the human operator did not personally review the output before submission, say so explicitly in the PR description. For example: + +> This PR was generated by [agent/tool]. The human operator [reviewed the diff and ran the checks locally / did not review the output before submission]. + +This is a transparency expectation, not a penalty against AI-assisted work. It helps calibrate review effort. PRs that appear to be unreviewed agent output without this disclosure will be closed without detailed feedback. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 7cd3d95c8d39689b08ea9f3540d8d90c9871990f Mon Sep 17 00:00:00 2001 From: Zerebos Date: Tue, 4 Aug 2026 13:12:10 -0400 Subject: [PATCH 35/36] chore: updating contributing guide --- AGENTS.md | 2 +- CONTRIBUTING.md | 26 ++++++++++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a1e1e0a..c376062 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ Releases are **tag-driven via GoReleaser**, all from a single Ubuntu runner (unl ## 6. Documentation & collaborator expectations - `README.md` is the user-facing reference (command table, compatibility matrix, FAQ). If you add/rename a command or flag, update the README command reference and help-output snippet in the same PR. -- `CONTRIBUTING.md` covers commit style (present-tense imperative, 72-char subject) and the Go styleguide summary. If it conflicts with these notes or with actual code, escalate via an issue rather than silently picking one. +- `CONTRIBUTING.md` covers commit style (present-tense imperative, 72-char subject), the Go styleguide summary, and the AI-assisted-contributions policy (see also ยง10). If it conflicts with these notes or with actual code, escalate via an issue rather than silently picking one. - Keep AGENTS focused on machine-readable operating guidance; long-form docs belong in the README. ## 7. Cursor / Copilot / assistant config diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c753096..2c5e977 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,8 @@ The following is a set of guidelines for contributing to BetterDiscord CLI. Thes * [Your First Code Contribution](#your-first-code-contribution) * [Pull Requests](#pull-requests) +[AI-assisted contributions](#ai-assisted-contributions) + [Styleguides](#styleguides) * [Git Commit Messages](#git-commit-messages) * [Go Styleguide](#go-styleguide) @@ -44,7 +46,7 @@ The repository is organized into: โ”‚ โ”œโ”€โ”€ output/ // Output formatting โ”‚ โ”œโ”€โ”€ utils/ // Shared utilities โ”‚ โ””โ”€โ”€ wsl/ // WSL support -โ”œโ”€โ”€ completions/ // Shell completion scripts (bash, fish, zsh) +โ”œโ”€โ”€ completions/ // Generated shell completions (bash, fish, zsh; gitignored) โ”œโ”€โ”€ main.go // CLI entry point โ”œโ”€โ”€ go.mod / go.sum // Dependency management โ””โ”€โ”€ README.md // Project documentation @@ -68,9 +70,11 @@ Common commands: The project uses [Taskfile](https://taskfile.dev/) for common tasks: -* `task build` - compile binaries for all platforms. -* `task test` - run tests and coverage. -* `task install` - install the CLI locally (from source). +* `task run -- ` - run the CLI locally with dev version info. +* `task build` - build a binary for your current platform into `dist/`. +* `task build:all` - build binaries for all platforms (requires GoReleaser). +* `task test` - run all tests (use `task coverage` for coverage). +* `task check` - format, vet, lint, and test in one shot. See `Taskfile.yml` for the full list of available tasks. @@ -116,6 +120,15 @@ Please follow these steps to have your contribution considered by the maintainer While the prerequisites above must be satisfied prior to having your pull request reviewed, the reviewer(s) may ask you to complete additional design work, tests, or other changes before your pull request can be ultimately accepted. +## AI-assisted contributions + +AI tools are fine to use. Submitting output with little or no personal review is not. + +- **Review the diff yourself.** If you can't explain why each changed line is correct, the PR isn't ready. +- **Run the required checks locally.** Don't rely on CI to catch problems you could catch before pushing. +- **Disclose AI involvement in your PR description.** If an AI wrote a meaningful portion of the code, say so and briefly describe what you reviewed. This isn't meant as a penalty, it's useful context for the reviewer. +- **If you used an autonomous agent with minimal personal review of the output, say that explicitly.** PRs that appear to be unreviewed agent output and don't disclose this will be closed without detailed feedback. + ## Styleguides ### Git Commit Messages @@ -129,11 +142,12 @@ While the prerequisites above must be satisfied prior to having your pull reques ### Go Styleguide * Run `gofmt` on any Go files you touch. -* Keep standard library imports first, then a blank line, then external deps, then local packages. +* Keep standard library imports first, then a blank line, then all remaining imports (external and internal packages share one alphabetized block). * Prefer early returns for error handling. +* Route all user-facing output through `internal/output` (never `fmt.Print*` to stdout directly) so `--silent` and output-capturing tests keep working. * Use clear, descriptive names for functions and variables. * Add comments to exported functions and types. -* Write tests for new functionality (see existing `*_test.go` files for patterns). +* Write tests for new functionality (see existing `*_test.go` files for patterns). Tests must not hit the real network, point package-level endpoint vars at `httptest` servers instead. ## Additional Notes From 387b6c003b6c70dc542982880d66a7e1a1b7881c Mon Sep 17 00:00:00 2001 From: Zerebos Date: Tue, 4 Aug 2026 13:20:26 -0400 Subject: [PATCH 36/36] chore: update readme to clarify snap --- README.md | 56 ++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 622c46a..60c4f5b 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ This repository contains the source code for the BetterDiscord CLI. It is a nati - Easy installation and uninstallation of BetterDiscord - Support for multiple Discord channels (Stable, PTB, Canary) +- Automatically stops and restarts Discord during install/uninstall +- Optional BetterDiscord development builds via `--dev` - Discover Discord installs and suggested paths - Manage plugins and themes (list, install, update, remove) - Browse and search the BetterDiscord store @@ -162,6 +164,10 @@ bdcli uninstall --path /path/to/Discord bdcli uninstall --all bdcli uninstall --full +# Install the BetterDiscord development build (install-only; update tracks stable) +bdcli install --dev +BDCLI_DEV_BUILD=1 bdcli install + # Check-only updates bdcli update --check bdcli plugins update --check @@ -189,25 +195,25 @@ BDCLI_SILENT=1 bdcli update A cross-platform CLI for installing, updating, and managing BetterDiscord. Usage: - bdcli [flags] - bdcli [command] + bdcli [flags] + bdcli [command] Available Commands: - completion Generate shell completions - discover Discover Discord installations and related data - help Help about any command - info Displays information about BetterDiscord installation - install Installs BetterDiscord to your Discord - plugins Manage BetterDiscord plugins - store Browse and search the BetterDiscord store - themes Manage BetterDiscord themes - uninstall Uninstalls BetterDiscord from your Discord - update Update BetterDiscord to the latest version - version Print the version number + completion Generate shell completions + discover Discover Discord installations and related data + help Help about any command + info Displays information about BetterDiscord installation + install Installs BetterDiscord to your Discord + plugins Manage BetterDiscord plugins + store Browse and search the BetterDiscord store + themes Manage BetterDiscord themes + uninstall Uninstalls BetterDiscord from your Discord + update Update BetterDiscord to the latest version + version Print the version number Flags: - --silent Suppress non-error output - -h, --help help for bdcli + -h, --help help for bdcli + --silent Suppress non-error output Use "bdcli [command] --help" for more information about a command. ``` @@ -222,7 +228,21 @@ Yes. Flatpak Discord installs are supported. ### Why is Snap Discord unsupported on Linux? -Discord Snap packaging/runtime changes prevent the CLI from supporting Snap installs. +Upstream Snap packaging changes mount Discord in a read-only filesystem, so the CLI cannot write the BetterDiscord injection into it. Native and Flatpak installs remain supported, and the CLI detects Snap installs and rejects them with a clear error instead of leaving a half-modified install. + +### Do I need to close Discord before installing or uninstalling? + +No. The CLI stops Discord automatically before modifying it and restarts it afterward if it was running. The exception is `bdcli update`, which only replaces the BetterDiscord asar, you need to restart Discord manually for the update to take effect. + +### How do I install the BetterDiscord development build? + +Use the `--dev` flag (or set `BDCLI_DEV_BUILD=1`): + +```bash +bdcli install --dev +``` + +The development build is BetterDiscord's rolling `canary` pre-release on GitHub (unrelated to the Discord Canary channel). It is not versioned, so `bdcli update` always tracks the stable release. Rerun `bdcli install --dev` to get the newest development build. ### How can I use the global BetterDiscord folder with Flatpak? @@ -329,7 +349,9 @@ Release outline: โ”‚ โ”œโ”€โ”€ betterdiscord/ # BetterDiscord installation logic โ”‚ โ”œโ”€โ”€ discord/ # Discord path resolution and injection โ”‚ โ”œโ”€โ”€ models/ # Data models -โ”‚ โ””โ”€โ”€ utils/ # Utility functions +โ”‚ โ”œโ”€โ”€ output/ # Output formatting +โ”‚ โ”œโ”€โ”€ utils/ # Utility functions +โ”‚ โ””โ”€โ”€ wsl/ # WSL detection and path mapping โ”œโ”€โ”€ main.go # Entry point โ”œโ”€โ”€ Taskfile.yml # Task automation โ””โ”€โ”€ .goreleaser.yaml # Release configuration