main: retain patch across discord host updates (#1016)

* main: retain patch across discord host updates

* bleh

* bleh 2

* bleh 3

* bleh 4

* bleh 5
This commit is contained in:
prism 2026-05-07 15:36:46 +02:00 • committed by GitHub
parent f0c25cec1a
commit 65d46ce49b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 390 additions and 59 deletions

120
src/main/applyHostPatch.ts Normal file
View file

@ -0,0 +1,120 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "original-fs";
import { basename, dirname, join } from "path";
const STUB_PACKAGE = JSON.stringify({ name: "discord", main: "index.js" });
const VERSION_PREFIX = "app-";
const makeStubIndex = (patcherPath: string) =>
`require(${JSON.stringify(patcherPath)});`;
/** `_app.asar` next to `app.asar` marks any patched install. */
export const isAlreadyPatched = (resources: string) =>
existsSync(join(resources, "_app.asar"));
/**
* apply the folder-shim patch to a discord `resources/` directory.
*
* renames vanilla `app.asar` to `_app.asar`, creates an `app/` folder
* whose `index.js` requires the given patcher script. electron prefers
* the folder over the asar of the same name and loads it first.
*
* idempotent. returns `false` if the directory is already patched or
* has no vanilla `app.asar`. throws on partial failure after rolling
* back any disk changes already made.
*/
export const patchResourcesDir = (resources: string, patcherJsPath: string): boolean => {
const app = join(resources, "app.asar");
const _app = join(resources, "_app.asar");
if (isAlreadyPatched(resources)) return false;
if (!existsSync(app)) return false;
try {
if (lstatSync(app).isDirectory()) return false;
} catch {
return false;
}
const undo: Array<() => void> = [];
try {
renameSync(app, _app);
undo.push(() => renameSync(_app, app));
mkdirSync(app);
undo.push(() => {
const indexPath = join(app, "index.js");
const pkgPath = join(app, "package.json");
if (existsSync(indexPath)) unlinkSync(indexPath);
if (existsSync(pkgPath)) unlinkSync(pkgPath);
rmdirSync(app);
});
writeFileSync(join(app, "package.json"), STUB_PACKAGE);
writeFileSync(join(app, "index.js"), makeStubIndex(patcherJsPath));
return true;
} catch (err) {
for (let i = undo.length - 1; i >= 0; i--) {
try {
undo[i]();
} catch (cleanupErr) {
console.error("[Equicord] Rollback step failed", cleanupErr);
}
}
throw err;
}
};
const parsePart = (s: string) => parseInt(s, 10) || 0;
const isNewer = ($new: string, old: string): boolean => {
const newParts = $new.slice(VERSION_PREFIX.length).split(".").map(parsePart);
const oldParts = old.slice(VERSION_PREFIX.length).split(".").map(parsePart);
const len = Math.max(newParts.length, oldParts.length);
for (let i = 0; i < len; i++) {
const n = newParts[i] ?? 0;
const o = oldParts[i] ?? 0;
if (n > o) return true;
if (n < o) return false;
}
return false;
};
/**
* find the newest sibling `app-VERSION` directory's `resources/` path.
*
* squirrel-only layout. returns `null` on non-win32 platforms, or when
* the running process is already in the newest sibling.
*/
export const findStaleSibling = (currentExeDir: string): string | null => {
if (process.platform !== "win32") return null;
const discordPath = dirname(currentExeDir);
const currentVersion = basename(currentExeDir);
let latest = currentVersion;
try {
for (const name of readdirSync(discordPath)) {
if (!name.startsWith(VERSION_PREFIX)) continue;
let isDir = false;
try {
isDir = statSync(join(discordPath, name)).isDirectory();
} catch (statErr) {
console.error("[Equicord] Skipping unreadable sibling", name, statErr);
continue;
}
if (!isDir) continue;
if (isNewer(name, latest)) latest = name;
}
} catch (err) {
console.error("[Equicord] Failed to scan for sibling versions", err);
return null;
}
if (latest === currentVersion) return null;
return join(discordPath, latest, "resources");
};

241
src/main/hostUpdateHook.ts Normal file
View file

@ -0,0 +1,241 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
/**
* re-applies the equicord patch to a freshly installed discord host
* version at the moment the native updater finishes writing it.
*
* hooks `discord_desktop_core.startup({ updater })` to capture the live
* updater instance, listens for its `host-updated` event, and wraps
* `startCurrentVersion` for the post-update relaunch handoff. both paths
* are idempotent because `patchResourcesDir` checks for the `_app.asar`
* marker before doing anything.
*
* targets discord's new updater code path. default on win32 and linux.
* on darwin the new updater is opt-in via `USE_NEW_UPDATER`, so installs
* using the legacy sparkle-style autoUpdater fall through to the normal
* reinject flow on first post-update launch.
*/
import Module from "module";
import { basename, dirname, join, resolve as resolvePath, sep } from "path";
import { findStaleSibling, patchResourcesDir } from "./applyHostPatch";
interface DiscordVersions {
current_host?: number[];
current_modules?: Record<string, string>;
last_successful_update?: unknown;
}
interface StartCurrentVersionOptions {
allowObsoleteHost?: boolean;
}
interface DiscordBuildInfo {
releaseChannel: string;
version: string;
}
interface DiscordHostUpdater {
committedHostVersion?: number[];
rootPath?: string;
on?(event: "host-updated", handler: () => void): void;
queryCurrentVersionsSync?(): DiscordVersions;
startCurrentVersion?(queryOptions?: object, options?: StartCurrentVersionOptions): Promise<void>;
startCurrentVersionSync?(options?: StartCurrentVersionOptions): void;
}
interface DiscordUpdaterModule {
getUpdater?(): DiscordHostUpdater | null | undefined;
tryInitUpdater?(buildInfo: DiscordBuildInfo, repositoryUrl: string, useRustBspatch: boolean): boolean;
__equicordTryInitWrapped?: boolean;
}
interface DiscordDesktopCoreStartupOpts {
updater?: DiscordUpdaterModule;
[key: string]: unknown;
}
interface DiscordDesktopCore {
startup?(opts: DiscordDesktopCoreStartupOpts): void;
__equicordStartupWrapped?: boolean;
}
const error = (...args: unknown[]) => console.error("[Equicord:HostUpdate]", ...args);
const hookedUpdaters = new WeakSet<DiscordHostUpdater>();
let hooked = false;
const getPatcherJsPath = () => join(__dirname, "patcher.js");
/**
* resolve `resources/` for a given discord version directory.
*
* on darwin, extracts the bundle name (Discord.app, DiscordCanary.app,
* DiscordPTB.app) from `process.execPath`. matches discord's own logic
* in `common/updater.js#_getHostExePath`. iterates from the end so a
* nested `.app` segment near the binary wins over an outer one.
*/
const resourcesPathFor = (versionDir: string): string => {
if (process.platform !== "darwin") return join(versionDir, "resources");
const segs = process.execPath.split(sep);
for (let i = segs.length - 1; i >= 0; i--) {
if (segs[i].endsWith(".app")) {
return join(versionDir, segs[i], "Contents", "Resources");
}
}
return join(versionDir, "Contents", "Resources");
};
/**
* resolve the version dir the current process runs from.
*
* on darwin, execPath sits four levels deep inside the version dir,
* at `<versionDir>/<AppName>.app/Contents/MacOS/<bin>`.
*/
const currentVersionDir = (): string => {
if (process.platform === "darwin") {
return resolvePath(process.execPath, "..", "..", "..", "..");
}
return dirname(process.execPath);
};
/**
* resolve the new host version array.
*
* `committedHostVersion` is only populated after `_startCurrentVersionInner`
* runs, so during `host-updated` we have to query the native side
* directly to get fresh state.
*/
const resolveCommittedVersion = (updater: DiscordHostUpdater): number[] | undefined => {
if (Array.isArray(updater.committedHostVersion)) return updater.committedHostVersion;
try {
const versions = updater.queryCurrentVersionsSync?.();
if (Array.isArray(versions?.current_host)) return versions.current_host;
} catch (err) {
error("queryCurrentVersionsSync failed", err);
}
return undefined;
};
const retainEquicord = (updater: DiscordHostUpdater, reason: string) => {
try {
const committed = resolveCommittedVersion(updater);
const { rootPath } = updater;
if (!committed || !rootPath) {
/*
* updater state unavailable. fall back to filesystem scan
* on win32 only because the sibling layout is squirrel
* specific.
*/
if (process.platform !== "win32") return;
const stale = findStaleSibling(dirname(process.execPath));
if (stale) patchResourcesDir(stale, getPatcherJsPath());
return;
}
const versionDir = join(rootPath, `app-${committed.join(".")}`);
if (resolvePath(versionDir) === resolvePath(currentVersionDir())) return;
patchResourcesDir(resourcesPathFor(versionDir), getPatcherJsPath());
} catch (err) {
error(`[${reason}] retain failed:`, err);
}
};
const attachToUpdater = (updater: DiscordHostUpdater | null | undefined) => {
if (!updater || hookedUpdaters.has(updater)) return;
try {
hookedUpdaters.add(updater);
} catch (err) {
error("Failed to track updater instance", err);
return;
}
updater.on?.("host-updated", () => retainEquicord(updater, "host-updated"));
/*
* wrap the post-update relaunch entrypoints. retain runs after the
* original so `committedHostVersion` is freshly populated by
* `_startCurrentVersionInner`. relaunch is scheduled via
* `app.once("will-quit", spawn)` then `app.quit()`, so the patch
* still finishes before the new exe is spawned.
*/
const sync = updater.startCurrentVersionSync;
if (typeof sync === "function") {
const bound = sync.bind(updater);
updater.startCurrentVersionSync = (options?: StartCurrentVersionOptions) => {
bound(options);
try { retainEquicord(updater, "startCurrentVersionSync"); } catch (e) { error(e); }
};
}
const async_ = updater.startCurrentVersion;
if (typeof async_ === "function") {
const bound = async_.bind(updater);
updater.startCurrentVersion = async (queryOptions?: object, options?: StartCurrentVersionOptions) => {
await bound(queryOptions, options);
try { retainEquicord(updater, "startCurrentVersion"); } catch (e) { error(e); }
};
}
};
const wrapStartup = (coreExports: DiscordDesktopCore | null | undefined) => {
if (!coreExports?.startup || coreExports.__equicordStartupWrapped) return;
coreExports.__equicordStartupWrapped = true;
const origStartup = coreExports.startup;
coreExports.startup = function (opts, ...rest) {
try {
const updaterModule = opts?.updater;
const inst = updaterModule?.getUpdater?.();
if (inst) {
attachToUpdater(inst);
} else if (typeof updaterModule?.tryInitUpdater === "function" && !updaterModule.__equicordTryInitWrapped) {
/*
* updater not yet constructed at startup time. wrap the
* factory so we attach once vanilla creates it.
*/
updaterModule.__equicordTryInitWrapped = true;
const origTry = updaterModule.tryInitUpdater.bind(updaterModule);
updaterModule.tryInitUpdater = (buildInfo, repositoryUrl, useRustBspatch) => {
const ok = origTry(buildInfo, repositoryUrl, useRustBspatch);
try { attachToUpdater(updaterModule.getUpdater?.()); } catch (e) { error(e); }
return ok;
};
}
} catch (e) {
error("wrap updater failed:", e);
}
return origStartup.call(this, opts, ...rest);
};
};
export const installHostUpdateHook = () => {
if (hooked) return;
hooked = true;
/*
* intercept `require` to catch the first `discord_desktop_core` load
* before vanilla bootstrap calls its `startup`. normalise separators
* so absolute requires resolve correctly on windows. once we have
* wrapped startup we restore the original require to avoid taxing
* every subsequent require call.
*/
const origRequire = Module.prototype.require;
Module.prototype.require = function (this: Module, id: string) {
const result = origRequire.call(this, id);
if (!id.includes("discord_desktop_core")) return result;
if (basename(id.replace(/\\/g, "/")) === "discord_desktop_core") {
try { wrapStartup(result?.default ?? result); } catch (e) { error(e); }
Module.prototype.require = origRequire;
}
return result;
} as typeof Module.prototype.require;
};

View file

@ -1,68 +1,24 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
* SPDX-License-Identifier: GPL-3.0-or-later
*/
/**
* fallback for `hostUpdateHook.ts`. scans for a newer squirrel sibling
* `app-VERSION` directory on quit and re-applies the patch there.
*/
import { app } from "electron";
import { existsSync, mkdirSync, readdirSync, renameSync, statSync, writeFileSync } from "original-fs";
import { basename, dirname, join } from "path";
import { dirname, join } from "path";
function isNewer($new: string, old: string) {
const newParts = $new.slice(4).split(".").map(Number);
const oldParts = old.slice(4).split(".").map(Number);
import { findStaleSibling, patchResourcesDir } from "./applyHostPatch";
for (let i = 0; i < oldParts.length; i++) {
if (newParts[i] > oldParts[i]) return true;
if (newParts[i] < oldParts[i]) return false;
}
return false;
}
function patchLatest() {
app.on("before-quit", () => {
try {
const currentAppPath = dirname(process.execPath);
const currentVersion = basename(currentAppPath);
const discordPath = join(currentAppPath, "..");
const latestVersion = readdirSync(discordPath)
.filter(name => name.startsWith("app-") && statSync(join(discordPath, name)).isDirectory())
.reduce((prev, curr) => isNewer(curr, prev) ? curr : prev, currentVersion as string);
if (latestVersion === currentVersion) return;
const resources = join(discordPath, latestVersion, "resources");
const app = join(resources, "app.asar");
const _app = join(resources, "_app.asar");
if (!existsSync(app) || statSync(app).isDirectory()) return;
console.info("[Equicord] Detected Host Update. Repatching...");
renameSync(app, _app);
mkdirSync(app);
writeFileSync(join(app, "package.json"), JSON.stringify({
name: "discord",
main: "index.js"
}));
writeFileSync(join(app, "index.js"), `require(${JSON.stringify(join(__dirname, "patcher.js"))});`);
const stale = findStaleSibling(dirname(process.execPath));
if (stale) patchResourcesDir(stale, join(__dirname, "patcher.js"));
} catch (err) {
console.error("[Equicord] Failed to repatch latest host update", err);
}
}
// Try to patch latest on before-quit
// Discord's Win32 updater will call app.quit() on restart and open new version on will-quit
app.on("before-quit", patchLatest);
});

View file

@ -44,9 +44,23 @@ if (!IS_VANILLA) {
patchTrayMenu();
// Repatch after host updates on Windows
if (process.platform === "win32") {
/*
* re-apply the patch when discord ships a new host version. skipped
* on vesktop and equibop because they manage their own updates.
*/
if (!IS_VESKTOP && !IS_EQUIBOP) {
try {
require("./hostUpdateHook").installHostUpdateHook();
} catch (err) {
console.error("[Equicord] Failed to install host update hook", err);
}
}
if (process.platform === "win32" && !IS_VESKTOP && !IS_EQUIBOP) {
/* before-quit fallback for the rare case the hook above never sees discord_desktop_core get required */
require("./patchWin32Updater");
}
if (process.platform === "win32") {
if (settings.winCtrlQ) {
const originalBuild = Menu.buildFromTemplate;