diff --git a/scripts/build.mjs b/scripts/build.mjs index 3e983ad..8bff0e7 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -77,7 +77,7 @@ const config = { constModules: { globals: { "bunny-build-info": { - version: `"1.4.1.6"`, + version: `"1.4.1.7"`, }, }, }, diff --git a/src/core/debug/patches/patchAnimatedValue.ts b/src/core/debug/patches/patchAnimatedValue.ts new file mode 100644 index 0000000..d5f37e8 --- /dev/null +++ b/src/core/debug/patches/patchAnimatedValue.ts @@ -0,0 +1,112 @@ +import { instead } from "@lib/api/patcher"; +import { logger } from "@lib/utils/logger"; +import { _lazyContextSymbol } from "@metro/lazy"; +import { requireModule } from "@metro/internals/modules"; +import { LazyModuleContext } from "@metro/types"; +import { findByNameLazy } from "@metro/wrappers"; + +function log(...messages: any[]) { + try { + logger.warn("[ShiggyCord] patchAnimatedValue:", ...messages); + } catch {} +} + +/** + * Discord's bundled React Native throws `AnimatedValue: Attempting to set value + * to undefined` when a `undefined` value reaches an `Animated.Value` — either + * through `setValue()` (which calls `_updateValue`) or through the constructor. + * Some of Discord's own flows (and third-party plugins) hit this during + * animation setup, which bubbles up to the React ErrorBoundary and makes + * ShiggyCord render its custom crash screen. + * + * Fix the root cause instead of masking the symptom: + * - `_updateValue` is a prototype method, so one patch covers every instance no + * matter which module captured the class reference. + * - The constructor is guarded by swapping the public `Animated.Value` + * reference (and the module's `default` export) for a patched proxy so that + * `new Animated.Value(undefined)` can no longer throw. + */ +export default function patchAnimatedValue() { + const unpatchers: Array<() => boolean> = []; + + const install = (Class: any, source: string) => { + try { + if ( + !Class?.prototype || + typeof Class.prototype._updateValue !== "function" + ) { + log(`${source}: resolved value is not AnimatedValue, skipping`); + return; + } + + // setValue(undefined) → _updateValue(undefined). Keep the last value. + unpatchers.push( + instead( + "_updateValue", + Class.prototype, + function (this: any, args: any[], origFunc: Function) { + if (args[0] === undefined) args[0] = this?._value ?? 0; + return origFunc(...args); + }, + ), + ); + + // Constructor guard: coerce non-number to 0 so `new Animated.Value(undefined)` can't throw. + const guard = (args: any[], origFunc: Function) => { + if (typeof args[0] !== "number") args[0] = 0; + return origFunc(...args); + }; + + // 1. Module exports (`require(...).default`). + try { + const ctxt: LazyModuleContext | undefined = + findByNameLazy("AnimatedValue")[_lazyContextSymbol]; + if (ctxt?.moduleId != null) { + const moduleExports = requireModule(ctxt.moduleId); + if (moduleExports?.default === Class) { + unpatchers.push(instead("default", moduleExports, guard)); + } + } + } catch {} + + // 2. The public `Animated.Value` namespace reference. + try { + const ReactNative: any = require("react-native"); + const Animated = ReactNative?.Animated; + if (Animated && Animated.Value === Class) { + unpatchers.push(instead("Value", Animated, guard)); + } + } catch {} + + log(`${source}: patched _updateValue + constructor guard`); + } catch (e) { + log(`${source}: patch failed`, e); + } + }; + + // Primary: Discord's own RN Animated namespace (most reliable, no full scan). + try { + const ReactNative: any = require("react-native"); + install(ReactNative?.Animated?.Value, "react-native.Animated.Value"); + if (unpatchers.length > 0) { + return () => unpatchers.forEach((unpatch) => unpatch()); + } + } catch (e) { + log("react-native.Animated path failed", e); + } + + // Fallback: byName lookup. + try { + const ctxt: LazyModuleContext = + findByNameLazy("AnimatedValue")[_lazyContextSymbol]; + if (ctxt) { + ctxt.getExports((Class: any) => install(Class, "findByNameLazy")); + } else { + log("no lazy context found"); + } + } catch (e) { + log("findByNameLazy path failed", e); + } + + return () => unpatchers.forEach((unpatch) => unpatch()); +} diff --git a/src/core/debug/patches/patchSafeCard.ts b/src/core/debug/patches/patchSafeCard.ts new file mode 100644 index 0000000..1a61e6d --- /dev/null +++ b/src/core/debug/patches/patchSafeCard.ts @@ -0,0 +1,141 @@ +import React from "react"; +import { View } from "react-native"; +import { byProps } from "@metro/filters"; +import { findExports } from "@metro/finders"; +import { findByStoreNameLazy } from "@metro/wrappers"; +import { instead } from "@lib/api/patcher"; +import { logger } from "@lib/utils/logger"; +import { isSemanticColor, resolveSemanticColor, semanticColors } from "@ui/color"; + +const ThemeStore = findByStoreNameLazy("ThemeStore"); + +function log(...messages: any[]) { + try { + logger.warn("[ShiggyCord] patchSafeCard:", ...messages); + } catch {} +} + +let cardHealthy = false; + +const FALLBACK_STYLE = { + borderRadius: 8, + padding: 16, +}; + +function resolveCardBackground(): string | undefined { + try { + const color = semanticColors?.CARD_BACKGROUND_DEFAULT; + if (isSemanticColor(color)) return resolveSemanticColor(color); + if (typeof color === "string") return color; + } catch {} + + return undefined; +} + +export default function patchSafeCard() { + const unpatchers: Array<() => boolean> = []; + + try { + const cardModule = findExports(byProps("Card")); + const origCard = cardModule?.Card; + if (typeof origCard !== "function") { + log("no Card function on the resolved module, skipping"); + return () => unpatchers.forEach((unpatch) => unpatch()); + } + + log("resolved design Card from first-match module"); + + let cardBackground = resolveCardBackground(); + + const unsubscribeTheme = ThemeStore?.addChangeListener?.(() => { + const resolved = resolveCardBackground(); + if (resolved) cardBackground = resolved; + }); + if (typeof unsubscribeTheme === "function") { + unpatchers.push(() => { + unsubscribeTheme(); + return true; + }); + } + + class CardProbeBoundary extends React.Component< + { + fallback: React.ReactNode; + children?: React.ReactNode; + }, + { hasError: boolean } + > { + state = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch(error: unknown) { + try { + log("Card render threw, using View fallback", error); + } catch {} + } + + render() { + if (this.state.hasError) return this.props.fallback; + return this.props.children; + } + } + + class HealthyProbe extends React.Component<{ + onHealthy: () => void; + children?: React.ReactNode; + }> { + componentDidMount() { + this.props.onHealthy(); + } + + render() { + return this.props.children; + } + } + + const SafeCard = function SafeCard(props: any) { + if (cardHealthy) return React.createElement(origCard, props); + + return React.createElement( + CardProbeBoundary, + { + fallback: React.createElement( + View, + { + style: [ + FALLBACK_STYLE, + ...(cardBackground + ? [{ backgroundColor: cardBackground }] + : []), + props?.style, + ], + }, + props?.children, + ), + }, + React.createElement( + HealthyProbe, + { + onHealthy: () => { + cardHealthy = true; + }, + }, + React.createElement(origCard, props), + ), + ); + }; + + unpatchers.push( + instead("Card", cardModule, (args: any[]) => SafeCard(args[0] ?? {})), + ); + + log("patched design Card (adaptive probe on first mount)"); + } catch (e) { + log("patch failed", e); + } + + return () => unpatchers.forEach((unpatch) => unpatch()); +} diff --git a/src/core/ui/reporter/components/ErrorBoundaryScreen.tsx b/src/core/ui/reporter/components/ErrorBoundaryScreen.tsx index f3a1495..7f7fce8 100644 --- a/src/core/ui/reporter/components/ErrorBoundaryScreen.tsx +++ b/src/core/ui/reporter/components/ErrorBoundaryScreen.tsx @@ -19,7 +19,6 @@ import { TableSwitchRow, Stack, } from "@metro/common/components"; -import { semanticColors } from "@ui/color"; import { ScrollView, View } from "react-native"; import ErrorComponentStackCard from "./ErrorComponentStackCard"; @@ -74,8 +73,8 @@ export default function ErrorBoundaryScreen(props: { } trailing={ - + } /> diff --git a/src/index.ts b/src/index.ts index afd7bb1..3ebf2d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,6 @@ +import patchAnimatedValue from "@core/debug/patches/patchAnimatedValue"; import patchErrorBoundary from "@core/debug/patches/patchErrorBoundary"; +import patchSafeCard from "@core/debug/patches/patchSafeCard"; import initFixes from "@core/fixes"; import { initFetchI18nStrings } from "@core/i18n"; import initSettings from "@core/ui/settings"; @@ -30,6 +32,8 @@ export default async () => { ["patchCommands", () => patchCommands()], ["patchJsx", () => patchJsx()], ["patchErrorBoundary", () => patchErrorBoundary()], + ["patchAnimatedValue", () => patchAnimatedValue()], + ["patchSafeCard", () => patchSafeCard()], ["initVendettaObject", () => initVendettaObject()], ["initFetchI18nStrings", () => initFetchI18nStrings()], ["initSettings", () => initSettings()],