diff --git a/README.md b/README.md index 80055c4..7c3ffd4 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,19 @@ 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. + +### 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? diff --git a/api/controller.go b/api/controller.go index 412777b..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, }) @@ -139,7 +132,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/assets/app_index.js b/discord/assets/app_index.js new file mode 100644 index 0000000..c2e06bc --- /dev/null +++ b/discord/assets/app_index.js @@ -0,0 +1,29 @@ +// BetterDiscord's Injection Script (app.asar method) +const path = require("path"); +const electron = require("electron"); + +// 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. +// 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. + // 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")); +} +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/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"} diff --git a/discord/assets/injection.js b/discord/assets/injection.js deleted file mode 100644 index 55f2f3b..0000000 --- a/discord/assets/injection.js +++ /dev/null @@ -1,18 +0,0 @@ -// BetterDiscord's Injection Script -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 -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")); - -// Discord's Default Export -module.exports = require("./core.asar"); \ No newline at end of file diff --git a/discord/injection.go b/discord/injection.go index 00c72e0..a7357b3 100644 --- a/discord/injection.go +++ b/discord/injection.go @@ -2,61 +2,231 @@ 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 + +// 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. +// A unique name (os.CreateTemp) avoids colliding with or clobbering an existing +// file and is safe under concurrent probes. +func probeWritable(dir string) error { + f, err := os.CreateTemp(dir, ".bd-write-probe-*") + if err != nil { + return err + } + // 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 +// 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 + if resources == "" { + return fmt.Errorf("cannot inject: resources path is empty") + } + // Backstop: the install flow rejects Snap before stopping Discord, but guard + // here too so any direct caller gets the same actionable error rather than the + // generic permission failure the writability probe would raise below. + if err := discord.errIfSnap(); err != nil { + return err + } + originalAsar := filepath.Join(resources, "app.asar") + preservedAsar := filepath.Join(resources, "betterdiscord.app.asar") + appDir := filepath.Join(resources, "app") - // 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) + // 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 } - log.Printf("✅ Injected into %s\n", discord.CorePath) + // 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. + switch { + 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 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 + } + 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) + } + + // 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: 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) { + if err := os.Rename(preservedAsar, originalAsar); err != nil { + log.Printf("❌ Rollback failed: unable to restore app.asar in %s\n", resources) + log.Printf(" %s\n", err.Error()) + } + } + } + + 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 + } + + 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 + } + + 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") + resources := discord.ResourcesPath + if resources == "" { + return fmt.Errorf("cannot uninject: resources path is empty") + } + // Backstop for direct callers; the uninstall/repair flows reject Snap before + // stopping Discord. Snap installs are never injectable, so there's nothing to + // revert — report it explicitly rather than attempting filesystem mutations. + if err := discord.errIfSnap(); err != nil { + return err + } + originalAsar := filepath.Join(resources, "app.asar") + preservedAsar := filepath.Join(resources, "betterdiscord.app.asar") + appDir := filepath.Join(resources, "app") - contents, err := os.ReadFile(indexFile) + // 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 + } - // 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 + // 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. + 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 + } + 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()) } } - 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 + // 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()) + 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 { + resources := discord.ResourcesPath + if resources == "" { return false } - lower := strings.ToLower(string(contents)) - return strings.Contains(lower, "betterdiscord") + 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 6b4ab25..dedd190 100644 --- a/discord/injection_test.go +++ b/discord/injection_test.go @@ -1,150 +1,392 @@ 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 utils.Exists(filepath.Join(resources, "app.asar")) { + t.Error("expected original app.asar to be renamed away") + } + preserved, err := os.ReadFile(filepath.Join(resources, "betterdiscord.app.asar")) + if err != nil { + 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") + } if !install.IsInjected() { - t.Fatal("expected IsInjected() to be true after inject()") + t.Error("expected IsInjected true after inject()") } - info, err := os.Stat(filepath.Join(corePath, "index.js")) - if err != nil { - t.Fatalf("index.js not written: %v", err) + // 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 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) + } + + 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) } - // 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) - } + 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_RemovesInjection(t *testing.T) { - corePath := t.TempDir() - install := &DiscordInstall{CorePath: corePath, Channel: types.Stable} - indexFile := filepath.Join(corePath, "index.js") +// 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) + } - 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) + // 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} + + 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") - - 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 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() failed: %v", err) + t.Fatalf("uninject: %v", err) } - contents, _ := os.ReadFile(indexFile) - if string(contents) != original { - t.Errorf("uninject rewrote a file with no BetterDiscord marker: got %q", string(contents)) + 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_MissingFileWritesDefault(t *testing.T) { - corePath := t.TempDir() - install := &DiscordInstall{CorePath: corePath, Channel: types.Stable} +func TestUninject_NotInjectedIsNoop(t *testing.T) { + 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) + t.Fatalf("uninject() on a clean install 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) + } +} + +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 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") } - contents, err := os.ReadFile(filepath.Join(corePath, "index.js")) + // 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("expected index.js to be created: %v", err) + t.Fatalf("app.asar not restored after rollback: %v", err) } - if string(contents) != defaultIndexJS { - t.Errorf("index.js = %q, expected %q", string(contents), defaultIndexJS) + 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 TestInjectUninject_RoundTrip(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} + + 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") + } + // 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("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 utils.Exists(filepath.Join(resources, "betterdiscord.app.asar")) { + t.Error("no rename should have happened after a probe failure") + } +} + +// 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() failed: %v", err) + t.Fatalf("inject: %v", err) } - if !install.IsInjected() { - t.Fatal("expected injected after inject()") + + // 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 err := install.uninject(); err != nil { - t.Fatalf("uninject() failed: %v", err) + if resolved.ResourcesPath != resources { + t.Errorf("ResourcesPath = %s, expected %s", resolved.ResourcesPath, resources) } - if install.IsInjected() { - t.Fatal("expected not injected after uninject()") + 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} + + // 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 err := install.inject(nil); err == nil { + t.Fatal("expected inject to fail when app/ can't be created") + } + + // 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 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.go b/discord/install.go index e41cc73..ebdff73 100644 --- a/discord/install.go +++ b/discord/install.go @@ -2,22 +2,32 @@ package discord import ( "log" + "os" + "path/filepath" + "strings" "installer/betterdiscord" "installer/types" - "installer/utils" ) 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). + ResourcesPath string `json:"resourcesPath"` + Channel types.DiscordChannel `json:"channel"` + Version string `json:"version"` + IsFlatpak bool `json:"isFlatpak"` + IsSnap bool `json:"isSnap"` } // 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 @@ -39,7 +49,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 @@ -47,10 +65,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("") @@ -61,6 +79,18 @@ 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 { + return err + } + log.Println("") + log.Println("🧹 Removing injection...") if err := discord.uninject(); err != nil { return err @@ -78,9 +108,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("") @@ -89,11 +119,27 @@ 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 { + // 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 { 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 { @@ -103,6 +149,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 } @@ -111,17 +169,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.CorePath, 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 ea4dd49..51f714f 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 -// 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) { - 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 { @@ -46,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{CorePath: 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") - } - 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") + t.Skipf("no home dir: %v", err) } - // 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} - bd, err := install.GetBetterDiscordInstall() - if err != nil { - t.Fatalf("unexpected error: %v", err) + cases := []struct { + channel types.DiscordChannel + id string + }{ + {types.Stable, "com.discordapp.Discord"}, + {types.Canary, "com.discordapp.DiscordCanary"}, + {types.PTB, "com.discordapp.DiscordPTB"}, } - 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{CorePath: "/no/matching/segment/here", Channel: types.Stable, IsFlatpak: true} + 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.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 ea8d7a0..d0e8668 100644 --- a/discord/paths.go +++ b/discord/paths.go @@ -37,9 +37,24 @@ 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`). + // 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 + // ("discordcanary"). + normalized := strings.ToLower(segments[i]) + normalized = strings.TrimSuffix(normalized, ".app") + normalized = strings.ReplaceAll(normalized, " ", "") + normalized = strings.ReplaceAll(normalized, "-", "") 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 } } @@ -49,7 +64,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 +76,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 +90,7 @@ func AddCustomPath(proposed string) *DiscordInstall { func ResolvePath(proposed string) *DiscordInstall { for channel := range allDiscordInstalls { - index := slices.IndexFunc(allDiscordInstalls[channel], func(d *DiscordInstall) bool { return d.CorePath == proposed }) + index := slices.IndexFunc(allDiscordInstalls[channel], func(d *DiscordInstall) bool { return d.ResourcesPath == proposed }) if index >= 0 { return allDiscordInstalls[channel][index] } diff --git a/discord/paths_common.go b/discord/paths_common.go index b2ef324..a0d8ef4 100644 --- a/discord/paths_common.go +++ b/discord/paths_common.go @@ -3,216 +3,191 @@ 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") - } +// 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")) +} - if selected == "discord_desktop_core" { - finalPath = proposed +// 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 { + 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 !hasDiscordApp(filepath.Join(base, entry.Name(), "resources")) { + 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 - } +// 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) +} - candidates := utils.Filter(channelPaths, func(file fs.DirEntry) bool { - return file.IsDir() && strings.HasPrefix(file.Name(), "discord") - }) +// 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 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 "" + } - if len(candidates) == 0 { - return nil - } + // The proposed path is already the resources dir (or macOS Contents/Resources). + if hasDiscordApp(proposed) { + return proposed + } - // 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 - } - - 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"); hasDiscordApp(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") - }) + // A dir with a direct `resources` child (flatpak files/{channel-}). + if res := filepath.Join(proposed, "resources"); hasDiscordApp(res) { + return res + } - if len(candidates) == 0 { - return nil - } + // A macOS app bundle: app.asar lives in Contents/Resources. + if res := filepath.Join(proposed, "Contents", "Resources"); hasDiscordApp(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"); hasDiscordApp(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 = isSnapPath(resources) } - return nil + return install } diff --git a/discord/paths_common_test.go b/discord/paths_common_test.go index b61e6e2..035834b 100644 --- a/discord/paths_common_test.go +++ b/discord/paths_common_test.go @@ -4,149 +4,320 @@ 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) { +// 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") - versionDir := filepath.Join(root, "app-1.0.9002") - coreWrap := filepath.Join(versionDir, "modules", "discord_desktop_core-1", "discord_desktop_core") + resources := filepath.Join(root, "app-1.0.9002", "resources") + writeInjectedResources(t, resources) // no app.asar, only betterdiscord.app.asar - writeCoreAsar(t, coreWrap) + 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") + 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) + + 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) + } +} - writeCoreAsar(t, coreWrap) +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") - - writeCoreAsar(t, corePath) + // Flatpak deployment layout: files/{channel-}/resources (no app-* segment). + resources := filepath.Join(tmpDir, "com.discordapp.Discord", "files", "discord", "resources") + writeAppAsar(t, resources) - 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) { +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 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, "snap", "discord", "current", ".config", "discord") - corePath := filepath.Join(root, "0.0.35", "modules", "discord_desktop_core") + root := filepath.Join(tmpDir, "Discord") - writeCoreAsar(t, corePath) + valid := filepath.Join(root, "app-1.0.9002", "resources") + writeAppAsar(t, valid) - result := validateUnixStyleInstall(root, false, true) + // 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) + } + + result := validateWindowsStyleInstall(root) if result == nil { - t.Fatalf("Expected install for %s", root) + 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) + } +} + +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") + } + }) +} + +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) + } + + install := newResourcesInstall(resources) + if install.Channel != types.Canary { + t.Errorf("Channel = %v, expected Canary from build_info", install.Channel) } - if !result.IsSnap { - t.Fatalf("Expected snap detection") + if install.Version != "1.0.5" { + t.Errorf("Version = %q, expected 1.0.5 from build_info", install.Version) + } +} + +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) } } diff --git a/discord/paths_darwin.go b/discord/paths_darwin.go index 7931f3e..382bc1e 100644 --- a/discord/paths_darwin.go +++ b/discord/paths_darwin.go @@ -3,24 +3,29 @@ package discord import ( "os" "path/filepath" - "strings" "installer/types" ) func init() { - config, _ := os.UserConfigDir() - paths := []string{ - filepath.Join(config, "{channel}"), + 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"), + } + // 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 { - 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)) } } @@ -30,3 +35,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 def3fd1..49336e1 100644 --- a/discord/paths_linux.go +++ b/discord/paths_linux.go @@ -10,36 +10,40 @@ 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{ - // Native. Data is stored 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`. - filepath.Join(config, "{channel}"), + filepath.Join("/var", "lib", "flatpak", "app", "com.discordapp.{CHANNEL}", "current", "active", "files", "{channel-}", "resources"), - // 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}"), + // 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. + } - // 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}"), + // 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`. + 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`. + paths = append(paths, filepath.Join(home, ".local", "share", "flatpak", "app", "com.discordapp.{CHANNEL}", "current", "active", "files", "{channel-}", "resources")) } 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}")) } } @@ -70,3 +74,19 @@ 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, err := os.UserConfigDir() + if err != nil { + return os.Getenv("HOME") + } + return config +} diff --git a/discord/paths_test.go b/discord/paths_test.go index 1ed26a3..1c10eb7 100644 --- a/discord/paths_test.go +++ b/discord/paths_test.go @@ -70,6 +70,31 @@ 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, + }, + { + 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 { 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/discord/process.go b/discord/process.go index e8e5a9c..353f14e 100644 --- a/discord/process.go +++ b/discord/process.go @@ -5,24 +5,54 @@ import ( "log" "os" "os/exec" + "time" "github.com/shirou/gopsutil/v3/process" ) -func (discord *DiscordInstall) restart() error { - exeName := discord.getFullExe() - - if running, _ := discord.isRunning(); !running { - log.Printf("✅ %s is not running; skipping restart.\n", discord.Channel.Name()) - return nil +// 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 +// 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 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 } + // 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 +60,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 @@ -54,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) @@ -82,12 +114,15 @@ 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) + signaled := false for _, p := range processes { n, err := p.Name() @@ -104,11 +139,51 @@ 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. 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 { + 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) + } } func (discord *DiscordInstall) getFullExe() string { 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