ServerDrawer: force Quest Dock state via Context value, not hook mutation
This commit is contained in:
parent
bd0e624256
commit
e5d0b685e0
8 changed files with 136 additions and 139 deletions
|
|
@ -1,10 +1,6 @@
|
||||||
import { logger } from "@vendetta";
|
import { logger } from "@vendetta";
|
||||||
import { storage } from "@vendetta/plugin";
|
import { storage } from "@vendetta/plugin";
|
||||||
import { patchQuestDockRender } from "./patches/questDockRender";
|
import { patchQuestDockContext } from "./patches/questDockContext";
|
||||||
import { patchQuestDockBase } from "./patches/questDockBase";
|
|
||||||
import { patchQuestDockVisible } from "./patches/questDockVisible";
|
|
||||||
import { patchMobileQuestDock } from "./patches/mobileQuestDock";
|
|
||||||
import { patchGetQuestAsset } from "./patches/getQuestAsset";
|
|
||||||
import { patchExpanded, patchEmpty } from "./patches/contentPatch";
|
import { patchExpanded, patchEmpty } from "./patches/contentPatch";
|
||||||
import { patchHideGuildsBar } from "./patches/hideGuildsBar";
|
import { patchHideGuildsBar } from "./patches/hideGuildsBar";
|
||||||
import { patchCreateElement } from "./lib/createElementIntercept";
|
import { patchCreateElement } from "./lib/createElementIntercept";
|
||||||
|
|
@ -36,11 +32,7 @@ function tryPatch(name: string, fn: () => boolean): boolean {
|
||||||
// missing rather than just not loaded yet).
|
// missing rather than just not loaded yet).
|
||||||
function applyAll() {
|
function applyAll() {
|
||||||
const patchers: Record<string, () => boolean> = {
|
const patchers: Record<string, () => boolean> = {
|
||||||
questDockRender: () => patchQuestDockRender(cleanups),
|
questDockContext: () => patchQuestDockContext(cleanups),
|
||||||
questDockBase: () => patchQuestDockBase(cleanups),
|
|
||||||
questDockVisible: () => patchQuestDockVisible(cleanups),
|
|
||||||
mobileQuestDock: () => patchMobileQuestDock(cleanups),
|
|
||||||
getQuestAsset: () => patchGetQuestAsset(cleanups),
|
|
||||||
expanded: () => patchExpanded(cleanups),
|
expanded: () => patchExpanded(cleanups),
|
||||||
collapsed: () => patchEmpty("QuestDockContentCollapsed", cleanups),
|
collapsed: () => patchEmpty("QuestDockContentCollapsed", cleanups),
|
||||||
enrolledHeader: () => patchEmpty("QuestDockEnrolledHeader", cleanups),
|
enrolledHeader: () => patchEmpty("QuestDockEnrolledHeader", cleanups),
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,11 @@ interface PropsIntercept {
|
||||||
replacement: React.ComponentType<any> | null;
|
replacement: React.ComponentType<any> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PropsTransform {
|
||||||
|
predicate: (props: any) => boolean;
|
||||||
|
transform: (props: any) => any;
|
||||||
|
}
|
||||||
|
|
||||||
interface TypeDetector {
|
interface TypeDetector {
|
||||||
predicate: (type: any) => boolean;
|
predicate: (type: any) => boolean;
|
||||||
onDetected: (type: any) => void;
|
onDetected: (type: any) => void;
|
||||||
|
|
@ -19,6 +24,7 @@ interface TypeDetector {
|
||||||
|
|
||||||
const intercepts = new Map<React.ComponentType<any>, Intercept>();
|
const intercepts = new Map<React.ComponentType<any>, Intercept>();
|
||||||
const propsIntercepts: PropsIntercept[] = [];
|
const propsIntercepts: PropsIntercept[] = [];
|
||||||
|
const propsTransforms: PropsTransform[] = [];
|
||||||
let typeDetectors: TypeDetector[] = [];
|
let typeDetectors: TypeDetector[] = [];
|
||||||
let isPatched = false;
|
let isPatched = false;
|
||||||
|
|
||||||
|
|
@ -41,6 +47,23 @@ export function registerPropsIntercept(predicate: (props: any) => boolean, repla
|
||||||
propsIntercepts.push({ predicate, replacement });
|
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
|
* 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
|
* `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 {
|
function resolveReplacement(type: any, props: any): { type: any; props: any } | null | undefined {
|
||||||
runTypeDetectors(type);
|
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) {
|
for (const { predicate, replacement } of propsIntercepts) {
|
||||||
try {
|
try {
|
||||||
if (predicate(props)) {
|
if (predicate(effectiveProps)) {
|
||||||
return replacement ? { type: replacement, props } : null;
|
return replacement ? { type: replacement, props: effectiveProps } : null;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// A bad predicate shouldn't block every other element from rendering.
|
// 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);
|
const entry = intercepts.get(type);
|
||||||
if (entry) {
|
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;
|
return undefined;
|
||||||
|
|
@ -166,6 +206,7 @@ export function patchCreateElement(cleanups: (() => void)[]) {
|
||||||
restoreJsx.forEach((fn) => fn());
|
restoreJsx.forEach((fn) => fn());
|
||||||
intercepts.clear();
|
intercepts.clear();
|
||||||
propsIntercepts.length = 0;
|
propsIntercepts.length = 0;
|
||||||
|
propsTransforms.length = 0;
|
||||||
typeDetectors = [];
|
typeDetectors = [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
89
plugins/server-drawer/src/patches/questDockContext.ts
Normal file
89
plugins/server-drawer/src/patches/questDockContext.ts
Normal file
|
|
@ -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 ?? "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue