Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -85,11 +85,15 @@ xattr -d com.apple.quarantine "/Applications/BetterDiscord Installer.app"

### Does the installer support Flatpak Discord on Linux?

Yes. Flatpak Discord installs are supported.
Yes, for **per-user** Flatpak installs (the default `flatpak install --user …`). **System-wide/global** Flatpak installs live under `/var/lib/flatpak`, which is root-owned; the installer needs to write into the app to inject BetterDiscord, and it doesn't request elevation yet, so global Flatpak installs aren't supported for now.

### Why is Snap Discord unsupported on Linux?

Discord Snap packaging/runtime changes prevent the installer from supporting Snap installs.
Snap mounts Discord's application files as a read-only squashfs. The installer injects BetterDiscord by modifying files inside the Discord app, which isn't possible on a read-only mount — so Snap can't be supported.

### How does the installer add BetterDiscord to Discord?

It places a small loader inside Discord's own app files (a `resources/app` folder) and preserves Discord's original `app.asar` next to it. Because this loads before Discord's updater runs, BetterDiscord can keep itself injected across Discord updates — so you generally only need to run the installer once. Uninstalling restores Discord's original files.

### How can I use the global BetterDiscord folder with Flatpak?

Expand Down
29 changes: 11 additions & 18 deletions api/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"fmt"
"log"
"os"

"installer/discord"
"installer/types"
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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,
})
Expand All @@ -139,7 +132,7 @@ func (d *Controller) BrowseForDiscord(schannel string) string {
}

if result := discord.ResolvePath(selection); result != nil {
return result.CorePath
return result.ResourcesPath
}
Comment thread
zerebos marked this conversation as resolved.

return ""
Expand Down
29 changes: 29 additions & 0 deletions discord/assets/app_index.js
Original file line number Diff line number Diff line change
@@ -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");
}
Comment thread
zerebos marked this conversation as resolved.

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");
1 change: 1 addition & 0 deletions discord/assets/app_package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"main": "./index.js"}
18 changes: 0 additions & 18 deletions discord/assets/injection.js

This file was deleted.

181 changes: 154 additions & 27 deletions discord/injection.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,61 +2,188 @@ package discord

import (
_ "embed"
"fmt"
"log"
"os"
"path/filepath"
"strings"

"installer/betterdiscord"
"installer/utils"
)

//go:embed assets/injection.js
var injectionScript string
//go:embed assets/app_index.js
var appIndexScript string

//go:embed assets/app_package.json
var appPackageJSON string

// probeWritable verifies dir accepts writes before we perform any destructive
// operation, by creating and removing a 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
}
Comment thread
zerebos marked this conversation as resolved.

// 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")
}
originalAsar := filepath.Join(resources, "app.asar")
preservedAsar := filepath.Join(resources, "betterdiscord.app.asar")
appDir := filepath.Join(resources, "app")
Comment thread
zerebos marked this conversation as resolved.

Comment thread
zerebos marked this conversation as resolved.
// Probe writability before the destructive rename so we never leave a
// half-modified bundle on a read-only/permission-denied target.
if err := probeWritable(resources); err != nil {
log.Printf("❌ Cannot write to %s\n", resources)
log.Printf(" %s\n", err.Error())
return err
}

// Preserve the original app.asar (idempotent, guarded).
//
// 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 preserve app.asar in %s\n", resources)
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())
}
}
}
Comment thread
zerebos marked this conversation as resolved.

// 0o644: index.js is a require target, it doesn't need the executable bit
// (matches the mode uninject writes).
if err := os.WriteFile(filepath.Join(discord.CorePath, "index.js"), []byte(injectionScript), 0o644); err != nil {
log.Printf("❌ Unable to write index.js in %s\n", discord.CorePath)
if err := os.MkdirAll(appDir, 0755); err != nil {
log.Printf("❌ Unable to create %s\n", appDir)
log.Printf(" %s\n", err.Error())
rollback()
return err
}

log.Printf("✅ Injected into %s\n", discord.CorePath)
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")

contents, err := os.ReadFile(indexFile)
resources := discord.ResourcesPath
if resources == "" {
return fmt.Errorf("cannot uninject: resources path is empty")
}
originalAsar := filepath.Join(resources, "app.asar")
preservedAsar := filepath.Join(resources, "betterdiscord.app.asar")
appDir := filepath.Join(resources, "app")
Comment thread
zerebos marked this conversation as resolved.
Comment thread
zerebos marked this conversation as resolved.

// First try to check the file, but if there's an issue we try to blindly overwrite below
if err == nil {
if !strings.Contains(strings.ToLower(string(contents)), "betterdiscord") {
log.Printf("✅ No injection found for %s\n", discord.Channel.Name())
return nil
if utils.Exists(appDir) {
if err := os.RemoveAll(appDir); err != nil {
log.Printf("❌ Unable to remove %s\n", appDir)
log.Printf(" %s\n", err.Error())
return err
}
}
Comment thread
zerebos marked this conversation as resolved.
Outdated

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
switch {
case utils.Exists(preservedAsar) && !utils.Exists(originalAsar):
// Normal revert: restore Discord's original app from the preserved copy.
if err := os.Rename(preservedAsar, originalAsar); err != nil {
log.Printf("❌ Unable to restore app.asar in %s\n", resources)
log.Printf(" %s\n", err.Error())
return err
}
case utils.Exists(preservedAsar):
// A live app.asar is already present (e.g. Discord repaired/reinstalled
// over the injection), so the preserved copy is stale. Remove it to fully
// revert and reclaim the space (100MB+). A failure here only leaves a
// harmless leftover — Discord still launches — so don't fail the uninstall.
if err := os.Remove(preservedAsar); err != nil {
log.Printf("⚠️ Unable to remove stale %s\n", preservedAsar)
log.Printf(" %s\n", err.Error())
}
}
log.Printf("✅ Removed from %s\n", discord.Channel.Name())

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"))
Comment thread
zerebos marked this conversation as resolved.
}
Loading