Expose collectAllPluginIds and improve error UI

This commit is contained in:
kmmiio99o 2025-10-18 23:59:57 +02:00
parent c2009a7c0b
commit 6af355ea2a

View file

@ -4,15 +4,20 @@ 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";
import { Codeblock, ErrorBoundary } from "@lib/ui/components";
import { Codeblock } from "@lib/ui/components";
import { createStyles } from "@lib/ui/styles";
import { tokens } from "@metro/common";
import { showToast } from "@lib/ui/toasts";
import {
Button,
Card,
SafeAreaProvider,
SafeAreaView,
Text,
TableRowGroup,
TableRow,
TableSwitchRow,
Stack,
} from "@metro/common/components";
import { ScrollView, View } from "react-native";
@ -22,7 +27,10 @@ import {
getBisectBatches,
enablePlugin,
getPluginSettingsComponent,
isPluginEnabled,
pluginSettings,
} from "@lib/addons/plugins";
import { getCorePlugins } from "@core/plugins";
import ErrorComponentStackCard from "./ErrorComponentStackCard";
import ErrorStackCard from "./ErrorStackCard";
import { NavigationNative } from "@metro/common";
@ -37,6 +45,74 @@ const useStyles = createStyles({
},
});
/**
* Module-level helper to collect all known plugin IDs (core + registered externals + installed keys).
* Exported and attached to window so external callers (or code executing before React mounts)
* can call it without needing the React component to mount first.
*/
export function collectAllPluginIdsModule(): string[] {
const ids = new Set<string>();
// registeredPlugins (includes externals known from repos)
try {
if (
typeof registeredPlugins !== "undefined" &&
registeredPlugins &&
typeof (registeredPlugins as any).keys === "function"
) {
for (const k of registeredPlugins.keys()) ids.add(k);
}
} catch {}
// installed plugin settings (may include plugins not registered yet)
try {
if (typeof pluginSettings !== "undefined" && pluginSettings) {
for (const k of Object.keys(pluginSettings || {})) ids.add(k);
}
} catch {}
return Array.from(ids);
}
// Provide a global alias for callers that expect the global to exist early.
// Use both spellings seen in various callers.
// Provide global aliases (some callers used plural/singular variants)
// Ensure a safe, early no-op fallback is present on the global object so
// callers that run before this module is evaluated won't throw a ReferenceError.
(function () {
try {
const g =
typeof globalThis !== "undefined"
? globalThis
: typeof window !== "undefined"
? window
: {};
// If the global already has a function, don't overwrite it.
if (typeof (g as any).collectAllPluginsIds !== "function") {
Object.defineProperty(g, "collectAllPluginsIds", {
configurable: true,
writable: true,
enumerable: false,
value: collectAllPluginIdsModule,
});
}
if (typeof (g as any).collectAllPluginIds !== "function") {
Object.defineProperty(g, "collectAllPluginIds", {
configurable: true,
writable: true,
enumerable: false,
value: collectAllPluginIdsModule,
});
}
} catch (e) {
// Best-effort only — avoid throwing during module evaluation
try {
console.error(
"[ShiggyCord] failed to install global collectAllPluginIds alias",
e,
);
} catch {}
}
})();
// Component entrypoint
export default function ErrorBoundaryScreen(props: {
error: Error;
rerender: () => void;
@ -52,317 +128,303 @@ export default function ErrorBoundaryScreen(props: {
const [bisectIndex, setBisectIndex] = React.useState<number>(0);
const [showBisectUI, setShowBisectUI] = React.useState<boolean>(false);
// Use a safe fallback so renders don't throw if state hasn't been initialized yet
const batches = bisectBatches ?? [];
const currentBatch = batches[bisectIndex] ?? [];
return (
<ErrorBoundary>
<SafeAreaProvider>
<SafeAreaView style={styles.container}>
<View style={{ gap: 4 }}>
<Text variant="display-lg">Uh oh.</Text>
<Text variant="text-md/normal">
A crash occurred while rendering a component. This could be caused
by a plugin, ShiggyCord, or Discord itself.
</Text>
<Text variant="text-sm/normal" color="text-muted">
{debugInfo.os.name}; {debugInfo.discord.build} (
{debugInfo.discord.version}); {debugInfo.bunny.version}
</Text>
</View>
<ScrollView fadingEdgeLength={64} contentContainerStyle={{ gap: 12 }}>
<Codeblock selectable={true}>{props.error.message}</Codeblock>
<SafeAreaProvider>
<SafeAreaView style={styles.container}>
<View style={{ gap: 4 }}>
<Text variant="display-lg">Uh oh.</Text>
<Text variant="text-md/normal">
A crash occurred while rendering a component. This could be caused
by a plugin, ShiggyCord, or Discord itself.
</Text>
<Text variant="text-sm/normal" color="text-muted">
{debugInfo.os.name}; {debugInfo.discord.build} (
{debugInfo.discord.version}); {debugInfo.bunny.version}
</Text>
</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;
{(() => {
// 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;
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">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 variant="heading-md/bold">Suspected plugin</Text>
<Text variant="text-md/normal">
This error is associated with a plugin based on runtime
context.
</Text>
{!showBisectUI ? (
<Text variant="text-sm/normal" color="text-muted">
{name} ({explicitPluginId})
</Text>
<View style={{ flexDirection: "row", gap: 8 }}>
<Button
text="Start plugin bisect"
onPress={() => {
variant="destructive"
text="Disable plugin"
onPress={async () => {
try {
const b = getBisectBatches();
if (!b || b.length === 0) {
// nothing to bisect
setBisectBatches([]);
setShowBisectUI(false);
props.rerender();
return;
}
setBisectBatches(b);
setBisectIndex(0);
setShowBisectUI(true);
await disablePlugin(explicitPluginId);
// Do not auto-retry render; let the user decide when to retry.
showToast("Disabled plugin " + explicitPluginId);
} 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>
</>
)}
<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>
);
})()}
}
{hasStack(props.error) && <ErrorStackCard error={props.error} />}
{isComponentStack(props.error) ? (
<ErrorComponentStackCard
componentStack={props.error.componentStack}
/>
) : null}
</ScrollView>
<Card style={{ gap: 6 }}>
<Button
text="Reload Discord"
onPress={() => BundleUpdaterManager.reload()}
// 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 {
console.log(
"[ShiggyCord][ErrorBoundaryScreen] Disable suspected plugin clicked:",
s.id,
);
await disablePlugin(s.id);
// Avoid automatically retrying the crashed render here.
showToast("Disabled plugin " + s.id);
console.log(
"[ShiggyCord][ErrorBoundaryScreen] Disabled suspected plugin:",
s.id,
);
} 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) Show only built-in (core) plugins from the bundle (no externals)
const corePlugins = getCorePlugins();
const ids = Object.keys(corePlugins);
return (
<Card>
<View style={{ gap: 8 }}>
<Text variant="heading-md/bold">Core plugins</Text>
<Text variant="text-sm/normal" color="text-muted">
Toggle built-in plugins below. After toggling, press "Retry
Render" to re-check the crash.
</Text>
{ids.length === 0 ? (
<Text variant="text-sm/normal" color="text-muted">
No core plugins found.
</Text>
) : (
<TableRowGroup title="Core plugins">
{ids.map((id) => {
const entry = (corePlugins as any)[id];
const name =
entry?.default?.manifest?.display?.name ??
entry?.default?.manifest?.name ??
id;
let enabled = false;
try {
enabled = Boolean(isPluginEnabled(id));
} catch {
enabled = false;
}
// Protect specific core plugins from being toggled (same heuristic as PluginCard)
const idLower = ((id || "") as string).toLowerCase();
const isProtectedCore =
idLower.includes("quickinstall") ||
idLower === "bunny.badges";
return (
<TableSwitchRow
key={id}
label={name}
value={enabled}
disabled={isProtectedCore}
onValueChange={async (v: boolean) => {
try {
if (v) {
await enablePlugin(id, true);
} else {
await disablePlugin(id);
}
// lightweight refresh so UI reflects the change
setBisectBatches((prev) =>
prev ? [...prev] : [],
);
showToast(
`${v ? "Enabled" : "Disabled"} ${name}`,
);
} catch (e) {
console.error(
"[ShiggyCord][ErrorBoundaryScreen] failed to toggle core plugin",
id,
e,
);
showToast(
`Failed to ${v ? "enable" : "disable"} ${name}`,
);
}
}}
/>
);
})}
</TableRowGroup>
)}
<View style={{ flexDirection: "row", gap: 8 }}>
<Button
text="Refresh list"
onPress={() => {
try {
// Force UI refresh of core list
setBisectBatches((prev) => (prev ? [...prev] : []));
showToast("Core plugin list refreshed");
} catch (e) {
console.error(e);
}
}}
/>
</View>
</View>
</Card>
);
})()}
{hasStack(props.error) && <ErrorStackCard error={props.error} />}
{isComponentStack(props.error) ? (
<ErrorComponentStackCard
componentStack={props.error.componentStack}
/>
{!settings.safeMode?.enabled && (
<Button
text="Reload in Safe Mode"
onPress={() => toggleSafeMode()}
/>
)}
) : null}
</ScrollView>
<Card style={{ gap: 6 }}>
<Button
text="Reload Discord"
onPress={() => BundleUpdaterManager.reload()}
/>
{!settings.safeMode?.enabled && (
<Button
variant="destructive"
text="Retry Render"
onPress={() => props.rerender()}
text="Reload in Safe Mode"
onPress={() => toggleSafeMode()}
/>
</Card>
</SafeAreaView>
</SafeAreaProvider>
</ErrorBoundary>
)}
<Button
variant="destructive"
text="Retry Render"
onPress={() => {
try {
console.log(
"[ShiggyCord][ErrorBoundaryScreen] Retry Render clicked",
);
} catch {}
props.rerender();
}}
/>
</Card>
</SafeAreaView>
</SafeAreaProvider>
);
}