From 30b6bfcd7bee1a79a4b73c25c4f37c5b3854ed8e Mon Sep 17 00:00:00 2001 From: Zerebos Date: Thu, 30 Jul 2026 09:27:57 -0400 Subject: [PATCH 01/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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)