Enhance ErrorBoundary and plugin attribution

Make ErrorBoundary patch resilient to missing contexts and add defensive
logging and best-effort global uncaught-error handlers Enhance reporter
UI to attribute suspected plugins via markers and stack heuristics, add
plugin bisect UI and getBisectBatches helper Mark plugins during
eval/instantiation and annotate errors with pluginId Wire the patch into
startup, bump build version, and add README badge
This commit is contained in:
kmmiio99o 2025-10-18 22:52:58 +02:00
parent d3754c2195
commit c2009a7c0b
9 changed files with 589 additions and 36 deletions

View file

@ -1,4 +1,4 @@
# ShiggyCord
# ShiggyCord [![Discord](https://img.shields.io/discord/1427396621905432699?style=social&logo=discord&label=ShiggyCord)](https://discord.gg/nQykFF9Ud6)
An unofficial fork of ShiggyCord, made just for fun
Idea taken from Rosiecord

View file

@ -77,7 +77,7 @@ const config = {
constModules: {
globals: {
"bunny-build-info": {
version: `"1.1.4-2"`,
version: `"1.1.4-3"`,
},
},
},

View file

@ -4,18 +4,135 @@ import { _lazyContextSymbol } from "@metro/lazy";
import { LazyModuleContext } from "@metro/types";
import { findByNameLazy } from "@metro/wrappers";
function getErrorBoundaryContext() {
const ctxt: LazyModuleContext = findByNameLazy("ErrorBoundary")[_lazyContextSymbol];
return new Promise(resolve => ctxt.getExports(exp => resolve(exp.prototype)));
/**
* Try to resolve the ErrorBoundary prototype context in a resilient way.
* Some Discord versions / builds may expose modules under slightly different
* names; attempt lookup but fail gracefully if not found.
*/
function getErrorBoundaryContext(): Promise<any> {
try {
const ctxt: LazyModuleContext =
findByNameLazy("ErrorBoundary")[_lazyContextSymbol];
if (ctxt) {
return new Promise((resolve) =>
ctxt.getExports((exp: any) => resolve(exp.prototype)),
);
}
} catch (e) {
// silent
}
// Best-effort fallback: resolve to null so callers can handle absence.
return Promise.reject(new Error("ErrorBoundary context not found"));
}
/**
* Patch Discord's ErrorBoundary.render to return ShiggyCord's custom screen.
* Add defensive logging and fallback behavior so the patch won't break startup
* if lookup fails. Also register lightweight global handlers to capture
* uncaught errors for debugging.
*/
export default function patchErrorBoundary() {
return after.await("render", getErrorBoundaryContext(), function (this: any) {
if (!this.state.error) return;
try {
console.log("[ShiggyCord] patchErrorBoundary: registering");
} catch {}
return <ErrorBoundaryScreen
error={this.state.error}
rerender={() => this.setState({ info: null, error: null })}
/>;
});
// Attempt to attach after the ErrorBoundary render. If the context lookup
// fails, the promise will reject and the patcher will not install the patch,
// which is preferable to throwing during startup.
const ctxPromise = getErrorBoundaryContext().catch((err) => {
try {
console.warn(
"[ShiggyCord] patchErrorBoundary: context lookup failed",
err,
);
} catch {}
// Return null so after.await receives a thenable; the patcher wrapper will handle it.
return null;
});
const unpatch = after.await("render", ctxPromise, function (this: any) {
try {
if (!this || !this.state || !this.state.error) return;
try {
console.log(
"[ShiggyCord] patchErrorBoundary: rendering custom error screen",
this.state.error,
);
} catch {}
return (
<ErrorBoundaryScreen
error={this.state.error}
// try to reset state in a way that plays nicely with various ErrorBoundary shapes
rerender={() => {
try {
// common variant: some boundaries track `hasErr` or `error`
this.setState?.({ info: null, error: null, hasErr: false });
} catch {
try {
this.setState?.({ error: null });
} catch {}
}
}}
/>
);
} catch (e) {
try {
console.error(
"[ShiggyCord] patchErrorBoundary: error while rendering custom screen",
e,
);
} catch {}
// Fall back to original behavior by returning undefined
return;
}
});
// Best-effort: install global handlers to capture uncaught errors / promise rejections.
// These handlers do not attempt to mount UI directly (that can be fragile), but they
// store the last uncaught error for inspection and log details which helps debugging.
try {
const g: any = global as any;
if (g) {
// Wrap React Native's ErrorUtils if present
try {
const ErrorUtils = g.ErrorUtils;
if (ErrorUtils && typeof ErrorUtils.setGlobalHandler === "function") {
// preserve previous handler
const prev =
ErrorUtils.getGlobalHandler?.() ??
ErrorUtils._globalHandler ??
null;
ErrorUtils.setGlobalHandler((err: any, isFatal?: boolean) => {
try {
console.error("[ShiggyCord] global uncaught error:", err, {
isFatal,
});
(window as any).__SHIGGY_LAST_UNCAUGHT_ERROR = err;
} catch {}
try {
if (typeof prev === "function") prev(err, isFatal);
} catch {}
});
}
} catch {}
// Listen for unhandled promise rejections if environment supports it.
try {
if (typeof g.addEventListener === "function") {
g.addEventListener("unhandledrejection", (ev: any) => {
try {
console.error(
"[ShiggyCord] unhandledrejection:",
ev?.reason ?? ev,
);
(window as any).__SHIGGY_LAST_UNCAUGHT_ERROR = ev?.reason ?? ev;
} catch {}
});
}
} catch {}
}
} catch {}
return unpatch;
}

View file

@ -1,4 +1,6 @@
import React from "react";
import { hasStack, isComponentStack } from "@core/ui/reporter/utils/isStack";
import parseErrorStack from "@core/ui/reporter/utils/parseErrorStack";
import { getDebugInfo, toggleSafeMode } from "@lib/api/debug";
import { BundleUpdaterManager } from "@lib/api/native/modules";
import { settings } from "@lib/api/settings";
@ -14,8 +16,16 @@ import {
} from "@metro/common/components";
import { ScrollView, View } from "react-native";
import {
registeredPlugins,
disablePlugin,
getBisectBatches,
enablePlugin,
getPluginSettingsComponent,
} from "@lib/addons/plugins";
import ErrorComponentStackCard from "./ErrorComponentStackCard";
import ErrorStackCard from "./ErrorStackCard";
import { NavigationNative } from "@metro/common";
const useStyles = createStyles({
container: {
@ -33,6 +43,14 @@ export default function ErrorBoundaryScreen(props: {
}) {
const styles = useStyles();
const debugInfo = getDebugInfo();
const navigation = NavigationNative.useNavigation();
// Bisect UI state (fixes ReferenceError when referenced below)
const [bisectBatches, setBisectBatches] = React.useState<string[][] | null>(
null,
);
const [bisectIndex, setBisectIndex] = React.useState<number>(0);
const [showBisectUI, setShowBisectUI] = React.useState<boolean>(false);
return (
<ErrorBoundary>
@ -51,6 +69,274 @@ export default function ErrorBoundaryScreen(props: {
</View>
<ScrollView fadingEdgeLength={64} contentContainerStyle={{ gap: 12 }}>
<Codeblock selectable={true}>{props.error.message}</Codeblock>
{(() => {
// 1) Prefer explicit annotation on the error (set by plugin loader) or current runtime marker.
const explicitPluginId =
(props.error as any)?.pluginId ??
(window as any)?.__SHIGGY_LAST_UNCAUGHT_ERROR?.pluginId ??
(window as any)?.__SHIGGY_CURRENT_PLUGIN;
if (explicitPluginId && registeredPlugins.has(explicitPluginId)) {
const manifest = registeredPlugins.get(explicitPluginId)!;
const name =
manifest.display?.name ?? manifest.name ?? explicitPluginId;
return (
<Card>
<View style={{ gap: 8 }}>
<Text variant="heading-md/bold">Suspected plugin</Text>
<Text variant="text-md/normal">
This error is associated with a plugin based on runtime
context.
</Text>
<Text variant="text-sm/normal" color="text-muted">
{name} ({explicitPluginId})
</Text>
<View style={{ flexDirection: "row", gap: 8 }}>
<Button
variant="destructive"
text="Disable plugin"
onPress={async () => {
try {
await disablePlugin(explicitPluginId);
props.rerender();
} catch (e) {
console.error(e);
}
}}
/>
<Button
text="Open settings"
onPress={() => {
try {
const Comp =
getPluginSettingsComponent(explicitPluginId);
if (Comp) {
navigation.push("SHIGGYCORD_CUSTOM_PAGE", {
title: name,
render: () => <Comp />,
});
}
} catch (e) {
console.error(e);
}
}}
/>
</View>
</View>
</Card>
);
}
// 2) Fallback: heuristic based on stack frames (best-effort)
let suspected: Array<{ id: string; name: string }> = [];
try {
const frames = parseErrorStack((props.error as any).stack);
if (frames && frames.length) {
for (const [id, manifest] of registeredPlugins) {
try {
const jsPath = (manifest as any).jsPath;
if (!jsPath) continue;
for (const f of frames) {
if (!f.file) continue;
if (
f.file.includes(jsPath) ||
f.file.includes(`/plugins/scripts/${id}`) ||
f.file.includes(`bunny-plugin/${id}`)
) {
suspected.push({
id,
name:
(manifest as any).display?.name ??
(manifest as any).name ??
id,
});
break;
}
}
if (suspected.length) break;
} catch {
// ignore per-manifest errors
}
}
}
} catch {
// parsing failed — continue to show suggestions
}
if (suspected.length > 0) {
const s = suspected[0];
return (
<Card>
<View style={{ gap: 8 }}>
<Text variant="heading-md/bold">Possible culprit</Text>
<Text variant="text-md/normal">
The call stack contains frames that look like they
originate from a plugin.
</Text>
<Text variant="text-sm/normal" color="text-muted">
Suspected plugin: {s.name} ({s.id})
</Text>
<View style={{ flexDirection: "row", gap: 8 }}>
<Button
variant="destructive"
text="Disable plugin"
onPress={async () => {
try {
await disablePlugin(s.id);
props.rerender();
} catch (e) {
console.error(e);
}
}}
/>
<Button
text="Open settings"
onPress={() => {
try {
const Comp = getPluginSettingsComponent(s.id);
if (Comp) {
navigation.push("SHIGGYCORD_CUSTOM_PAGE", {
title: s.name,
render: () => <Comp />,
});
}
} catch (e) {
console.error(e);
}
}}
/>
</View>
</View>
</Card>
);
}
// 3) No confident detection — show bisect suggestion and controls
const batches = bisectBatches;
return (
<Card>
<View style={{ gap: 8 }}>
<Text variant="heading-md/bold">No plugin detected</Text>
<Text variant="text-sm/normal" color="text-muted">
We couldn't confidently identify a plugin from this call
stack. If you recently installed or updated plugins, try
disabling the most recent ones first.
</Text>
{!showBisectUI ? (
<Button
text="Start plugin bisect"
onPress={() => {
try {
const b = getBisectBatches();
if (!b || b.length === 0) {
// nothing to bisect
setBisectBatches([]);
setShowBisectUI(false);
props.rerender();
return;
}
setBisectBatches(b);
setBisectIndex(0);
setShowBisectUI(true);
} catch (e) {
console.error(e);
}
}}
/>
) : (
<>
<Text variant="text-md/normal">
Bisect step {bisectIndex + 1} of {batches.length}
</Text>
{(batches[bisectIndex] || []).map((id) => {
const manifest = registeredPlugins.get(id);
const name =
manifest?.display?.name ?? manifest?.name ?? id;
return (
<View
key={id}
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Text>{name}</Text>
<View style={{ flexDirection: "row", gap: 8 }}>
<Button
size="sm"
variant="destructive"
text="Disable"
onPress={async () => {
try {
await disablePlugin(id);
props.rerender();
} catch (e) {
console.error(e);
}
}}
/>
<Button
size="sm"
text="Open"
onPress={() => {
try {
const Comp =
getPluginSettingsComponent(id);
if (Comp)
navigation.push(
"SHIGGYCORD_CUSTOM_PAGE",
{
render: () => <Comp />,
title: name,
},
);
} catch (e) {
console.error(e);
}
}}
/>
</View>
</View>
);
})}
<View style={{ flexDirection: "row", gap: 8 }}>
<Button
text="Disable batch"
variant="destructive"
onPress={async () => {
try {
const batch = bisectBatches[bisectIndex] || [];
for (const id of batch) await disablePlugin(id);
props.rerender();
} catch (e) {
console.error(e);
}
}}
/>
<Button
text="Next step"
onPress={() => {
setBisectIndex((i) =>
Math.min(i + 1, bisectBatches.length - 1),
);
}}
/>
<Button
text="Stop"
variant="secondary"
onPress={() => setShowBisectUI(false)}
/>
</View>
</>
)}
</View>
</Card>
);
})()}
{hasStack(props.error) && <ErrorStackCard error={props.error} />}
{isComponentStack(props.error) ? (
<ErrorComponentStackCard

View file

@ -26,6 +26,7 @@ import {
import { findByProps } from "@metro/wrappers";
import { semanticColors } from "@ui/color";
import { ErrorBoundary } from "@ui/components";
import ErrorBoundaryScreen from "@core/ui/reporter/components/ErrorBoundaryScreen";
import { createStyles, TextStyleSheet } from "@ui/styles";
import { NativeModules } from "react-native";
import { ScrollView, StyleSheet } from "react-native";
@ -234,7 +235,13 @@ export default function Developer() {
label: Strings.SHIGGYCORD,
onPress: () =>
navigation.push("SHIGGYCORD_CUSTOM_PAGE", {
render: () => <undefined />,
// Render ShiggyCord's ErrorBoundaryScreen directly so the custom UI is shown.
render: () => (
<ErrorBoundaryScreen
error={new Error("ShiggyCord test crash")}
rerender={() => {}}
/>
),
}),
},
{

View file

@ -1,4 +1,4 @@
// import patchErrorBoundary from "@core/debug/patches/patchErrorBoundary";
import patchErrorBoundary from "@core/debug/patches/patchErrorBoundary";
import initFixes from "@core/fixes";
import { initFetchI18nStrings } from "@core/i18n";
import initSettings from "@core/ui/settings";
@ -33,6 +33,7 @@ export default async () => {
patchLogHook(),
patchCommands(),
patchJsx(),
patchErrorBoundary(),
initVendettaObject(),
initFetchI18nStrings(),
initSettings(),

View file

@ -367,37 +367,80 @@ export async function startPlugin(
try {
// jsPath should always exists when the plugin is installed, unless the storage is corrupted
const iife = await readFile(manifest.jsPath!!);
// Set a global marker so runtime/async errors can be attributed to the plugin
// This is a minimal, best-effort context marker. It will be cleared immediately
// after the synchronous evaluation step below.
try {
(window as any).__SHIGGY_CURRENT_PLUGIN = id;
} catch {
// ignore if environment prevents writing to window
}
var instantiator = globalEvalWithSourceUrl(
`(bunny,definePlugin)=>{${iife};return plugin?.default ?? plugin;}`,
`bunny-plugin/${id}-${manifest.version}`,
) as PluginInstantiator;
} catch (error) {
throw new Error(
// Annotate parsing errors with plugin id to aid attribution in ErrorBoundary
const e = new Error(
"An error occured while parsing plugin's code, possibly a syntax error?",
{ cause: error },
);
) as any;
e.pluginId = id;
throw e;
} finally {
// Clear the immediate evaluation marker - instantiation happens next.
try {
(window as any).__SHIGGY_CURRENT_PLUGIN = null;
} catch {}
}
// Stage two, load the plugin
try {
const api = createBunnyPluginApi(id);
pluginInstance = instantiator(api.object, (p) => {
return Object.assign(p, {
manifest,
}) as t.PluginInstanceInternal;
});
// During instantiation we again mark the current plugin so synchronous
// work performed by the plugin's top-level code is attributable.
try {
(window as any).__SHIGGY_CURRENT_PLUGIN = id;
} catch {}
if (!pluginInstance)
throw new Error(
const api = createBunnyPluginApi(id);
// Wrap instantiator call to annotate errors with plugin id if they escape here.
try {
pluginInstance = instantiator(api.object, (p) => {
return Object.assign(p, {
manifest,
}) as t.PluginInstanceInternal;
});
} catch (innerError) {
const e = new Error(
"An error occured while instantiating plugin's code",
) as any;
e.cause = innerError;
e.pluginId = id;
throw e;
}
if (!pluginInstance) {
const e = new Error(
`Plugin '${id}' does not export a valid plugin instance`,
);
) as any;
e.pluginId = id;
throw e;
}
apiObjects.set(id, api);
pluginInstances.set(id, pluginInstance);
} catch (error) {
throw new Error("An error occured while instantiating plugin's code", {
cause: error,
});
// propagate already-annotated error or annotate as fallback
if (!(error as any).pluginId) (error as any).pluginId = id;
throw error;
} finally {
// Always clear the global plugin marker after instantiation attempt.
try {
(window as any).__SHIGGY_CURRENT_PLUGIN = null;
} catch {}
}
} else {
pluginInstance = corePluginInstances.get(id)!;
@ -432,6 +475,58 @@ export function stopPlugin(id: string) {
pluginInstances.delete(id);
}
/**
* Utility: produce a sequence of bisect batches for enabled external plugins.
* This returns an array of arrays where each returned array is the set of plugin
* ids you should disable in that bisect step (simple binary-halving strategy).
*
* The UI can use these batches to guide a manual binary-search disabling flow:
* - Disable all plugin ids in batches[0], reproduce crash
* - If crash persists, disable batches[1], otherwise re-enable batches[0] and disable complementary half, etc.
*
* This function is intentionally read-only; it only computes batches. The UI
* should call `disablePlugin(id)` / `enablePlugin(id, ...)` itself to toggle plugins.
*/
export function getBisectBatches(maxPerStep = 50): string[][] {
// Collect external enabled plugin ids
const enabled: string[] = [];
for (const [id, manifest] of registeredPlugins) {
try {
const isExternal = (manifest as any).parentRepository != null;
const enabledFlag = Boolean(pluginSettings[id]?.enabled);
if (!isExternal) continue;
if (!enabledFlag) continue;
enabled.push(id);
} catch {
// ignore corrupt manifests
}
}
// Limit to a reasonable number to avoid overwhelming the user
const ids = enabled.slice(0, maxPerStep);
if (ids.length <= 1) return ids.length === 1 ? [[ids[0]]] : [];
// Binary-halving batches:
// On step n, disable the first half of the current candidate set.
const batches: string[][] = [];
let candidates = ids.slice();
while (candidates.length > 1) {
const half = Math.ceil(candidates.length / 2);
const toDisable = candidates.slice(0, half);
batches.push(toDisable);
// For the next iteration assume we will test the complement; compute complement
candidates = candidates.slice(half);
// If complement has only one left, add it as final step
if (candidates.length === 1) {
batches.push([candidates[0]]);
break;
}
}
return batches;
}
export async function updateAllRepository() {
try {
await updateRepository(OFFICIAL_PLUGINS_REPO_URL);

View file

@ -9,15 +9,52 @@ export const CustomPageRenderer = React.memo(() => {
const navigation = NavigationNative.useNavigation();
const route = NavigationNative.useRoute();
const { render: PageComponent, ...args } = route.params;
// Accept `render` as a render-function (recommended) or a component.
// Respect `noErrorBoundary` param to allow callers to opt out of wrapping.
const {
render: PageComponent,
noErrorBoundary = false,
...args
} = route.params ?? {};
React.useEffect(() => void navigation.setOptions({ ...args }), []);
return (
<ErrorBoundary>
<PageComponent />
</ErrorBoundary>
React.useEffect(
() => void navigation.setOptions({ ...args }),
[navigation, args],
);
// Normalize to a React element. PageComponent may be:
// - a function returning a React element (render wrapper): () => <Comp />
// - a React component type: MyComponent
// - a React element (unlikely), in which case return as-is.
const element = React.useMemo(() => {
if (!PageComponent) return null;
try {
// If caller provided a render function (common in this codebase), invoke it to get the element.
if (typeof PageComponent === "function") {
// When PageComponent is a component type, invoking it directly could be unsafe for class components.
// However, most callers pass a zero-arg render function that returns an element (e.g. () => <MyComp />),
// so prefer calling it. If it returns something that looks like a React element, use it.
const maybeEl = PageComponent();
return maybeEl;
}
// Otherwise assume it's already a React element.
return PageComponent as any;
} catch {
// As a last resort, attempt JSX-style rendering so component types still work.
try {
return <PageComponent />;
} catch {
return null;
}
}
}, [PageComponent]);
if (noErrorBoundary) {
return element;
}
return <ErrorBoundary>{element}</ErrorBoundary>;
});
export function wrapOnPress(

12
src/modules.d.ts vendored
View file

@ -1,8 +1,18 @@
declare module "bunny-build-info" {
const version: "1.1.4-2";
const version: "1.1.4-3";
}
declare module "*.png" {
const str: string;
export default str;
}
/**
* Optional runtime helper used by the startup sequence.
* Declared here to satisfy the typechecker when the file isn't present.
*/
declare module "@core/debug/toggleCorePlugins" {
type ToggleOptions = { offDuration?: number };
const _default: (opts?: ToggleOptions) => Promise<void>;
export default _default;
}