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:
parent
f0c25cec1a
commit
65d46ce49b
4 changed files with 390 additions and 59 deletions
120
src/main/applyHostPatch.ts
Normal file
120
src/main/applyHostPatch.ts
Normal 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
241
src/main/hostUpdateHook.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue