From e5d0b685e0af5b785fe57fb89480b8f5d83b27be Mon Sep 17 00:00:00 2001 From: everestmcarthur <280397636+everestmcarthur@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:36:33 -0400 Subject: [PATCH] ServerDrawer: force Quest Dock state via Context value, not hook mutation --- plugins/server-drawer/src/index.ts | 12 +-- .../src/lib/createElementIntercept.ts | 49 +++++++++- .../src/patches/getQuestAsset.ts | 24 ----- .../src/patches/mobileQuestDock.ts | 40 --------- .../src/patches/questDockBase.ts | 17 ---- .../src/patches/questDockContext.ts | 89 +++++++++++++++++++ .../src/patches/questDockRender.ts | 24 ----- .../src/patches/questDockVisible.ts | 20 ----- 8 files changed, 136 insertions(+), 139 deletions(-) delete mode 100644 plugins/server-drawer/src/patches/getQuestAsset.ts delete mode 100644 plugins/server-drawer/src/patches/mobileQuestDock.ts delete mode 100644 plugins/server-drawer/src/patches/questDockBase.ts create mode 100644 plugins/server-drawer/src/patches/questDockContext.ts delete mode 100644 plugins/server-drawer/src/patches/questDockRender.ts delete mode 100644 plugins/server-drawer/src/patches/questDockVisible.ts diff --git a/plugins/server-drawer/src/index.ts b/plugins/server-drawer/src/index.ts index 90f979a..f9c1840 100644 --- a/plugins/server-drawer/src/index.ts +++ b/plugins/server-drawer/src/index.ts @@ -1,10 +1,6 @@ import { logger } from "@vendetta"; import { storage } from "@vendetta/plugin"; -import { patchQuestDockRender } from "./patches/questDockRender"; -import { patchQuestDockBase } from "./patches/questDockBase"; -import { patchQuestDockVisible } from "./patches/questDockVisible"; -import { patchMobileQuestDock } from "./patches/mobileQuestDock"; -import { patchGetQuestAsset } from "./patches/getQuestAsset"; +import { patchQuestDockContext } from "./patches/questDockContext"; import { patchExpanded, patchEmpty } from "./patches/contentPatch"; import { patchHideGuildsBar } from "./patches/hideGuildsBar"; import { patchCreateElement } from "./lib/createElementIntercept"; @@ -36,11 +32,7 @@ function tryPatch(name: string, fn: () => boolean): boolean { // missing rather than just not loaded yet). function applyAll() { const patchers: Record boolean> = { - questDockRender: () => patchQuestDockRender(cleanups), - questDockBase: () => patchQuestDockBase(cleanups), - questDockVisible: () => patchQuestDockVisible(cleanups), - mobileQuestDock: () => patchMobileQuestDock(cleanups), - getQuestAsset: () => patchGetQuestAsset(cleanups), + questDockContext: () => patchQuestDockContext(cleanups), expanded: () => patchExpanded(cleanups), collapsed: () => patchEmpty("QuestDockContentCollapsed", cleanups), enrolledHeader: () => patchEmpty("QuestDockEnrolledHeader", cleanups), diff --git a/plugins/server-drawer/src/lib/createElementIntercept.ts b/plugins/server-drawer/src/lib/createElementIntercept.ts index b1e157a..93c2656 100644 --- a/plugins/server-drawer/src/lib/createElementIntercept.ts +++ b/plugins/server-drawer/src/lib/createElementIntercept.ts @@ -12,6 +12,11 @@ interface PropsIntercept { replacement: React.ComponentType | null; } +interface PropsTransform { + predicate: (props: any) => boolean; + transform: (props: any) => any; +} + interface TypeDetector { predicate: (type: any) => boolean; onDetected: (type: any) => void; @@ -19,6 +24,7 @@ interface TypeDetector { const intercepts = new Map, Intercept>(); const propsIntercepts: PropsIntercept[] = []; +const propsTransforms: PropsTransform[] = []; let typeDetectors: TypeDetector[] = []; let isPatched = false; @@ -41,6 +47,23 @@ export function registerPropsIntercept(predicate: (props: any) => boolean, repla propsIntercepts.push({ predicate, replacement }); } +/** + * Rewrites an element's props in place while keeping the same type - unlike registerPropsIntercept, + * this doesn't swap in a different component, it lets the real one render with different input. + * + * Exists because monkey-patching a hook's exported property (`someModule.useSomeHook = wrapper`) + * only affects callers that go through that exact exports-object reference. Confirmed live (Key + * Inspector fiber capture) that Discord's Quest Dock hooks are called via an internal, same-chunk + * closure reference instead - the patched export sits there unused, and the real, unpatched hook + * result flows through untouched. A React.createElement/jsx call has no such ambiguity: it's always + * the one shared runtime function, called the same way regardless of which chunk the JSX originated + * in - so rewriting the `value` prop of a Context.Provider element here reaches every consumer + * exactly as if the underlying hooks had actually returned that value. + */ +export function registerPropsTransform(predicate: (props: any) => boolean, transform: (props: any) => any) { + propsTransforms.push({ predicate, transform }); +} + /** * Purely observational, one-shot: the first time an element is created whose `type` matches * `predicate`, calls `onDetected(type)` with the real, live type reference and then stops @@ -86,11 +109,24 @@ function runTypeDetectors(type: any) { function resolveReplacement(type: any, props: any): { type: any; props: any } | null | undefined { runTypeDetectors(type); - if (props) { + let effectiveProps = props; + if (effectiveProps) { + for (const { predicate, transform } of propsTransforms) { + try { + if (predicate(effectiveProps)) { + effectiveProps = transform(effectiveProps); + } + } catch { + // A bad transform shouldn't block every other element from rendering. + } + } + } + + if (effectiveProps) { for (const { predicate, replacement } of propsIntercepts) { try { - if (predicate(props)) { - return replacement ? { type: replacement, props } : null; + if (predicate(effectiveProps)) { + return replacement ? { type: replacement, props: effectiveProps } : null; } } catch { // A bad predicate shouldn't block every other element from rendering. @@ -100,7 +136,11 @@ function resolveReplacement(type: any, props: any): { type: any; props: any } | const entry = intercepts.get(type); if (entry) { - return { type: entry.replacement, props: entry.extraProps ? { ...props, ...entry.extraProps } : props }; + return { type: entry.replacement, props: entry.extraProps ? { ...effectiveProps, ...entry.extraProps } : effectiveProps }; + } + + if (effectiveProps !== props) { + return { type, props: effectiveProps }; } return undefined; @@ -166,6 +206,7 @@ export function patchCreateElement(cleanups: (() => void)[]) { restoreJsx.forEach((fn) => fn()); intercepts.clear(); propsIntercepts.length = 0; + propsTransforms.length = 0; typeDetectors = []; }); } diff --git a/plugins/server-drawer/src/patches/getQuestAsset.ts b/plugins/server-drawer/src/patches/getQuestAsset.ts deleted file mode 100644 index d29e708..0000000 --- a/plugins/server-drawer/src/patches/getQuestAsset.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { rawFindByFunctionProps } from "../lib/rawFind"; - -const TAG = "[ServerDrawer]"; - -// rawFindByFunctionProps, not findByProps or rawFindByProps - see questDockRender.ts for why -// (retried lookup that a caching findByProps would defeat, plus a confirmed-live decoy module -// with the same property names but non-function values that a plain rawFindByProps was matching). -export function patchGetQuestAsset(cleanups: (() => void)[]): boolean { - const mod = rawFindByFunctionProps("getQuestAsset"); - if (!mod?.getQuestAsset) { - console.log(TAG, "WARN: getQuestAsset not found"); - return false; - } - const orig = mod.getQuestAsset; - mod.getQuestAsset = function (...args: any[]) { - try { - return orig.apply(this, args); - } catch { - return { url: null, isAnimated: false }; - } - }; - cleanups.push(() => { mod.getQuestAsset = orig; }); - return true; -} diff --git a/plugins/server-drawer/src/patches/mobileQuestDock.ts b/plugins/server-drawer/src/patches/mobileQuestDock.ts deleted file mode 100644 index 0cd5507..0000000 --- a/plugins/server-drawer/src/patches/mobileQuestDock.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { rawFindByFunctionProps } from "../lib/rawFind"; - -// rawFindByFunctionProps, not findByProps or rawFindByProps - see questDockRender.ts for why -// (retried lookup that a caching findByProps would defeat, plus a confirmed-live decoy module -// with the same property names but non-function values that a plain rawFindByProps was matching). -export function patchMobileQuestDock(cleanups: (() => void)[]): boolean { - const mod = rawFindByFunctionProps("useMobileQuestDock"); - // The upstream version of this check was `!mod.useMobileQuestDock` (no `?.`) - if the lookup - // came back empty, that threw immediately and, since nothing here was wrapped in try/catch, - // took every patch after this one in the onLoad sequence down with it. - if (!mod?.useMobileQuestDock) return false; - - const orig = mod.useMobileQuestDock; - mod.useMobileQuestDock = function (...args: any[]) { - const real = orig.apply(this, args); - if (real) return real; - return { - id: "server-drawer", - config: { - quest_content_type: 0, - // Empty strings, not null: confirmed live (crash stack trace) that Discord's real - // getQuestAsset -> resolveAsset -> buildUrl calls .startsWith() on whichever asset - // field it reads, uncaught by this repo's own getQuestAsset try/catch patch (meaning - // useQuestDockHeroAsset calls a reference that patch never actually reaches - likely - // an internal same-module call that bypasses the exports-object property it mutates). - // An empty string is falsy (same as null for any truthiness check upstream) but is a - // valid string .startsWith() can safely be called on, so it can't crash regardless of - // which exact code path reads it. - assets: { questBarHeroVideo: "", questBarHero: "" }, - features: [], - }, - userStatus: { enrolledAt: "2099-01-01", claimedAt: null }, - benefits: { rewards: [] }, - guildId: "0", - tasks: [], - }; - }; - cleanups.push(() => { mod.useMobileQuestDock = orig; }); - return true; -} diff --git a/plugins/server-drawer/src/patches/questDockBase.ts b/plugins/server-drawer/src/patches/questDockBase.ts deleted file mode 100644 index d18a80e..0000000 --- a/plugins/server-drawer/src/patches/questDockBase.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { rawFindByFunctionProps } from "../lib/rawFind"; - -// rawFindByFunctionProps, not findByProps or rawFindByProps - see questDockRender.ts for why -// (retried lookup that a caching findByProps would defeat, plus a confirmed-live decoy module -// with the same property names but non-function values that a plain rawFindByProps was matching). -export function patchQuestDockBase(cleanups: (() => void)[]): boolean { - const mod = rawFindByFunctionProps("useIsMobileQuestDockRenderedBase"); - if (!mod?.useIsMobileQuestDockRenderedBase) return false; - - const orig = mod.useIsMobileQuestDockRenderedBase; - mod.useIsMobileQuestDockRenderedBase = function (...args: any[]) { - orig.apply(this, args); - return true; - }; - cleanups.push(() => { mod.useIsMobileQuestDockRenderedBase = orig; }); - return true; -} diff --git a/plugins/server-drawer/src/patches/questDockContext.ts b/plugins/server-drawer/src/patches/questDockContext.ts new file mode 100644 index 0000000..4bedbee --- /dev/null +++ b/plugins/server-drawer/src/patches/questDockContext.ts @@ -0,0 +1,89 @@ +import { registerPropsTransform } from "../lib/createElementIntercept"; + +const TAG = "[ServerDrawer]"; + +/** + * Replaces every hook-mutation patch that used to live in questDockRender.ts, questDockBase.ts, + * questDockVisible.ts, mobileQuestDock.ts, and getQuestAsset.ts. All five mutated a hook or utility + * function's exported property (`someModule.useSomeHook = wrapper`) - confirmed live (Key + * Inspector fiber capture) that this never reached the real call site at all: Discord's Quest Dock + * hooks call each other via an internal, same-chunk closure reference, not through the exports + * object property these patches mutated. The proof: with every one of those patches active, the + * live Context value at QuestDockExternalCoordinationContextProviderInner still carried a real + * Discord quest snowflake ID, never this plugin's fake "server-drawer" quest. + * + * QuestDockExternalCoordinationContextProviderInner assembles exactly the state we need + * (isRendered, isVisibleToUser, quest) from those hooks and hands it down through a Context + * Provider - and a Provider element is always created through the one shared createElement/jsx + * runtime, the same interception point GuildsBar's patch already relies on successfully. So instead + * of trying to fake the hooks' return values, this rewrites the Provider's `value` prop directly, + * which reaches every consumer exactly as if the hooks themselves had returned it. + */ +export function patchQuestDockContext(cleanups: (() => void)[]): boolean { + registerPropsTransform( + (props) => { + const v = props?.value; + return !!(v && typeof v === "object" && + "isRendered" in v && "isVisibleToUser" in v && "quest" in v); + }, + (props) => { + const v = props.value; + const quest = v.quest ?? fallbackQuest(); + return { + ...props, + value: { + ...v, + isRendered: true, + isVisibleToUser: true, + quest: sanitizeQuestAssets(quest), + }, + }; + } + ); + cleanups.push(() => { + // No per-call unregister needed - createElementIntercept clears all transforms/intercepts + // together when its own patch unwinds, part of this plugin's normal unload/restart cleanup. + }); + console.log(TAG, "PATCH: watching for the Quest Dock coordination context"); + return true; +} + +function fallbackQuest(): any { + return { + id: "server-drawer", + config: { + quest_content_type: 0, + assets: { questBarHeroVideo: "", questBarHero: "" }, + features: [], + }, + userStatus: { enrolledAt: "2099-01-01", claimedAt: null }, + benefits: { rewards: [] }, + guildId: "0", + tasks: [], + }; +} + +// Same fix as the old mobileQuestDock.ts fallback, but now also applied to REAL quest data - a +// real, currently-active quest that has no hero media at all sets these fields to null too +// (confirmed: the crash reproduced with this plugin's own hook patches active, at a point where +// this quest's real ID was flowing through unmodified - meaning the null came from Discord's own +// quest, not this plugin's fallback). Empty string is falsy for any truthiness check upstream, same +// as null, but is a valid string .startsWith() can't throw on. +function sanitizeQuestAssets(quest: any): any { + const assets = quest?.config?.assets; + if (!assets) return quest; + const hero = assets.questBarHero; + const heroVideo = assets.questBarHeroVideo; + if (hero != null && heroVideo != null) return quest; + return { + ...quest, + config: { + ...quest.config, + assets: { + ...assets, + questBarHero: hero ?? "", + questBarHeroVideo: heroVideo ?? "", + }, + }, + }; +} diff --git a/plugins/server-drawer/src/patches/questDockRender.ts b/plugins/server-drawer/src/patches/questDockRender.ts deleted file mode 100644 index d21e0cb..0000000 --- a/plugins/server-drawer/src/patches/questDockRender.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { rawFindByFunctionProps } from "../lib/rawFind"; - -// rawFindByFunctionProps, not rawFindByProps or findByProps. Two separate reasons: -// 1. This gets retried by index.ts's fast-retry loop, and Revenge's own findByProps permanently -// caches a "not found" result and never rescans (see rawFind.ts), which would make the retry -// pointless after the first failed attempt. -// 2. Confirmed live (Key Inspector's Eval console) that plain rawFindByProps was matching a -// completely unrelated, lower-module-id module whose properties happen to share these exact -// names but hold non-function (type-shape-looking object) values - window.modules iterates in -// ascending id order, so that decoy always won over the real, higher-id hook implementation. -// rawFindByFunctionProps additionally requires the matched property to actually be a function, -// which the decoy isn't. See rawFind.ts for the full writeup. -export function patchQuestDockRender(cleanups: (() => void)[]): boolean { - const mod = rawFindByFunctionProps("useIsMobileQuestDockRendered"); - if (!mod?.useIsMobileQuestDockRendered) return false; - - const orig = mod.useIsMobileQuestDockRendered; - mod.useIsMobileQuestDockRendered = function (...args: any[]) { - orig.apply(this, args); - return true; - }; - cleanups.push(() => { mod.useIsMobileQuestDockRendered = orig; }); - return true; -} diff --git a/plugins/server-drawer/src/patches/questDockVisible.ts b/plugins/server-drawer/src/patches/questDockVisible.ts deleted file mode 100644 index f88639a..0000000 --- a/plugins/server-drawer/src/patches/questDockVisible.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { rawFindByFunctionProps } from "../lib/rawFind"; - -// A third gate alongside useIsMobileQuestDockRendered/useIsMobileQuestDockRenderedBase, found by -// reading the component that actually consumes all three together -// (QuestDockWithQuestContext, confirmed against decompiled current-build Discord source): even -// with the quest data faked and the "rendered" hooks forced true, this one still decides whether -// the dock is visible right now based on navigation route and channel-focus state - so without -// forcing it too, the drawer could still never show up depending on what screen you're on. -export function patchQuestDockVisible(cleanups: (() => void)[]): boolean { - const mod = rawFindByFunctionProps("useIsMobileQuestDockVisibleToUser"); - if (!mod?.useIsMobileQuestDockVisibleToUser) return false; - - const orig = mod.useIsMobileQuestDockVisibleToUser; - mod.useIsMobileQuestDockVisibleToUser = function (...args: any[]) { - orig.apply(this, args); - return true; - }; - cleanups.push(() => { mod.useIsMobileQuestDockVisibleToUser = orig; }); - return true; -}