[SafeMode] Initial implementation (#61)

* [UI > Components] Add Discord's button

* [SafeMode] Initial work, basic ErrorBoundary patch

* [SafeMode] Custom error boundary (#57)

* [SafeMode] Add mostly complete custom error boundary

* [SafeMode] Use Button from @ui/components in error boundary

* [SafeMode] Wrap the error boundary in our own error boundary

* [SafeMode > ErrorBoundary] Code-style changes

---------

Co-authored-by: Beef <beefers@riseup.net>

* [TS] Add basic type for Discord's button

* [UI] Move Codeblock to components, and use it

* [UI > Settings] Allow disabling the ErrorBoundary in CustomPage

* [UI > Settings] Move the ErrorBoundary triggers to Developer

* [TS] Add Codeblock to types

* [TS] Use ButtonColors in Button type

* [SafeMode > ErrorBoundary] Rework

* [UI] Add HelpMessage to components

* [SafeMode] Proper implementation

* [Global] SafeMode is optional (#59)

* [UI > Developer] Restore the balance

* [SafeMode > ErrorBoundary] Optimise for tablet UI

* [SafeMode] Last-minute fixes

---------

Co-authored-by: Jack <30497388+FieryFlames@users.noreply.github.com>
Co-authored-by: Jack Matthews <jm5112356@gmail.com>
This commit is contained in:
Beef 2023-04-13 18:42:14 +00:00 committed by GitHub
parent f85fc4b00c
commit 5344f0017a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 293 additions and 47 deletions

15
src/def.d.ts vendored
View file

@ -18,7 +18,13 @@ interface SummaryProps {
} }
interface ErrorBoundaryProps { interface ErrorBoundaryProps {
children: JSX.Element | JSX.Element[], children: JSX.Element | JSX.Element[];
}
interface CodeblockProps {
selectable?: boolean;
style?: _RN.TextStyle;
children?: string;
} }
// Helper types for API functions // Helper types for API functions
@ -125,6 +131,10 @@ interface Theme {
interface Settings { interface Settings {
debuggerUrl: string; debuggerUrl: string;
developerSettings: boolean; developerSettings: boolean;
safeMode?: {
enabled: boolean;
currentThemeId?: string;
};
} }
interface ApplicationCommand { interface ApplicationCommand {
@ -404,9 +414,12 @@ interface VendettaObject {
General: PropIntellisense<"Button" | "Text" | "View">; General: PropIntellisense<"Button" | "Text" | "View">;
Search: _React.ComponentType; Search: _React.ComponentType;
Alert: _React.ComponentType; Alert: _React.ComponentType;
Button: _React.ComponentType<any> & { Looks: any, Colors: ButtonColors, Sizes: any };
HelpMessage: _React.ComponentType;
// Vendetta // Vendetta
Summary: _React.ComponentType<SummaryProps>; Summary: _React.ComponentType<SummaryProps>;
ErrorBoundary: _React.ComponentType<ErrorBoundaryProps>; ErrorBoundary: _React.ComponentType<ErrorBoundaryProps>;
Codeblock: _React.ComponentType<CodeblockProps>;
} }
toasts: { toasts: {
showToast: (content: string, asset: number) => void; showToast: (content: string, asset: number) => void;

View file

@ -3,6 +3,7 @@ import { patchCommands } from "@lib/commands";
import { initPlugins } from "@lib/plugins"; import { initPlugins } from "@lib/plugins";
import { patchAssets } from "@ui/assets"; import { patchAssets } from "@ui/assets";
import initQuickInstall from "@ui/quickInstall"; import initQuickInstall from "@ui/quickInstall";
import initSafeMode from "@ui/safeMode";
import initSettings from "@ui/settings"; import initSettings from "@ui/settings";
import initFixes from "@lib/fixes"; import initFixes from "@lib/fixes";
import logger from "@lib/logger"; import logger from "@lib/logger";
@ -15,6 +16,7 @@ export default async () => {
patchAssets(), patchAssets(),
patchCommands(), patchCommands(),
initFixes(), initFixes(),
initSafeMode(),
initSettings(), initSettings(),
initQuickInstall(), initQuickInstall(),
]); ]);

View file

@ -1,12 +1,27 @@
import { RNConstants } from "@types"; import { RNConstants } from "@types";
import { ReactNative as RN } from "@metro/common"; import { ReactNative as RN } from "@metro/common";
import { after } from "@lib/patcher"; import { after } from "@lib/patcher";
import { ClientInfoManager, DeviceManager } from "@lib/native"; import { ClientInfoManager, DeviceManager, BundleUpdaterManager } from "@lib/native";
import { getCurrentTheme, selectTheme } from "@lib/themes";
import { getAssetIDByName } from "@ui/assets"; import { getAssetIDByName } from "@ui/assets";
import { showToast } from "@ui/toasts"; import { showToast } from "@ui/toasts";
import settings from "@lib/settings";
import logger from "@lib/logger"; import logger from "@lib/logger";
export let socket: WebSocket; export let socket: WebSocket;
export async function toggleSafeMode() {
settings.safeMode = { ...settings.safeMode, enabled: !settings.safeMode?.enabled }
if (window.__vendetta_loader?.features.themes) {
if (getCurrentTheme()?.id) settings.safeMode!.currentThemeId = getCurrentTheme()!.id;
if (settings.safeMode?.enabled) {
await selectTheme("default");
} else if (settings.safeMode?.currentThemeId) {
await selectTheme(settings.safeMode?.currentThemeId);
}
}
setTimeout(BundleUpdaterManager.reload);
}
export function connectToDebugger(url: string) { export function connectToDebugger(url: string) {
if (socket !== undefined && socket.readyState !== WebSocket.CLOSED) socket.close(); if (socket !== undefined && socket.readyState !== WebSocket.CLOSED) socket.close();
@ -16,7 +31,7 @@ export function connectToDebugger(url: string) {
} }
socket = new WebSocket(`ws://${url}`); socket = new WebSocket(`ws://${url}`);
socket.addEventListener("open", () => showToast("Connected to debugger.", getAssetIDByName("Check"))); socket.addEventListener("open", () => showToast("Connected to debugger.", getAssetIDByName("Check")));
socket.addEventListener("message", (message: any) => { socket.addEventListener("message", (message: any) => {
try { try {
@ -32,7 +47,7 @@ export function connectToDebugger(url: string) {
}); });
} }
export function patchLogHook() { export function patchLogHook() {
const unpatch = after("nativeLoggingHook", globalThis, (args) => { const unpatch = after("nativeLoggingHook", globalThis, (args) => {
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ message: args[0], level: args[1] })); if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ message: args[0], level: args[1] }));
logger.log(args[0]); logger.log(args[0]);

View file

@ -1,6 +1,7 @@
import { Indexable, PluginManifest, Plugin } from "@types"; import { Indexable, PluginManifest, Plugin } from "@types";
import { awaitSyncWrapper, createMMKVBackend, createStorage, wrapSync } from "@lib/storage"; import { awaitSyncWrapper, createMMKVBackend, createStorage, wrapSync } from "@lib/storage";
import { MMKVManager } from "@lib/native"; import { MMKVManager } from "@lib/native";
import settings from "@lib/settings";
import logger, { logModule } from "@lib/logger"; import logger, { logModule } from "@lib/logger";
import safeFetch from "@utils/safeFetch"; import safeFetch from "@utils/safeFetch";
@ -76,9 +77,11 @@ export async function startPlugin(id: string) {
if (!plugin) throw new Error("Attempted to start non-existent plugin"); if (!plugin) throw new Error("Attempted to start non-existent plugin");
try { try {
const pluginRet: EvaledPlugin = await evalPlugin(plugin); if (!settings.safeMode?.enabled) {
loadedPlugins[id] = pluginRet; const pluginRet: EvaledPlugin = await evalPlugin(plugin);
pluginRet.onLoad?.(); loadedPlugins[id] = pluginRet;
pluginRet.onLoad?.();
}
plugin.enabled = true; plugin.enabled = true;
} catch(e) { } catch(e) {
logger.error(`Plugin ${plugin.id} errored whilst loading, and will be unloaded`, e); logger.error(`Plugin ${plugin.id} errored whilst loading, and will be unloaded`, e);
@ -99,15 +102,17 @@ export function stopPlugin(id: string, disable = true) {
const plugin = plugins[id]; const plugin = plugins[id];
const pluginRet = loadedPlugins[id]; const pluginRet = loadedPlugins[id];
if (!plugin) throw new Error("Attempted to stop non-existent plugin"); if (!plugin) throw new Error("Attempted to stop non-existent plugin");
if (!pluginRet) throw new Error("Attempted to stop a non-started plugin");
try { if (!settings.safeMode?.enabled) {
pluginRet.onUnload?.(); try {
} catch(e) { pluginRet?.onUnload?.();
logger.error(`Plugin ${plugin.id} errored whilst unloading`, e); } catch(e) {
logger.error(`Plugin ${plugin.id} errored whilst unloading`, e);
}
delete loadedPlugins[id];
} }
delete loadedPlugins[id];
disable && (plugin.enabled = false); disable && (plugin.enabled = false);
} }
@ -120,13 +125,16 @@ export function removePlugin(id: string) {
} }
export async function initPlugins() { export async function initPlugins() {
await awaitSyncWrapper(settings);
await awaitSyncWrapper(plugins); await awaitSyncWrapper(plugins);
const allIds = Object.keys(plugins); const allIds = Object.keys(plugins);
// Loop over any plugin that is enabled, update it if allowed, then start it. if (!settings.safeMode?.enabled) {
await Promise.allSettled(allIds.filter(pl => plugins[pl].enabled).map(async (pl) => (plugins[pl].update && await fetchPlugin(pl), await startPlugin(pl)))); // Loop over any plugin that is enabled, update it if allowed, then start it.
// Wait for the above to finish, then update all disabled plugins that are allowed to. await Promise.allSettled(allIds.filter(pl => plugins[pl].enabled).map(async (pl) => (plugins[pl].update && await fetchPlugin(pl), await startPlugin(pl))));
allIds.filter(pl => !plugins[pl].enabled && plugins[pl].update).forEach(pl => fetchPlugin(pl)); // Wait for the above to finish, then update all disabled plugins that are allowed to.
allIds.filter(pl => !plugins[pl].enabled && plugins[pl].update).forEach(pl => fetchPlugin(pl));
};
return stopAllPlugins; return stopAllPlugins;
} }

View file

@ -22,7 +22,7 @@ export default async (unloads: any[]): Promise<VendettaObject> => ({
metro: { ...metro, common: { ...common } }, metro: { ...metro, common: { ...common } },
constants, constants,
utils, utils,
debug: utils.without(debug, "versionHash", "patchLogHook"), debug: utils.without(debug, "versionHash", "patchLogHook", "toggleSafeMode"),
ui: { ui: {
components, components,
toasts, toasts,

View file

@ -0,0 +1,30 @@
import { CodeblockProps } from "@types";
import { ReactNative as RN, stylesheet, constants } from "@metro/common";
import { semanticColors } from "@ui/color";
const styles = stylesheet.createThemedStyleSheet({
codeBlock: {
fontFamily: constants.Fonts.CODE_SEMIBOLD,
fontSize: 12,
textAlignVertical: "center",
backgroundColor: semanticColors.BACKGROUND_SECONDARY,
color: semanticColors.TEXT_NORMAL,
borderWidth: 1,
borderRadius: 4,
borderColor: semanticColors.BACKGROUND_TERTIARY,
padding: 10,
},
});
// iOS doesn't support the selectable property on RN.Text...
const InputBasedCodeblock = ({ style, children }: CodeblockProps) => <RN.TextInput editable={false} multiline style={[styles.codeBlock, style && style]} value={children} />
const TextBasedCodeblock = ({ selectable, style, children }: CodeblockProps) => <RN.Text selectable={selectable} style={[styles.codeBlock, style && style]}>{children}</RN.Text>
export default function Codeblock({ selectable, style, children }: CodeblockProps) {
if (!selectable) return <TextBasedCodeblock style={style} children={children} />;
return RN.Platform.select({
ios: <InputBasedCodeblock style={style} children={children} />,
default: <TextBasedCodeblock style={style} children={children} selectable />,
});
}

View file

@ -1,7 +1,6 @@
import { ErrorBoundaryProps } from "@types"; import { ErrorBoundaryProps } from "@types";
import { React, ReactNative as RN, stylesheet, constants } from "@metro/common"; import { React, ReactNative as RN, stylesheet, constants } from "@metro/common";
import { findByProps } from "@metro/filters"; import { Forms, Button, Codeblock } from "@ui/components";
import { Forms } from "@ui/components";
import { semanticColors } from "@ui/color"; import { semanticColors } from "@ui/color";
interface ErrorBoundaryState { interface ErrorBoundaryState {
@ -9,8 +8,6 @@ interface ErrorBoundaryState {
errText?: string; errText?: string;
} }
const Button = findByProps("Looks", "Colors", "Sizes") as any;
const styles = stylesheet.createThemedStyleSheet({ const styles = stylesheet.createThemedStyleSheet({
view: { view: {
flex: 1, flex: 1,
@ -22,15 +19,6 @@ const styles = stylesheet.createThemedStyleSheet({
textAlign: "center", textAlign: "center",
marginBottom: 5, marginBottom: 5,
}, },
codeblock: {
fontFamily: constants.Fonts.CODE_SEMIBOLD,
includeFontPadding: false,
fontSize: 12,
backgroundColor: semanticColors.BACKGROUND_SECONDARY,
padding: 5,
borderRadius: 5,
marginBottom: 5,
}
}); });
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> { export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
@ -47,7 +35,7 @@ export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, E
return ( return (
<RN.ScrollView style={styles.view}> <RN.ScrollView style={styles.view}>
<Forms.FormText style={styles.title}>Uh oh.</Forms.FormText> <Forms.FormText style={styles.title}>Uh oh.</Forms.FormText>
<Forms.FormText style={styles.codeblock}>{this.state.errText}</Forms.FormText> <Codeblock selectable style={{ marginBottom: 5 }}>{this.state.errText}</Codeblock>
<Button <Button
color={Button.Colors.RED} color={Button.Colors.RED}
size={Button.Sizes.MEDIUM} size={Button.Sizes.MEDIUM}

View file

@ -5,7 +5,10 @@ export const Forms = findByProps("Form", "FormSection");
export const General = findByProps("Button", "Text", "View"); export const General = findByProps("Button", "Text", "View");
export const Search = findByName("StaticSearchBarContainer"); export const Search = findByName("StaticSearchBarContainer");
export const Alert = findByProps("alertDarkStyles", "alertLightStyles").default; export const Alert = findByProps("alertDarkStyles", "alertLightStyles").default;
export const Button = findByProps("Looks", "Colors", "Sizes") as React.ComponentType<any> & { Looks: any, Colors: any, Sizes: any };
export const HelpMessage = findByName("HelpMessage");
// Vendetta // Vendetta
export { default as Summary } from "@ui/components/Summary"; export { default as Summary } from "@ui/components/Summary";
export { default as ErrorBoundary } from "@ui/components/ErrorBoundary"; export { default as ErrorBoundary } from "@ui/components/ErrorBoundary";
export { default as Codeblock } from "@ui/components/Codeblock";

133
src/ui/safeMode.tsx Normal file
View file

@ -0,0 +1,133 @@
import { ButtonColors } from "@types";
import { ReactNative as RN, stylesheet } from "@metro/common";
import { findByName, findByProps, findByStoreName } from "@metro/filters";
import { after } from "@lib/patcher";
import { DeviceManager } from "@lib/native";
import { toggleSafeMode } from "@lib/debug";
import { semanticColors } from "@ui/color";
import { Button, Codeblock, ErrorBoundary as _ErrorBoundary } from "@ui/components";
import settings from "@lib/settings";
const ErrorBoundary = findByName("ErrorBoundary");
// React Native's included SafeAreaView only adds padding on iOS.
const { SafeAreaView } = findByProps("useSafeAreaInsets");
// Let's just pray they have this.
const { BadgableTabBar } = findByProps("BadgableTabBar");
const ThemeStore = findByStoreName("ThemeStore");
const { TextStyleSheet } = findByProps("TextStyleSheet");
const styles = stylesheet.createThemedStyleSheet({
container: {
flex: 1,
backgroundColor: semanticColors.BACKGROUND_PRIMARY,
paddingHorizontal: 16,
},
header: {
flex: 1,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
marginVertical: 8,
},
headerTitle: {
...TextStyleSheet["heading-md/semibold"],
textAlign: "center",
textTransform: "uppercase",
color: semanticColors.HEADER_PRIMARY,
},
headerDescription: {
...TextStyleSheet["text-sm/medium"],
textAlign: "center",
color: semanticColors.TEXT_MUTED,
},
footer: {
flexDirection: DeviceManager.isTablet ? "row" : "column",
justifyContent: "flex-end",
marginVertical: 8,
},
});
interface Tab {
id: string;
title: string;
trimWhitespace?: boolean;
}
interface Button {
text: string;
// TODO: Proper types for the below
color?: string;
size?: string;
onPress: () => void;
}
const tabs: Tab[] = [
{ id: "message",title: "Message" },
{ id: "stack", title: "Stack Trace" },
{ id: "componentStack", title: "Component", trimWhitespace: true },
];
export default () => after("render", ErrorBoundary.prototype, function (this: any, _, ret) {
if (!this.state.error) return;
// Not using setState here as we don't want to cause a re-render, we want this to be set in the initial render
this.state.activeTab ??= "message";
const tabData = tabs.find(t => t.id === this.state.activeTab);
const errorText: string = this.state.error[this.state.activeTab];
// This is in the patch and not outside of it so that we can use `this`, e.g. for setting state
const buttons: Button[] = [
{ text: "Restart Discord", onPress: this.handleReload },
...!settings.safeMode?.enabled ? [{ text: "Restart in Safe Mode", onPress: toggleSafeMode }] : [],
{ text: "Retry Render", color: ButtonColors.RED, onPress: () => this.setState({ info: null, error: null }) },
]
return (
<_ErrorBoundary>
<SafeAreaView style={styles.container}>
<RN.View style={styles.header}>
<RN.Image style={{ flex: 1, resizeMode: "contain", maxHeight: 96, paddingRight: 4 }} source={ThemeStore.theme === "light" ? ret.props.lightSource : ret.props.darkSource} />
<RN.View style={{ flex: 2, paddingLeft: 4 }}>
<RN.Text style={styles.headerTitle}>{ret.props.title}</RN.Text>
<RN.Text style={styles.headerDescription}>{ret.props.body}</RN.Text>
</RN.View>
</RN.View>
<RN.View style={{ flex: 6 }}>
<RN.View style={{ paddingBottom: 8 }}>
{/* Are errors caught by ErrorBoundary guaranteed to have the component stack? */}
<BadgableTabBar
tabs={tabs}
activeTab={this.state.activeTab}
onTabSelected={(tab: string) => { this.setState({ activeTab: tab }) }}
/>
</RN.View>
<Codeblock
selectable
style={{ flex: 1, textAlignVertical: "top" }}
>
{/*
TODO: I tried to get this working as intended using regex and failed.
When trimWhitespace is true, each line should have it's whitespace removed but with it's spaces kept.
*/}
{tabData?.trimWhitespace ? errorText.split("\n").filter(i => i.length !== 0).map(i => i.trim()).join("\n") : errorText}
</Codeblock>
</RN.View>
<RN.View style={styles.footer}>
{buttons.map(button => {
const buttonIndex = buttons.indexOf(button) !== 0 ? 8 : 0;
return <Button
text={button.text}
color={button.color ?? ButtonColors.BRAND}
size={button.size ?? "small"}
onPress={button.onPress}
style={DeviceManager.isTablet ? { flex: `0.${buttons.length}`, marginLeft: buttonIndex } : { marginTop: buttonIndex }}
/>
})}
</RN.View>
</SafeAreaView>
</_ErrorBoundary>
)
});

View file

@ -48,7 +48,7 @@ interface CardProps {
index?: number; index?: number;
headerLabel: string | React.ComponentType; headerLabel: string | React.ComponentType;
headerIcon?: string; headerIcon?: string;
toggleType: "switch" | "radio"; toggleType?: "switch" | "radio";
toggleValue?: boolean; toggleValue?: boolean;
onToggleChange?: (v: boolean) => void; onToggleChange?: (v: boolean) => void;
descriptionLabel?: string | React.ComponentType; descriptionLabel?: string | React.ComponentType;
@ -66,7 +66,7 @@ export default function Card(props: CardProps) {
style={styles.header} style={styles.header}
label={props.headerLabel} label={props.headerLabel}
leading={props.headerIcon && <FormRow.Icon source={getAssetIDByName(props.headerIcon)} />} leading={props.headerIcon && <FormRow.Icon source={getAssetIDByName(props.headerIcon)} />}
trailing={props.toggleType === "switch" ? trailing={props.toggleType && (props.toggleType === "switch" ?
(<FormSwitch (<FormSwitch
style={RN.Platform.OS === "android" && { marginVertical: -15 }} style={RN.Platform.OS === "android" && { marginVertical: -15 }}
value={props.toggleValue} value={props.toggleValue}
@ -80,7 +80,7 @@ export default function Card(props: CardProps) {
{/* TODO: Look into making this respect brand color */} {/* TODO: Look into making this respect brand color */}
<FormRadio selected={props.toggleValue} /> <FormRadio selected={props.toggleValue} />
</RN.Pressable>) </RN.Pressable>)
} )}
/> />
<FormRow <FormRow
label={props.descriptionLabel} label={props.descriptionLabel}

View file

@ -2,9 +2,11 @@ import { ButtonColors, Theme } from "@types";
import { clipboard } from "@metro/common"; import { clipboard } from "@metro/common";
import { fetchTheme, removeTheme, selectTheme } from "@lib/themes"; import { fetchTheme, removeTheme, selectTheme } from "@lib/themes";
import { BundleUpdaterManager } from "@lib/native"; import { BundleUpdaterManager } from "@lib/native";
import { useProxy } from "@lib/storage";
import { getAssetIDByName } from "@ui/assets"; import { getAssetIDByName } from "@ui/assets";
import { showConfirmationAlert } from "@ui/alerts"; import { showConfirmationAlert } from "@ui/alerts";
import { showToast } from "@ui/toasts"; import { showToast } from "@ui/toasts";
import settings from "@lib/settings";
import Card from "@ui/settings/components/Card"; import Card from "@ui/settings/components/Card";
interface ThemeCardProps { interface ThemeCardProps {
@ -18,6 +20,7 @@ async function selectAndReload(value: boolean, id: string) {
} }
export default function ThemeCard({ theme, index }: ThemeCardProps) { export default function ThemeCard({ theme, index }: ThemeCardProps) {
useProxy(settings);
const [removed, setRemoved] = React.useState(false); const [removed, setRemoved] = React.useState(false);
// This is needed because of React™ // This is needed because of React™
@ -30,7 +33,7 @@ export default function ThemeCard({ theme, index }: ThemeCardProps) {
index={index} index={index}
headerLabel={`${theme.data.name} ${authors ? `by ${authors.map(i => i.name).join(", ")}` : ""}`} headerLabel={`${theme.data.name} ${authors ? `by ${authors.map(i => i.name).join(", ")}` : ""}`}
descriptionLabel={theme.data.description ?? "No description."} descriptionLabel={theme.data.description ?? "No description."}
toggleType="radio" toggleType={!settings.safeMode?.enabled ? "radio" : undefined}
toggleValue={theme.selected} toggleValue={theme.selected}
onToggleChange={(v: boolean) => { onToggleChange={(v: boolean) => {
selectAndReload(v, theme.id); selectAndReload(v, theme.id);

View file

@ -3,6 +3,7 @@ import { findByName } from "@metro/filters";
import { after } from "@lib/patcher"; import { after } from "@lib/patcher";
import { installPlugin } from "@lib/plugins"; import { installPlugin } from "@lib/plugins";
import { installTheme } from "@lib/themes"; import { installTheme } from "@lib/themes";
import { Forms } from "@ui/components";
import findInReactTree from "@utils/findInReactTree"; import findInReactTree from "@utils/findInReactTree";
import without from "@utils/without"; import without from "@utils/without";
import ErrorBoundary from "@ui/components/ErrorBoundary"; import ErrorBoundary from "@ui/components/ErrorBoundary";
@ -13,7 +14,7 @@ import Plugins from "@ui/settings/pages/Plugins";
import Themes from "@ui/settings/pages/Themes"; import Themes from "@ui/settings/pages/Themes";
import Developer from "@ui/settings/pages/Developer"; import Developer from "@ui/settings/pages/Developer";
import AssetBrowser from "@ui/settings/pages/AssetBrowser"; import AssetBrowser from "@ui/settings/pages/AssetBrowser";
import { Forms } from "@ui/components"; import settings from "@lib/settings";
const screensModule = findByName("getScreens", false); const screensModule = findByName("getScreens", false);
const settingsModule = findByName("UserSettingsOverviewWrapper", false); const settingsModule = findByName("UserSettingsOverviewWrapper", false);
@ -36,7 +37,7 @@ export default function initSettings() {
VendettaThemes: { VendettaThemes: {
title: "Themes", title: "Themes",
render: Themes, render: Themes,
headerRight: () => <InstallButton alertTitle="Install Theme" installFunction={installTheme} />, headerRight: !settings.safeMode?.enabled && (() => <InstallButton alertTitle="Install Theme" installFunction={installTheme} />),
}, },
VendettaDeveloper: { VendettaDeveloper: {
title: "Developer", title: "Developer",
@ -48,11 +49,10 @@ export default function initSettings() {
}, },
VendettaCustomPage: { VendettaCustomPage: {
title: "Vendetta Page", title: "Vendetta Page",
render: ({ render: PageView, ...options }: { render: React.ComponentType } & Record<string, object>) => { render: ({ render: PageView, noErrorBoundary, ...options }: { render: React.ComponentType, noErrorBoundary: boolean } & Record<string, object>) => {
const navigation = NavigationNative.useNavigation(); const navigation = NavigationNative.useNavigation();
React.useEffect(() => options && navigation.setOptions(without(options, "render")), []); React.useEffect(() => options && navigation.setOptions(without(options, "render", "noErrorBoundary")), []);
// TODO: Is wrapping this in ErrorBoundary a good idea? return noErrorBoundary ? <PageView /> : <ErrorBoundary><PageView /></ErrorBoundary>;
return <ErrorBoundary><PageView /></ErrorBoundary>;
} }
} }
} }

View file

@ -1,4 +1,5 @@
import { ReactNative as RN, NavigationNative } from "@metro/common"; import { ReactNative as RN, NavigationNative } from "@metro/common";
import { findByProps } from "@metro/filters";
import { Forms } from "@ui/components"; import { Forms } from "@ui/components";
import { getAssetIDByName } from "@ui/assets"; import { getAssetIDByName } from "@ui/assets";
import { connectToDebugger } from "@lib/debug"; import { connectToDebugger } from "@lib/debug";
@ -7,6 +8,8 @@ import settings, { loaderConfig } from "@lib/settings";
import ErrorBoundary from "@ui/components/ErrorBoundary"; import ErrorBoundary from "@ui/components/ErrorBoundary";
const { FormSection, FormRow, FormSwitchRow, FormInput, FormDivider } = Forms; const { FormSection, FormRow, FormSwitchRow, FormInput, FormDivider } = Forms;
const { hideActionSheet } = findByProps("openLazy", "hideActionSheet");
const { showSimpleActionSheet } = findByProps("showSimpleActionSheet");
export default function Developer() { export default function Developer() {
const navigation = NavigationNative.useNavigation(); const navigation = NavigationNative.useNavigation();
@ -79,6 +82,26 @@ export default function Developer() {
trailing={FormRow.Arrow} trailing={FormRow.Arrow}
onPress={() => navigation.push("VendettaAssetBrowser")} onPress={() => navigation.push("VendettaAssetBrowser")}
/> />
<FormDivider />
<FormRow
label="ErrorBoundary Tools"
leading={<FormRow.Icon source={getAssetIDByName("ic_warning_24px")} />}
trailing={FormRow.Arrow}
onPress={() => showSimpleActionSheet({
key: "ErrorBoundaryTools",
header: {
title: "Which ErrorBoundary do you want to trip?",
icon: <FormRow.Icon style={{ marginRight: 8 }} source={getAssetIDByName("ic_warning_24px")} />,
onClose: () => hideActionSheet(),
},
options: [
// @ts-expect-error
// Of course, to trigger an error, we need to do something incorrectly. The below will do!
{ label: "Vendetta", onPress: () => navigation.push("VendettaCustomPage", { render: () => <undefined /> }) },
{ label: "Discord", isDestructive: true, onPress: () => navigation.push("VendettaCustomPage", { noErrorBoundary: true }) },
],
})}
/>
</FormSection> </FormSection>
</RN.ScrollView> </RN.ScrollView>
</ErrorBoundary> </ErrorBoundary>

View file

@ -2,7 +2,7 @@ import { ReactNative as RN, url } from "@metro/common";
import { getAssetIDByName } from "@ui/assets"; import { getAssetIDByName } from "@ui/assets";
import { Forms, Summary } from "@ui/components"; import { Forms, Summary } from "@ui/components";
import { DISCORD_SERVER, GITHUB } from "@lib/constants"; import { DISCORD_SERVER, GITHUB } from "@lib/constants";
import { getDebugInfo } from "@lib/debug"; import { getDebugInfo, toggleSafeMode } from "@lib/debug";
import { useProxy } from "@lib/storage"; import { useProxy } from "@lib/storage";
import { BundleUpdaterManager } from "@lib/native"; import { BundleUpdaterManager } from "@lib/native";
import settings from "@lib/settings"; import settings from "@lib/settings";
@ -106,6 +106,13 @@ export default function General() {
onPress={() => BundleUpdaterManager.reload()} onPress={() => BundleUpdaterManager.reload()}
/> />
<FormDivider /> <FormDivider />
<FormRow
label={settings.safeMode?.enabled ? "Return to Normal Mode" : "Reload in Safe Mode"}
subLabel={`This will reload Discord ${settings.safeMode?.enabled ? "normally." : "without loading plugins."}`}
leading={<FormRow.Icon source={getAssetIDByName("ic_privacy_24px")} />}
onPress={toggleSafeMode}
/>
<FormDivider />
<FormSwitchRow <FormSwitchRow
label="Developer Settings" label="Developer Settings"
leading={<FormRow.Icon source={getAssetIDByName("ic_progress_wrench_24px")} />} leading={<FormRow.Icon source={getAssetIDByName("ic_progress_wrench_24px")} />}

View file

@ -1,15 +1,21 @@
import { ReactNative as RN } from "@metro/common"; import { ReactNative as RN } from "@metro/common";
import { useProxy } from "@lib/storage"; import { useProxy } from "@lib/storage";
import { plugins } from "@lib/plugins"; import { plugins } from "@lib/plugins";
import { HelpMessage } from "@ui/components";
import settings from "@lib/settings";
import PluginCard from "@ui/settings/components/PluginCard"; import PluginCard from "@ui/settings/components/PluginCard";
import ErrorBoundary from "@ui/components/ErrorBoundary"; import ErrorBoundary from "@ui/components/ErrorBoundary";
export default function Plugins() { export default function Plugins() {
useProxy(settings)
useProxy(plugins); useProxy(plugins);
return ( return (
<ErrorBoundary> <ErrorBoundary>
<RN.View style={{ flex: 1 }}> <RN.View style={{ flex: 1 }}>
{settings.safeMode?.enabled && <RN.View style={{ margin: 10 }}>
<HelpMessage messageType={0}>You are in Safe Mode, so plugins cannot be loaded. Disable any misbehaving plugins, then return to Normal Mode from the General settings page. </HelpMessage>
</RN.View>}
<RN.FlatList <RN.FlatList
data={Object.values(plugins)} data={Object.values(plugins)}
renderItem={({ item, index }) => <PluginCard plugin={item} index={index} />} renderItem={({ item, index }) => <PluginCard plugin={item} index={index} />}

View file

@ -1,15 +1,30 @@
import { themes } from "@/lib/themes"; import { ButtonColors } from "@types";
import { useProxy } from "@lib/storage";
import { ReactNative as RN } from "@metro/common"; import { ReactNative as RN } from "@metro/common";
import ErrorBoundary from "@ui/components/ErrorBoundary"; import { themes } from "@lib/themes";
import { useProxy } from "@lib/storage";
import { ErrorBoundary, Button, HelpMessage } from "@ui/components";
import settings from "@lib/settings";
import ThemeCard from "@ui/settings/components/ThemeCard"; import ThemeCard from "@ui/settings/components/ThemeCard";
export default function Themes() { export default function Themes() {
useProxy(settings);
useProxy(themes); useProxy(themes);
return ( return (
<ErrorBoundary> <ErrorBoundary>
<RN.View style={{ flex: 1 }}> <RN.View style={{ flex: 1 }}>
{settings.safeMode?.enabled && <RN.View style={{ margin: 10 }}>
<HelpMessage messageType={0}>You are in Safe Mode, meaning themes have been temporarily disabled.{settings.safeMode?.currentThemeId && " If a theme appears to be causing the issue, you can press below to disable it persistently."}</HelpMessage>
{settings.safeMode?.currentThemeId && <Button
text="Disable Theme"
color={ButtonColors.BRAND}
size="small"
onPress={() => {
delete settings.safeMode?.currentThemeId;
}}
style={{ marginTop: 8 }}
/>}
</RN.View>}
<RN.FlatList <RN.FlatList
data={Object.values(themes)} data={Object.values(themes)}
renderItem={({ item, index }) => <ThemeCard theme={item} index={index} />} renderItem={({ item, index }) => <ThemeCard theme={item} index={index} />}