From 65d46ce49b8e1b36e676fd63addb9a14afbfb90d Mon Sep 17 00:00:00 2001 From: prism Date: Thu, 7 May 2026 15:36:46 +0200 Subject: [PATCH] main: retain patch across discord host updates (#1016) * main: retain patch across discord host updates * bleh * bleh 2 * bleh 3 * bleh 4 * bleh 5 --- src/main/applyHostPatch.ts | 120 +++++++++++++++++ src/main/hostUpdateHook.ts | 241 ++++++++++++++++++++++++++++++++++ src/main/patchWin32Updater.ts | 70 ++-------- src/main/patcher.ts | 18 ++- 4 files changed, 390 insertions(+), 59 deletions(-) create mode 100644 src/main/applyHostPatch.ts create mode 100644 src/main/hostUpdateHook.ts diff --git a/src/main/applyHostPatch.ts b/src/main/applyHostPatch.ts new file mode 100644 index 000000000..14d5a22c2 --- /dev/null +++ b/src/main/applyHostPatch.ts @@ -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"); +}; diff --git a/src/main/hostUpdateHook.ts b/src/main/hostUpdateHook.ts new file mode 100644 index 000000000..bf359f0b5 --- /dev/null +++ b/src/main/hostUpdateHook.ts @@ -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; + 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; + 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(); +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 `/.app/Contents/MacOS/`. + */ +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; +}; diff --git a/src/main/patchWin32Updater.ts b/src/main/patchWin32Updater.ts index b0648d3c8..d42f6db62 100644 --- a/src/main/patchWin32Updater.ts +++ b/src/main/patchWin32Updater.ts @@ -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 . -*/ + * 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); +}); diff --git a/src/main/patcher.ts b/src/main/patcher.ts index 911a40473..f067bbaaf 100644 --- a/src/main/patcher.ts +++ b/src/main/patcher.ts @@ -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;