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:
parent
d3754c2195
commit
c2009a7c0b
9 changed files with 589 additions and 36 deletions
|
|
@ -1,4 +1,4 @@
|
|||
# ShiggyCord
|
||||
# ShiggyCord [](https://discord.gg/nQykFF9Ud6)
|
||||
|
||||
An unofficial fork of ShiggyCord, made just for fun
|
||||
Idea taken from Rosiecord
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ const config = {
|
|||
constModules: {
|
||||
globals: {
|
||||
"bunny-build-info": {
|
||||
version: `"1.1.4-2"`,
|
||||
version: `"1.1.4-3"`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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={() => {}}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
12
src/modules.d.ts
vendored
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue