From 1184d90ec6c013a0564be064415e10bdd09aff34 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 18 Jul 2026 01:52:39 -0400 Subject: [PATCH 01/20] fix: use new injection assets --- discord/assets/{injection.js => app_index.js} | 10 +++++----- discord/assets/app_package.json | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) rename discord/assets/{injection.js => app_index.js} (61%) create mode 100644 discord/assets/app_package.json diff --git a/discord/assets/injection.js b/discord/assets/app_index.js similarity index 61% rename from discord/assets/injection.js rename to discord/assets/app_index.js index 55f2f3b..d92860e 100644 --- a/discord/assets/injection.js +++ b/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"); diff --git a/discord/assets/app_package.json b/discord/assets/app_package.json new file mode 100644 index 0000000..4960d2a --- /dev/null +++ b/discord/assets/app_package.json @@ -0,0 +1 @@ +{"main": "./index.js"} From dbb719e57ed6e080d38b4c0d995e7f0f88b8dbda Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 18 Jul 2026 01:52:49 -0400 Subject: [PATCH 02/20] refactor: use resources path --- api/controller.go | 2 +- discord/install.go | 15 +++++++++------ discord/paths.go | 6 +++--- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/api/controller.go b/api/controller.go index 412777b..28f7610 100644 --- a/api/controller.go +++ b/api/controller.go @@ -139,7 +139,7 @@ func (d *Controller) BrowseForDiscord(schannel string) string { } if result := discord.ResolvePath(selection); result != nil { - return result.CorePath + return result.ResourcesPath } return "" diff --git a/discord/install.go b/discord/install.go index e41cc73..7a61e2b 100644 --- a/discord/install.go +++ b/discord/install.go @@ -9,11 +9,14 @@ import ( ) type DiscordInstall struct { - CorePath string `json:"corePath"` - Channel types.DiscordChannel `json:"channel"` - Version string `json:"version"` - IsFlatpak bool `json:"isFlatpak"` - IsSnap bool `json:"isSnap"` + // ResourcesPath is the Discord install's `resources` directory (the one + // holding app.asar). The json tag stays `corePath` so the existing Wails + // bindings and frontend keep working; a full frontend rename is deferred. + ResourcesPath string `json:"corePath"` + Channel types.DiscordChannel `json:"channel"` + Version string `json:"version"` + IsFlatpak bool `json:"isFlatpak"` + IsSnap bool `json:"isSnap"` } // InstallBD installs BetterDiscord into this Discord installation @@ -118,7 +121,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/discord/paths.go b/discord/paths.go index ea8d7a0..dc28319 100644 --- a/discord/paths.go +++ b/discord/paths.go @@ -49,7 +49,7 @@ func GetChannel(proposed string) types.DiscordChannel { func GetSuggestedPath(channel types.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] } From 0474c5b79141a52ff653e0db871a5a271b620114 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 18 Jul 2026 01:56:03 -0400 Subject: [PATCH 03/20] feat: update common path resolution --- discord/paths_common.go | 283 ++++++++++++++--------------------- discord/paths_common_test.go | 187 +++++++++++++++-------- 2 files changed, 233 insertions(+), 237 deletions(-) diff --git a/discord/paths_common.go b/discord/paths_common.go index b2ef324..90ce33c 100644 --- a/discord/paths_common.go +++ b/discord/paths_common.go @@ -3,216 +3,155 @@ package discord import ( - "io/fs" + "encoding/json" "os" "path/filepath" - "sort" "strings" + "installer/types" "installer/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 - } +// 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"` +} - 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 utils.CompareVersions(candidates[i].Name(), candidates[j].Name()) < 0 - }) - versionDir := candidates[len(candidates)-1].Name() - - // 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{ - CorePath: 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") - }) - - if len(candidates) == 0 { - return nil - } - - // 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 +// 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 strings.HasPrefix(strings.ToLower(selected), "discord") { - // Get version dir like 0.0.35 - dFiles, err := os.ReadDir(proposed) - if err != nil { - return nil + if strings.HasPrefix(filepath.Base(proposed), "app-") { + if res := filepath.Join(proposed, "resources"); hasAppAsar(res) { + return res } + return "" + } - 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 utils.CompareVersions(candidates[i].Name(), candidates[j].Name()) < 0 - }) - versionDir := candidates[len(candidates)-1].Name() - - // 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 - } + // A dir with a direct `resources` child (flatpak files/{channel-}). + if res := filepath.Join(proposed, "resources"); hasAppAsar(res) { + return res + } - // 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") + // 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 } } - // 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 - } + return "" +} - 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 = types.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, "modules", "discord_desktop_core") - } else { - finalPath = filepath.Join(proposed, "modules", coreWrap, "discord_desktop_core") + if info.Version != "" { + version = info.Version } } - if selected == "discord_desktop_core" { - finalPath = proposed + return &DiscordInstall{ + ResourcesPath: resourcesDir, + Channel: channel, + Version: version, + } +} + +// 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 + } - // Heuristic: infer the packaging format from the resolved path. These - // substring checks could in theory false-positive on an unusual custom - // path, but they match the real Flatpak/Snap layouts in practice. - if detectFlatpak { - isFlatpak = strings.Contains(finalPath, "com.discordapp.") - } - if detectSnap { - isSnap = strings.Contains(finalPath, "snap/") - } + install := newResourcesInstall(resources) - return &DiscordInstall{ - CorePath: 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 } diff --git a/discord/paths_common_test.go b/discord/paths_common_test.go index b61e6e2..1187f49 100644 --- a/discord/paths_common_test.go +++ b/discord/paths_common_test.go @@ -4,149 +4,206 @@ import ( "os" "path/filepath" "testing" + + "installer/types" ) -func writeCoreAsar(t *testing.T, corePath string) { +// writeAppAsar creates a resources dir seeded with an app.asar. +func writeAppAsar(t *testing.T, resourcesDir string) { t.Helper() - if err := os.MkdirAll(corePath, 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(corePath, "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.CorePath != coreWrap { - t.Errorf("CorePath = %s, expected %s", result.CorePath, 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.CorePath != coreWrap { - t.Errorf("CorePath = %s, expected %s", result.CorePath, coreWrap) + if result.ResourcesPath != resources { + t.Errorf("ResourcesPath = %s, expected %s", result.ResourcesPath, resources) } } -func TestValidateWindowsStyleInstall_FromCoreFolder(t *testing.T) { - tmpDir := t.TempDir() - corePath := filepath.Join(tmpDir, "discord_desktop_core") - writeCoreAsar(t, corePath) +func TestValidateWindowsStyleInstall_FromResourcesFolder(t *testing.T) { + resources := filepath.Join(t.TempDir(), "resources") + writeAppAsar(t, resources) - result := validateWindowsStyleInstall(corePath) + result := validateWindowsStyleInstall(resources) if result == nil { - t.Fatalf("Expected install for %s", corePath) + t.Fatalf("Expected install for %s", resources) } - if result.CorePath != corePath { - t.Errorf("CorePath = %s, expected %s", result.CorePath, corePath) + 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") - corePath := filepath.Join(root, "0.0.35", "modules", "discord_desktop_core") - - writeCoreAsar(t, corePath) + 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.CorePath != corePath { - t.Errorf("CorePath = %s, expected %s", result.CorePath, corePath) + 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") - corePath := filepath.Join(versionDir, "modules", "discord_desktop_core") - - writeCoreAsar(t, corePath) + 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.CorePath != corePath { - t.Errorf("CorePath = %s, expected %s", result.CorePath, corePath) + 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") - corePath := 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, corePath) - - 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") - corePath := 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, corePath) +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 != types.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 != types.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) } } From 0744164c9e6f0c9420574d40e92fcec4d112d58f Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 18 Jul 2026 01:58:27 -0400 Subject: [PATCH 04/20] feat: rework injection to match app.asar --- discord/injection.go | 141 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 112 insertions(+), 29 deletions(-) diff --git a/discord/injection.go b/discord/injection.go index 00c72e0..ec7c705 100644 --- a/discord/injection.go +++ b/discord/injection.go @@ -2,61 +2,144 @@ package discord import ( _ "embed" + "fmt" "log" "os" "path/filepath" - "strings" "installer/betterdiscord" + "installer/utils" ) -//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 + +// 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") + + // 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 { + log.Printf("❌ Cannot write to %s\n", resources) + log.Printf(" %s\n", err.Error()) + return err + } + + // 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 { + log.Printf("❌ Unable to preserve app.asar in %s\n", resources) + log.Printf(" %s\n", err.Error()) + return err + } + renamed = true + default: + 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. + rollback := func() { + os.RemoveAll(appDir) + if renamed && !utils.Exists(originalAsar) { + os.Rename(preservedAsar, originalAsar) + } + } + + if err := os.MkdirAll(appDir, 0755); err != nil { + log.Printf("❌ Unable to create %s\n", appDir) + log.Printf(" %s\n", err.Error()) + rollback() + return err + } + + if err := os.WriteFile(filepath.Join(appDir, "package.json"), []byte(appPackageJSON), 0o644); err != nil { + log.Printf("❌ Unable to write package.json in %s\n", appDir) + log.Printf(" %s\n", err.Error()) + rollback() + return err + } - // 0o644: index.js is a require target, it doesn't need the executable bit - // (matches the mode uninject writes). - if err := os.WriteFile(filepath.Join(discord.CorePath, "index.js"), []byte(injectionScript), 0o644); err != nil { - log.Printf("❌ Unable to write index.js in %s\n", discord.CorePath) + if err := os.WriteFile(filepath.Join(appDir, "index.js"), []byte(appIndexScript), 0o644); err != nil { + log.Printf("❌ Unable to write index.js in %s\n", appDir) log.Printf(" %s\n", err.Error()) + rollback() return err } - log.Printf("✅ Injected into %s\n", discord.CorePath) + 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) + } + + log.Printf("✅ Injected into %s\n", resources) return nil } +// 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 { - indexFile := filepath.Join(discord.CorePath, "index.js") - - contents, err := os.ReadFile(indexFile) + resources := discord.ResourcesPath + originalAsar := filepath.Join(resources, "app.asar") + preservedAsar := filepath.Join(resources, "betterdiscord.app.asar") + appDir := filepath.Join(resources, "app") - // 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") { - log.Printf("✅ No injection found for %s\n", discord.Channel.Name()) - return nil + if utils.Exists(appDir) { + if err := os.RemoveAll(appDir); err != nil { + log.Printf("❌ Unable to remove %s\n", appDir) + log.Printf(" %s\n", err.Error()) + return err } } - if err := os.WriteFile(indexFile, []byte(`module.exports = require("./core.asar");`), 0o644); err != nil { - log.Printf("❌ Unable to write file %s\n", indexFile) - log.Printf(" %s\n", err.Error()) - return err + // 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 { + log.Printf("❌ Unable to restore app.asar in %s\n", resources) + log.Printf(" %s\n", err.Error()) + return err + } } - log.Printf("✅ Removed from %s\n", discord.Channel.Name()) + log.Printf("✅ Removed from %s\n", discord.Channel.Name()) return nil } -// TODO: consider putting this in the betterdiscord package +// 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 { - indexFile := filepath.Join(discord.CorePath, "index.js") - contents, err := os.ReadFile(indexFile) - if err != nil { - return false - } - lower := strings.ToLower(string(contents)) - return strings.Contains(lower, "betterdiscord") + resources := discord.ResourcesPath + return utils.Exists(filepath.Join(resources, "app", "index.js")) && + utils.Exists(filepath.Join(resources, "betterdiscord.app.asar")) } From 732a782fd6d6e15acfc00150e9588a793d653e3d Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 18 Jul 2026 01:58:36 -0400 Subject: [PATCH 05/20] chore: update remaining tests --- discord/injection_test.go | 241 +++++++++++++++++++++++++------------- discord/install_test.go | 41 ++++--- 2 files changed, 187 insertions(+), 95 deletions(-) diff --git a/discord/injection_test.go b/discord/injection_test.go index 6b4ab25..780fa33 100644 --- a/discord/injection_test.go +++ b/discord/injection_test.go @@ -1,150 +1,233 @@ package discord import ( - "installer/types" "os" "path/filepath" "runtime" + "strings" "testing" -) -const defaultIndexJS = `module.exports = require("./core.asar");` + "installer/types" + "installer/utils" +) -func TestIsInjected(t *testing.T) { - tmpDir := t.TempDir() - corePath := filepath.Join(tmpDir, "discord_desktop_core") - if err := os.MkdirAll(corePath, 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(corePath, "index.js") + return resources, content +} - install := &DiscordInstall{ - CorePath: corePath, - Channel: types.Stable, - } +func TestIsInjected(t *testing.T) { + resources := t.TempDir() + install := &DiscordInstall{ResourcesPath: resources, Channel: types.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) { - corePath := t.TempDir() - install := &DiscordInstall{CorePath: corePath, Channel: types.Stable} +func TestInject_Clean(t *testing.T) { + resources, original := newResourcesDir(t) + install := &DiscordInstall{ResourcesPath: resources, Channel: types.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(corePath, "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) { - corePath := t.TempDir() - install := &DiscordInstall{CorePath: corePath, Channel: types.Stable} - indexFile := filepath.Join(corePath, "index.js") +func TestInject_Idempotent(t *testing.T) { + resources, original := newResourcesDir(t) + install := &DiscordInstall{ResourcesPath: resources, Channel: types.Stable} + + 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) + } - 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) + 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: types.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) { - corePath := t.TempDir() - install := &DiscordInstall{CorePath: corePath, Channel: types.Stable} - indexFile := filepath.Join(corePath, "index.js") +func TestUninject_NotInjectedIsNoop(t *testing.T) { + resources, original := newResourcesDir(t) + install := &DiscordInstall{ResourcesPath: resources, Channel: types.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: types.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) { - corePath := t.TempDir() - install := &DiscordInstall{CorePath: corePath, Channel: types.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: types.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) }) + + if err := install.inject(nil); err == nil { + t.Fatal("expected inject to fail the writability probe") } - contents, err := os.ReadFile(filepath.Join(corePath, "index.js")) + // 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(got) != string(original) { + t.Errorf("app.asar content changed: got %q", got) } - if string(contents) != defaultIndexJS { - t.Errorf("index.js = %q, expected %q", string(contents), defaultIndexJS) + 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) { - corePath := t.TempDir() - install := &DiscordInstall{CorePath: corePath, Channel: types.Stable} +func TestInject_RollbackOnMidOpFailure(t *testing.T) { + resources, original := newResourcesDir(t) + install := &DiscordInstall{ResourcesPath: resources, Channel: types.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") } } diff --git a/discord/install_test.go b/discord/install_test.go index ea4dd49..a0df0dc 100644 --- a/discord/install_test.go +++ b/discord/install_test.go @@ -9,33 +9,42 @@ import ( "installer/types" ) -// 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{CorePath: corePath, Channel: types.Stable} + install := &DiscordInstall{ResourcesPath: resources, Channel: types.Stable} if err := install.UninstallBD(types.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) } } func TestGetBetterDiscordInstall_Global(t *testing.T) { - install := &DiscordInstall{CorePath: "/some/discord/core", Channel: types.Stable} + install := &DiscordInstall{ResourcesPath: "/some/discord/core", Channel: types.Stable} bd, err := install.GetBetterDiscordInstall() if err != nil { @@ -53,7 +62,7 @@ func TestGetBetterDiscordInstall_FlatpakResolvesConfig(t *testing.T) { // 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{CorePath: corePath, Channel: types.Stable, IsFlatpak: true} + install := &DiscordInstall{ResourcesPath: corePath, Channel: types.Stable, IsFlatpak: true} bd, err := install.GetBetterDiscordInstall() if err != nil { @@ -74,7 +83,7 @@ func TestGetBetterDiscordInstall_SnapResolvesConfig(t *testing.T) { // 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{CorePath: corePath, Channel: types.Stable, IsSnap: true} + install := &DiscordInstall{ResourcesPath: corePath, Channel: types.Stable, IsSnap: true} bd, err := install.GetBetterDiscordInstall() if err != nil { @@ -89,7 +98,7 @@ func TestGetBetterDiscordInstall_SnapResolvesConfig(t *testing.T) { // 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{CorePath: "/no/matching/segment/here", Channel: types.Stable, IsFlatpak: true} + install := &DiscordInstall{ResourcesPath: "/no/matching/segment/here", Channel: types.Stable, IsFlatpak: true} bd, err := install.GetBetterDiscordInstall() if err == nil { From 7230528fbaa0701ad44262c93733c2a05cd95acf Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 18 Jul 2026 02:55:41 -0400 Subject: [PATCH 06/20] fix: update paths for macos + linux + flatpak --- discord/install.go | 21 ++++++----- discord/install_test.go | 69 +++++++++++++----------------------- discord/paths.go | 6 +++- discord/paths_common.go | 5 +++ discord/paths_common_test.go | 28 +++++++++++++++ discord/paths_darwin.go | 21 +++++------ discord/paths_linux.go | 30 +++++++--------- discord/paths_test.go | 10 ++++++ 8 files changed, 109 insertions(+), 81 deletions(-) diff --git a/discord/install.go b/discord/install.go index 7a61e2b..b3b1056 100644 --- a/discord/install.go +++ b/discord/install.go @@ -2,10 +2,12 @@ package discord import ( "log" + "os" + "path/filepath" + "strings" "installer/betterdiscord" "installer/types" - "installer/utils" ) type DiscordInstall struct { @@ -114,17 +116,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/discord/install_test.go b/discord/install_test.go index a0df0dc..54f5ca1 100644 --- a/discord/install_test.go +++ b/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: types.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: types.Stable, IsSnap: true} + cases := []struct { + channel types.DiscordChannel + id string + }{ + {types.Stable, "com.discordapp.Discord"}, + {types.Canary, "com.discordapp.DiscordCanary"}, + {types.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: types.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) + } } } diff --git a/discord/paths.go b/discord/paths.go index dc28319..b15c7c1 100644 --- a/discord/paths.go +++ b/discord/paths.go @@ -38,8 +38,12 @@ func GetVersion(proposed string) string { func GetChannel(proposed string) types.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 types.Channels { - if strings.ToLower(folder) == strings.ReplaceAll(strings.ToLower(channel.Name()), " ", "") { + if normalized == strings.ReplaceAll(strings.ToLower(channel.Name()), " ", "") { return channel } } diff --git a/discord/paths_common.go b/discord/paths_common.go index 90ce33c..45633b2 100644 --- a/discord/paths_common.go +++ b/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/discord/paths_common_test.go b/discord/paths_common_test.go index 1187f49..3fb998c 100644 --- a/discord/paths_common_test.go +++ b/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 != types.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/discord/paths_darwin.go b/discord/paths_darwin.go index 7931f3e..88ad3f6 100644 --- a/discord/paths_darwin.go +++ b/discord/paths_darwin.go @@ -3,24 +3,25 @@ package discord import ( "os" "path/filepath" - "strings" "installer/types" ) 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 types.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/discord/paths_linux.go b/discord/paths_linux.go index def3fd1..d566425 100644 --- a/discord/paths_linux.go +++ b/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/discord/paths_test.go b/discord/paths_test.go index 1ed26a3..7c35f6f 100644 --- a/discord/paths_test.go +++ b/discord/paths_test.go @@ -70,6 +70,16 @@ func TestGetChannel(t *testing.T) { path: filepath.Join("C:", "Users", "Me", "AppData", "Local", "DiscordCanary", "app-1.0.9002", "modules", "discord_desktop_core-1", "discord_desktop_core"), expected: types.Canary, }, + { + name: "macOS bundle name", + path: filepath.Join("/Applications", "Discord Canary.app", "Contents", "Resources"), + expected: types.Canary, + }, + { + name: "macOS stable bundle name", + path: filepath.Join("/Applications", "Discord.app", "Contents", "Resources"), + expected: types.Stable, + }, } for _, tt := range tests { From d8a472738746db510c309b19470c3ad0d6f16c9f Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 18 Jul 2026 05:37:33 -0400 Subject: [PATCH 07/20] refactor: remove all references to corePath --- api/controller.go | 27 +++++++------------ discord/install.go | 5 ++-- discord/paths_darwin.go | 6 +++++ discord/paths_linux.go | 13 +++++++++ discord/paths_windows.go | 10 +++++++ frontend/src/lib/stores/state.svelte.ts | 6 ++--- frontend/src/lib/types.ts | 2 +- .../actions/perform/[action]/+page.svelte | 4 +-- .../actions/setup/[action]/+page.svelte | 6 ++--- 9 files changed, 50 insertions(+), 29 deletions(-) diff --git a/api/controller.go b/api/controller.go index 28f7610..3fc78ef 100644 --- a/api/controller.go +++ b/api/controller.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "log" - "os" "installer/discord" "installer/types" @@ -37,9 +36,9 @@ func (d *Controller) GetDiscordPath(channel string) string { } // #region Actions -func (action *Controller) Install(corePaths []string, options types.InstallOptions) { - for i := range corePaths { - install := discord.ResolvePath(corePaths[i]) +func (action *Controller) Install(resourcePaths []string, options types.InstallOptions) { + for i := range resourcePaths { + install := discord.ResolvePath(resourcePaths[i]) if install == nil { continue } @@ -56,9 +55,9 @@ func (action *Controller) Install(corePaths []string, options types.InstallOptio runtime.EventsEmit(action.ctx, "success") } -func (action *Controller) Uninstall(corePaths []string, options types.UninstallOptions) { - for i := range corePaths { - install := discord.ResolvePath(corePaths[i]) +func (action *Controller) Uninstall(resourcePaths []string, options types.UninstallOptions) { + for i := range resourcePaths { + install := discord.ResolvePath(resourcePaths[i]) if install == nil { continue } @@ -73,9 +72,9 @@ func (action *Controller) Uninstall(corePaths []string, options types.UninstallO runtime.EventsEmit(action.ctx, "success") } -func (action *Controller) Repair(corePaths []string, options types.RepairOptions) { - for i := range corePaths { - install := discord.ResolvePath(corePaths[i]) +func (action *Controller) Repair(resourcePaths []string, options types.RepairOptions) { + for i := range resourcePaths { + install := discord.ResolvePath(resourcePaths[i]) if install == nil { continue } @@ -119,17 +118,11 @@ func (action *Controller) Repair(corePaths []string, options types.RepairOptions // #region Dialogs func (d *Controller) BrowseForDiscord(schannel string) string { - var browsePath string - browsePath, err := os.UserConfigDir() - if err != nil { - browsePath = os.Getenv("HOME") - } - channel := types.ParseChannel(schannel) selection, err := runtime.OpenDirectoryDialog(d.ctx, runtime.OpenDialogOptions{ Title: "Browsing to " + channel.Name(), - DefaultDirectory: browsePath, + DefaultDirectory: discord.DefaultBrowseDir(), ShowHiddenFiles: true, TreatPackagesAsDirectories: true, }) diff --git a/discord/install.go b/discord/install.go index b3b1056..45144e8 100644 --- a/discord/install.go +++ b/discord/install.go @@ -12,9 +12,8 @@ import ( type DiscordInstall struct { // ResourcesPath is the Discord install's `resources` directory (the one - // holding app.asar). The json tag stays `corePath` so the existing Wails - // bindings and frontend keep working; a full frontend rename is deferred. - ResourcesPath string `json:"corePath"` + // holding app.asar). + ResourcesPath string `json:"resourcesPath"` Channel types.DiscordChannel `json:"channel"` Version string `json:"version"` IsFlatpak bool `json:"isFlatpak"` diff --git a/discord/paths_darwin.go b/discord/paths_darwin.go index 88ad3f6..ec6b536 100644 --- a/discord/paths_darwin.go +++ b/discord/paths_darwin.go @@ -31,3 +31,9 @@ func init() { func Validate(proposed string) *DiscordInstall { return validateUnixStyleInstall(proposed, false, false) } + +// DefaultBrowseDir is where the "browse for Discord" dialog should open: app +// bundles live in /Applications. +func DefaultBrowseDir() string { + return filepath.Join("/", "Applications") +} diff --git a/discord/paths_linux.go b/discord/paths_linux.go index d566425..83bd48e 100644 --- a/discord/paths_linux.go +++ b/discord/paths_linux.go @@ -66,3 +66,16 @@ func Validate(proposed string) *DiscordInstall { // Native Linux validation with Flatpak and Snap detection return validateUnixStyleInstall(proposed, true, true) } + +// DefaultBrowseDir is where the "browse for Discord" dialog should open. Under +// WSL that's the Windows user's %LOCALAPPDATA%; natively it's the config dir +// (~/.config), which is also where the new updater installs Discord. +func DefaultBrowseDir() string { + if wsl.IsWSL() { + if winHome, err := wsl.WindowsHome(); err == nil && winHome != "" { + return filepath.Join(winHome, "AppData", "Local") + } + } + config, _ := os.UserConfigDir() + return config +} diff --git a/discord/paths_windows.go b/discord/paths_windows.go index cb47ab8..b5c02c4 100644 --- a/discord/paths_windows.go +++ b/discord/paths_windows.go @@ -29,3 +29,13 @@ func init() { func Validate(proposed string) *DiscordInstall { return validateWindowsStyleInstall(proposed) } + +// DefaultBrowseDir is where the "browse for Discord" dialog should open: Discord +// installs under %LOCALAPPDATA%. +func DefaultBrowseDir() string { + if dir := os.Getenv("LOCALAPPDATA"); dir != "" { + return dir + } + config, _ := os.UserConfigDir() + return config +} diff --git a/frontend/src/lib/stores/state.svelte.ts b/frontend/src/lib/stores/state.svelte.ts index 11a96b4..e51dc99 100644 --- a/frontend/src/lib/stores/state.svelte.ts +++ b/frontend/src/lib/stores/state.svelte.ts @@ -5,7 +5,7 @@ import {GetDiscordPath} from "@api"; const app = $state({ eulaAgreed: false, action: "install", - corePaths: {stable: "", ptb: "", canary: ""}, + resourcePaths: {stable: "", ptb: "", canary: ""}, channels: {stable: false, ptb: false, canary: false}, options: { install: {restartDiscord: true}, @@ -28,8 +28,8 @@ try { for (const channel of channels) { // eslint-disable-next-line new-cap void GetDiscordPath(channel) - .then(path => app.corePaths[channel] = path) - .catch(() => {/* leave corePaths[channel] empty (e.g. not in Wails) */}); + .then(path => app.resourcePaths[channel] = path) + .catch(() => {/* leave resourcePaths[channel] empty (e.g. not in Wails) */}); } } catch { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index c4e081c..648c57e 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -48,7 +48,7 @@ export const createNavState = (state?: Partial) => { export interface AppState { eulaAgreed: boolean; action: InstallerAction; - corePaths: Record; + resourcePaths: Record; channels: Record; options: ActionOptionsMap; navigation: NavigationState; diff --git a/frontend/src/routes/actions/perform/[action]/+page.svelte b/frontend/src/routes/actions/perform/[action]/+page.svelte index d0c7a07..0ddb4bf 100644 --- a/frontend/src/routes/actions/perform/[action]/+page.svelte +++ b/frontend/src/routes/actions/perform/[action]/+page.svelte @@ -55,10 +55,10 @@ const currentAction = app.action; const installPaths: Partial> = {}; - for (const channelKey in app.corePaths) { + for (const channelKey in app.resourcePaths) { const channel = channelKey as DiscordChannel; if (!app.channels[channel]) continue; - installPaths[channel] = app.corePaths[channel]; + installPaths[channel] = app.resourcePaths[channel]; } let active = $state(true); diff --git a/frontend/src/routes/actions/setup/[action]/+page.svelte b/frontend/src/routes/actions/setup/[action]/+page.svelte index 12a69bf..952bfe6 100644 --- a/frontend/src/routes/actions/setup/[action]/+page.svelte +++ b/frontend/src/routes/actions/setup/[action]/+page.svelte @@ -14,7 +14,7 @@ const nextLabel = $derived(app.action[0].toUpperCase() + app.action.slice(1)); async function browseForChannel(platform: DiscordChannel) { const resourcesPath = await findDiscordDialog(platform); - app.corePaths[platform] = resourcesPath; + app.resourcePaths[platform] = resourcesPath; app.channels[platform] = Boolean(resourcesPath); } @@ -30,9 +30,9 @@ {#each Object.entries(labels) as [channel, label] (channel)} browseForChannel(channel as DiscordChannel)} - description={app.corePaths[channel as DiscordChannel] || "Not Found"} + description={app.resourcePaths[channel as DiscordChannel] || "Not Found"} bind:checked={app.channels[channel as DiscordChannel]} - disabled={!app.corePaths[channel as DiscordChannel]} + disabled={!app.resourcePaths[channel as DiscordChannel]} > {#snippet icon()} Platform Icon From 736caaa4814f7f2ad2068a8c5b153c812966c474 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 18 Jul 2026 06:11:20 -0400 Subject: [PATCH 08/20] fix: resolve injected folders --- README.md | 12 +++++++---- discord/injection_test.go | 39 +++++++++++++++++++++++++++++++++ discord/paths_common.go | 36 ++++++++++++++++++++----------- discord/paths_common_test.go | 42 ++++++++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 80055c4..77fbb2c 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,8 @@ This repository contains the source code for the BetterDiscord installer. The ap Linux install support: - Native Discord install: ✅ Supported -- Flatpak Discord install: ✅ Supported -- Snap Discord install: ❌ Unsupported due to upstream Snap packaging/runtime changes +- Flatpak Discord install: ✅ Supported for per-user installs; ⚠️ system-wide/global installs need elevated write access and aren't supported yet +- Snap Discord install: ❌ Unsupported — Snap mounts Discord's files read-only, so the installer can't modify the app ## Downloads @@ -85,11 +85,15 @@ xattr -d com.apple.quarantine "/Applications/BetterDiscord Installer.app" ### Does the installer support Flatpak Discord on Linux? -Yes. Flatpak Discord installs are supported. +Yes, for **per-user** Flatpak installs (the default `flatpak install --user …`). **System-wide/global** Flatpak installs live under `/var/lib/flatpak`, which is root-owned; the installer needs to write into the app to inject BetterDiscord, and it doesn't request elevation yet, so global Flatpak installs aren't supported for now. ### Why is Snap Discord unsupported on Linux? -Discord Snap packaging/runtime changes prevent the installer from supporting Snap installs. +Snap mounts Discord's application files as a read-only squashfs. The installer injects BetterDiscord by modifying files inside the Discord app, which isn't possible on a read-only mount — so Snap can't be supported. + +### How does the installer add BetterDiscord to Discord? + +It places a small loader inside Discord's own app files (a `resources/app` folder) and preserves Discord's original `app.asar` next to it. Because this loads before Discord's updater runs, BetterDiscord can keep itself injected across Discord updates — so you generally only need to run the installer once. Uninstalling restores Discord's original files. ### How can I use the global BetterDiscord folder with Flatpak? diff --git a/discord/injection_test.go b/discord/injection_test.go index 780fa33..48fbec1 100644 --- a/discord/injection_test.go +++ b/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: types.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: types.Stable} diff --git a/discord/paths_common.go b/discord/paths_common.go index 45633b2..ab0e1a8 100644 --- a/discord/paths_common.go +++ b/discord/paths_common.go @@ -34,9 +34,18 @@ 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 +74,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/discord/paths_common_test.go b/discord/paths_common_test.go index 3fb998c..0f9b735 100644 --- a/discord/paths_common_test.go +++ b/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 ae24cce95b6548c5982d25fb6ec07c146ffa2d78 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 18 Jul 2026 06:23:54 -0400 Subject: [PATCH 09/20] fix: reinject should always work --- discord/injection.go | 20 ++++++++++++++++++-- discord/injection_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/discord/injection.go b/discord/injection.go index ec7c705..d65342f 100644 --- a/discord/injection.go +++ b/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 { + log.Printf("❌ Unable to replace stale %s\n", preservedAsar) + log.Printf(" %s\n", err.Error()) + return err + } + } if err := os.Rename(originalAsar, preservedAsar); err != nil { log.Printf("❌ Unable to preserve app.asar in %s\n", resources) log.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/discord/injection_test.go b/discord/injection_test.go index 48fbec1..fd89114 100644 --- a/discord/injection_test.go +++ b/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: types.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: types.Stable} From 11daf313f0cc90b74a0a4e3b19340751be28bd58 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 18 Jul 2026 07:39:35 -0400 Subject: [PATCH 10/20] fix: add additional sanity checks --- discord/injection.go | 38 +++++++++++++++++++++++++++++--------- discord/injection_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/discord/injection.go b/discord/injection.go index d65342f..6be5fc4 100644 --- a/discord/injection.go +++ b/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. + log.Printf("❌ Rollback failed: unable to restore app.asar in %s\n", resources) + log.Printf(" %s\n", err.Error()) + } } } @@ -138,14 +149,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 { log.Printf("❌ Unable to restore app.asar in %s\n", resources) log.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 { + log.Printf("⚠️ Unable to remove stale %s\n", preservedAsar) + log.Printf(" %s\n", err.Error()) + } } log.Printf("✅ Removed from %s\n", discord.Channel.Name()) diff --git a/discord/injection_test.go b/discord/injection_test.go index fd89114..e98fad8 100644 --- a/discord/injection_test.go +++ b/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: types.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: types.Stable} From d72bed76dbe9ad654d19be9820d0b3320c9efff5 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 18 Jul 2026 08:20:18 -0400 Subject: [PATCH 11/20] fix: add protection for empty resources dir --- discord/injection.go | 22 ++++++++++++------ discord/injection_test.go | 47 +++++++++++++++++++++++++++++++++++++++ discord/paths_common.go | 4 ++++ discord/paths_linux.go | 5 ++++- 4 files changed, 70 insertions(+), 8 deletions(-) diff --git a/discord/injection.go b/discord/injection.go index 6be5fc4..091df28 100644 --- a/discord/injection.go +++ b/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 { log.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. log.Printf("❌ Rollback failed: unable to restore app.asar in %s\n", resources) log.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") @@ -176,6 +181,9 @@ 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 + if resources == "" { + return false + } return utils.Exists(filepath.Join(resources, "app", "index.js")) && utils.Exists(filepath.Join(resources, "betterdiscord.app.asar")) } diff --git a/discord/injection_test.go b/discord/injection_test.go index e98fad8..dedd190 100644 --- a/discord/injection_test.go +++ b/discord/injection_test.go @@ -245,6 +245,53 @@ func TestInject_NoAppAsarErrors(t *testing.T) { } } +func TestInject_EmptyResourcesPathErrors(t *testing.T) { + install := &DiscordInstall{ResourcesPath: "", Channel: types.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: types.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: types.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/discord/paths_common.go b/discord/paths_common.go index ab0e1a8..62aefd6 100644 --- a/discord/paths_common.go +++ b/discord/paths_common.go @@ -82,6 +82,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 diff --git a/discord/paths_linux.go b/discord/paths_linux.go index 83bd48e..d72a4f9 100644 --- a/discord/paths_linux.go +++ b/discord/paths_linux.go @@ -76,6 +76,9 @@ func DefaultBrowseDir() string { return filepath.Join(winHome, "AppData", "Local") } } - config, _ := os.UserConfigDir() + config, err := os.UserConfigDir() + if err != nil { + return os.Getenv("HOME") + } return config } From 4204f7368e9e6a50f1e330ed9e9841bb537e8363 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 18 Jul 2026 08:53:32 -0400 Subject: [PATCH 12/20] chore: update injection js --- discord/assets/app_index.js | 10 +++++++++- discord/injection.go | 6 +++--- discord/paths_common.go | 5 ++++- discord/paths_common_test.go | 18 ++++++++++++++++++ 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/discord/assets/app_index.js b/discord/assets/app_index.js index d92860e..7835c85 100644 --- a/discord/assets/app_index.js +++ b/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"); diff --git a/discord/injection.go b/discord/injection.go index 091df28..4e708b2 100644 --- a/discord/injection.go +++ b/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/discord/paths_common.go b/discord/paths_common.go index 62aefd6..5b99411 100644 --- a/discord/paths_common.go +++ b/discord/paths_common.go @@ -169,7 +169,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/discord/paths_common_test.go b/discord/paths_common_test.go index 0f9b735..3ea47ff 100644 --- a/discord/paths_common_test.go +++ b/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 943f6ebef68fc39928f498b9dc2299f386dc2ece Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 18 Jul 2026 09:13:43 -0400 Subject: [PATCH 13/20] fix: more edge cases --- discord/assets/app_index.js | 23 ++++++++--------- discord/paths.go | 9 ++++--- discord/paths_common.go | 26 ++++++++++++++----- discord/paths_common_test.go | 48 +++++++++++++++++++++++++++--------- discord/paths_test.go | 15 +++++++++++ 5 files changed, 90 insertions(+), 31 deletions(-) diff --git a/discord/assets/app_index.js b/discord/assets/app_index.js index 7835c85..72b784b 100644 --- a/discord/assets/app_index.js +++ b/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/discord/paths.go b/discord/paths.go index b15c7c1..a8f9ecf 100644 --- a/discord/paths.go +++ b/discord/paths.go @@ -38,10 +38,13 @@ func GetVersion(proposed string) string { func GetChannel(proposed string) types.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 types.Channels { if normalized == strings.ReplaceAll(strings.ToLower(channel.Name()), " ", "") { return channel diff --git a/discord/paths_common.go b/discord/paths_common.go index 5b99411..a0d8ef4 100644 --- a/discord/paths_common.go +++ b/discord/paths_common.go @@ -48,8 +48,11 @@ func hasDiscordApp(dir string) bool { 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 { @@ -66,6 +69,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 } @@ -74,6 +80,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: @@ -169,10 +186,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/discord/paths_common_test.go b/discord/paths_common_test.go index 3ea47ff..035834b 100644 --- a/discord/paths_common_test.go +++ b/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/discord/paths_test.go b/discord/paths_test.go index 7c35f6f..1c10eb7 100644 --- a/discord/paths_test.go +++ b/discord/paths_test.go @@ -80,6 +80,21 @@ func TestGetChannel(t *testing.T) { path: filepath.Join("/Applications", "Discord.app", "Contents", "Resources"), expected: types.Stable, }, + { + name: "flatpak dashed canary dir", + path: filepath.Join("/var", "lib", "flatpak", "app", "com.discordapp.DiscordCanary", "current", "active", "files", "discord-canary", "resources"), + expected: types.Canary, + }, + { + name: "flatpak dashed ptb dir", + path: filepath.Join("/var", "lib", "flatpak", "app", "com.discordapp.DiscordPTB", "current", "active", "files", "discord-ptb", "resources"), + expected: types.PTB, + }, + { + name: "flatpak stable dir", + path: filepath.Join("/var", "lib", "flatpak", "app", "com.discordapp.Discord", "current", "active", "files", "discord", "resources"), + expected: types.Stable, + }, } for _, tt := range tests { From 128b0f622a2dd5f0997b474f159f476801e5b141 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 18 Jul 2026 12:35:54 -0400 Subject: [PATCH 14/20] fix: kill discord before inject --- discord/install.go | 54 +++++++++++++++++++++++++++++++++++++++------- discord/process.go | 34 ++++++++++++++++++++--------- 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/discord/install.go b/discord/install.go index 45144e8..23907e9 100644 --- a/discord/install.go +++ b/discord/install.go @@ -43,7 +43,15 @@ func (discord *DiscordInstall) InstallBD(options types.InstallOptions) error { log.Println("✅ BetterDiscord downloaded") log.Println("") - // 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 + } + log.Println("") + + // Shadow app.asar with our loader log.Println("🔌 Injecting into Discord...") if err := discord.inject(bd); err != nil { return err @@ -51,10 +59,10 @@ func (discord *DiscordInstall) InstallBD(options types.InstallOptions) error { log.Println("✅ Injection successful") log.Println("") - 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 { log.Printf("🔄 Restarting %s...\n", discord.Channel.Name()) - if err := discord.restart(); err != nil { + if err := discord.start(exe); err != nil { return err } log.Println("") @@ -65,6 +73,13 @@ func (discord *DiscordInstall) InstallBD(options types.InstallOptions) error { // UninstallBD removes BetterDiscord from this Discord installation func (discord *DiscordInstall) UninstallBD(options types.UninstallOptions) error { + // Discord locks app.asar while running; stop it before reverting the injection. + exe, wasRunning, err := discord.stop() + if err != nil { + return err + } + log.Println("") + log.Println("🧹 Removing injection...") if err := discord.uninject(); err != nil { return err @@ -82,9 +97,9 @@ func (discord *DiscordInstall) UninstallBD(options types.UninstallOptions) error log.Println("") } - if options.RestartDiscord { + if options.RestartDiscord && wasRunning { log.Printf("🔄 Restarting %s...\n", discord.Channel.Name()) - if err := discord.restart(); err != nil { + if err := discord.start(exe); err != nil { return err } log.Println("") @@ -93,11 +108,22 @@ func (discord *DiscordInstall) UninstallBD(options types.UninstallOptions) error 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 types.RepairOptions) error { - if err := discord.UninstallBD(types.UninstallOptions{FullUninstall: 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 } + log.Println("") + + log.Println("🧹 Removing injection...") + if err := discord.uninject(); err != nil { + return err + } + log.Println("") bd, err := discord.GetBetterDiscordInstall() if err != nil { @@ -107,6 +133,18 @@ func (discord *DiscordInstall) RepairBD(options types.RepairOptions) error { if err := bd.Repair(discord.Channel, options); err != nil { return err } + log.Println("") + + // 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 { + log.Printf("🔄 Restarting %s...\n", discord.Channel.Name()) + if err := discord.start(exe); err != nil { + return err + } + log.Println("") + } return nil } diff --git a/discord/process.go b/discord/process.go index e8e5a9c..7c4f03f 100644 --- a/discord/process.go +++ b/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 { - log.Printf("✅ %s is not running; skipping restart.\n", discord.Channel.Name()) - return nil + log.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 { - log.Printf("❌ Unable to restart %s, please do so manually.\n", discord.Channel.Name()) + log.Printf("❌ Unable to stop %s. Please close it and try again.\n", discord.Channel.Name()) log.Printf(" %s\n", err.Error()) - return err + return exe, true, err } + log.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 == "" { log.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 73d7e6a5fc1476bfb473f272d91d0b307936c602 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 18 Jul 2026 12:59:32 -0400 Subject: [PATCH 15/20] yet more sanity checking --- discord/assets/app_index.js | 6 ++++-- discord/install_test.go | 6 +++--- discord/process.go | 11 ++++++++++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/discord/assets/app_index.js b/discord/assets/app_index.js index 72b784b..c2e06bc 100644 --- a/discord/assets/app_index.js +++ b/discord/assets/app_index.js @@ -13,8 +13,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/discord/install_test.go b/discord/install_test.go index 54f5ca1..51f714f 100644 --- a/discord/install_test.go +++ b/discord/install_test.go @@ -9,9 +9,9 @@ import ( "installer/types" ) -// 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/discord/process.go b/discord/process.go index 7c4f03f..2f01afe 100644 --- a/discord/process.go +++ b/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 { + log.Printf("❌ Unable to determine whether %s is running. Please close it and try again.\n", discord.Channel.Name()) + log.Printf(" %s\n", err.Error()) + return "", false, err + } + if !running { log.Printf("✅ %s is not running.\n", discord.Channel.Name()) return "", false, nil } From a409e6644eb3dce5a0ef664f296c652cef531ae3 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 18 Jul 2026 13:39:39 -0400 Subject: [PATCH 16/20] fix: add note for wsl and kill timeout --- README.md | 4 ++++ discord/injection.go | 4 +++- discord/process.go | 34 ++++++++++++++++++++++++++++++++-- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 77fbb2c..7c3ffd4 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,10 @@ Snap mounts Discord's application files as a read-only squashfs. The installer i It places a small loader inside Discord's own app files (a `resources/app` folder) and preserves Discord's original `app.asar` next to it. Because this loads before Discord's updater runs, BetterDiscord can keep itself injected across Discord updates — so you generally only need to run the installer once. Uninstalling restores Discord's original files. +### I'm running the installer under WSL — do I need to do anything special? + +Yes: **fully close Discord before installing, repairing, or uninstalling.** WSL support targets a Windows Discord install, but the installer can't see or manage the Windows Discord process from the Linux side, so it can't stop Discord for you. If Discord is still running it holds a lock on `app.asar` and the operation will fail — close Discord and try again. (For headless or CLI-based workflows, the BetterDiscord CLI is a better fit.) + ### How can I use the global BetterDiscord folder with Flatpak? 1. Grant the Flatpak app access to the BetterDiscord config directory: diff --git a/discord/injection.go b/discord/injection.go index 4e708b2..ab650e8 100644 --- a/discord/injection.go +++ b/discord/injection.go @@ -77,7 +77,8 @@ func (discord *DiscordInstall) inject(bd *betterdiscord.BDInstall) error { } } if err := os.Rename(originalAsar, preservedAsar); err != nil { - log.Printf("❌ Unable to preserve app.asar in %s\n", resources) + log.Printf("❌ Unable to modify app.asar in %s\n", resources) + log.Printf(" Discord may still be running, please fully close it and try again.\n") log.Printf(" %s\n", err.Error()) return err } @@ -159,6 +160,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 { log.Printf("❌ Unable to restore app.asar in %s\n", resources) + log.Printf(" Discord may still be running — please fully close it and try again.\n") log.Printf(" %s\n", err.Error()) return err } diff --git a/discord/process.go b/discord/process.go index 2f01afe..f6bdaa1 100644 --- a/discord/process.go +++ b/discord/process.go @@ -5,10 +5,17 @@ import ( "log" "os" "os/exec" + "time" "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() @@ -127,11 +135,33 @@ func (discord *DiscordInstall) kill() error { if killErr != nil { return killErr } + signaled = true } } - // 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 275a529db78ee7acf263375b7417ee313479ba6e Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 18 Jul 2026 13:55:45 -0400 Subject: [PATCH 17/20] fix: more protection edge cases --- discord/injection.go | 15 +++++++++++++++ discord/paths.go | 9 +++++++-- discord/paths_darwin.go | 8 ++++++-- discord/paths_linux.go | 32 ++++++++++++++++++++------------ 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/discord/injection.go b/discord/injection.go index ab650e8..ae9b775 100644 --- a/discord/injection.go +++ b/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 { + log.Printf("❌ Snap installs are not supported\n") + log.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 { + log.Printf("❌ Snap installs are not supported\n") + log.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/discord/paths.go b/discord/paths.go index a8f9ecf..2363a7e 100644 --- a/discord/paths.go +++ b/discord/paths.go @@ -37,11 +37,16 @@ func GetVersion(proposed string) string { } func GetChannel(proposed string) types.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/discord/paths_darwin.go b/discord/paths_darwin.go index ec6b536..382bc1e 100644 --- a/discord/paths_darwin.go +++ b/discord/paths_darwin.go @@ -8,14 +8,18 @@ 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 types.Channels { diff --git a/discord/paths_linux.go b/discord/paths_linux.go index d72a4f9..49336e1 100644 --- a/discord/paths_linux.go +++ b/discord/paths_linux.go @@ -10,24 +10,32 @@ import ( ) func init() { - config, _ := os.UserConfigDir() - home, _ := os.UserHomeDir() + 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`. + // 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"), + + // 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. + } + + // Only search config/home-relative locations when those dirs resolved; + // otherwise the joins would produce relative paths anchored at the current + // working directory. + if errConfig == nil && config != "" { // 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}"), - - // 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"), - + paths = append(paths, filepath.Join(config, "{channel}")) + } + if errHome == nil && home != "" { // 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. + paths = append(paths, filepath.Join(home, ".local", "share", "flatpak", "app", "com.discordapp.{CHANNEL}", "current", "active", "files", "{channel-}", "resources")) } if wsl.IsWSL() { From 8d967226116fb375cc0d99b82c70318610736b6e Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 18 Jul 2026 14:40:01 -0400 Subject: [PATCH 18/20] chore: more protection for snap installs --- discord/injection.go | 37 ++++++++++++++++++++++++------------- discord/install.go | 16 ++++++++++++++++ 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/discord/injection.go b/discord/injection.go index ae9b775..267bac9 100644 --- a/discord/injection.go +++ b/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 + } + log.Printf("❌ Snap installs are not supported\n") + log.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,13 +60,11 @@ 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 { - log.Printf("❌ Snap installs are not supported\n") - log.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") @@ -151,12 +163,11 @@ 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 { - log.Printf("❌ Snap installs are not supported\n") - log.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") diff --git a/discord/install.go b/discord/install.go index 23907e9..ebdff73 100644 --- a/discord/install.go +++ b/discord/install.go @@ -22,6 +22,12 @@ type DiscordInstall struct { // InstallBD installs BetterDiscord into this Discord installation func (discord *DiscordInstall) InstallBD(options types.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 @@ -73,6 +79,11 @@ func (discord *DiscordInstall) InstallBD(options types.InstallOptions) error { // UninstallBD removes BetterDiscord from this Discord installation func (discord *DiscordInstall) UninstallBD(options types.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 types.UninstallOptions) error // injection and cleans the requested data files, leaving BetterDiscord // uninstalled; the caller then offers to reinstall. func (discord *DiscordInstall) RepairBD(options types.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 dde926606f4c0023fada203fe25258aafa06c89e Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 18 Jul 2026 19:09:05 -0400 Subject: [PATCH 19/20] fix: edge case handling --- discord/injection.go | 27 +++++++++++++++++++++------ discord/paths.go | 5 ++++- discord/process.go | 6 ++++-- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/discord/injection.go b/discord/injection.go index 267bac9..a7357b3 100644 --- a/discord/injection.go +++ b/discord/injection.go @@ -173,14 +173,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 { - log.Printf("❌ Unable to remove %s\n", appDir) - log.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) { + log.Printf("ℹ️ No injection found in %s\n", discord.Channel.Name()) + return nil } + // 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. @@ -201,6 +205,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 { + log.Printf("❌ Unable to remove %s\n", appDir) + log.Printf(" %s\n", err.Error()) + return err + } + } + log.Printf("✅ Removed from %s\n", discord.Channel.Name()) return nil } diff --git a/discord/paths.go b/discord/paths.go index 2363a7e..d0e8668 100644 --- a/discord/paths.go +++ b/discord/paths.go @@ -41,7 +41,10 @@ func GetChannel(proposed string) types.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 // flatpak channel dirs ("discord-canary") both match the channel names diff --git a/discord/process.go b/discord/process.go index f6bdaa1..887d0d3 100644 --- a/discord/process.go +++ b/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 1fbaa809af5c36b72b53b61fc31eb4635e9d9b55 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 18 Jul 2026 19:37:09 -0400 Subject: [PATCH 20/20] fix: cleanly resolve flaky enumeration --- discord/process.go | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/discord/process.go b/discord/process.go index 887d0d3..353f14e 100644 --- a/discord/process.go +++ b/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) @@ -152,14 +154,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)