Merge branch 'dev'

This commit is contained in:
pylixonly 2024-12-26 13:00:32 +08:00
commit 9a82dfec85
61 changed files with 1507 additions and 774 deletions

View file

@ -25,6 +25,7 @@ const {
let context = null;
/** @type {import("esbuild").BuildOptions} */
const config = {
entryPoints: ["src/entry.ts"],
bundle: true,

View file

@ -80,7 +80,7 @@ if (args.adb && isADBAvailableAndAppInstalled()) {
if (key.name === "r") {
console.info(chalk.yellow(`${chalk.bold("↻ Reloading")} ${packageName}`));
restartAppFromADB(server.port)
restartAppFromADB(server.address().port)
.then(() => console.info(chalk.greenBright(`${chalk.bold("✔ Executed")} reload command`)))
.catch(e => console.error(e));
}

View file

@ -0,0 +1,17 @@
import { getCurrentTheme, writeThemeToNative } from "@lib/addons/themes";
import { BundleUpdaterManager } from "@lib/api/native/modules";
import { settings } from "@lib/api/settings";
export function isSafeMode() {
return settings.safeMode?.enabled === true;
}
export async function toggleSafeMode({
to = !isSafeMode(),
reload = true
} = {}) {
const enabled = (settings.safeMode ??= { enabled: to }).enabled = to;
const currentColor = getCurrentTheme();
await writeThemeToNative(enabled ? {} : currentColor?.data ?? {});
if (reload) setTimeout(() => BundleUpdaterManager.reload(), 500);
}

View file

@ -1,28 +1,28 @@
import { CardWrapper } from "@core/ui/components/AddonCard";
import { useProxy } from "@core/vendetta/storage";
import { findAssetId } from "@lib/api/assets";
import { settings } from "@lib/api/settings";
import AlertModal, { AlertActionButton } from "@lib/ui/components/wrappers/AlertModal";
import { dismissAlert, openAlert } from "@lib/ui/alerts";
import { showSheet } from "@lib/ui/sheets";
import isValidHttpUrl from "@lib/utils/isValidHttpUrl";
import { lazyDestructure } from "@lib/utils/lazy";
import { findByProps } from "@metro";
import { clipboard } from "@metro/common";
import { Button, FlashList, FloatingActionButton, HelpMessage, IconButton, Stack, Text, TextInput, useSafeAreaInsets } from "@metro/common/components";
import { clipboard, NavigationNative } from "@metro/common";
import { AlertActionButton, AlertModal, Button, FlashList, FloatingActionButton, HelpMessage, IconButton, Stack, Text, TextInput, useSafeAreaInsets } from "@metro/common/components";
import { ErrorBoundary, Search } from "@ui/components";
import { isNotNil } from "es-toolkit";
import fuzzysort from "fuzzysort";
import { ComponentType, ReactNode, useCallback, useMemo } from "react";
import { ComponentType, ReactNode, useCallback, useEffect, useMemo } from "react";
import { Image, ScrollView, View } from "react-native";
const { showSimpleActionSheet, hideActionSheet } = lazyDestructure(() => findByProps("showSimpleActionSheet"));
const { openAlert, dismissAlert } = lazyDestructure(() => findByProps("openAlert", "dismissAlert"));
type SearchKeywords = Array<string | ((obj: any & {}) => string)>;
type SearchKeywords<T> = Array<string | ((obj: T & {}) => string)>;
interface AddonPageProps<T extends object, I = any> {
title: string;
items: I[];
searchKeywords: SearchKeywords;
sortOptions?: Record<string, (a: I, b: I) => number>;
searchKeywords: SearchKeywords<T>;
sortOptions?: Record<string, (a: T, b: T) => number>;
resolveItem?: (value: I) => T | undefined;
safeModeHint?: {
message?: string;
@ -34,6 +34,9 @@ interface AddonPageProps<T extends object, I = any> {
fetchFn?: (url: string) => Promise<void>;
onPress?: () => void;
};
OptionsActionSheetComponent?: ComponentType<any>;
CardComponent: ComponentType<CardWrapper<T>>;
ListHeaderComponent?: ComponentType<any>;
ListFooterComponent?: ComponentType<any>;
@ -55,7 +58,7 @@ function InputAlert(props: { label: string, fetchFn: (url: string) => Promise<vo
return <AlertModal
title={props.label}
content="Enter the URL of the source you want to install from:"
content="Type in the source URL you want to install from:"
extraContent={
<Stack style={{ marginTop: -12 }}>
<TextInput
@ -107,16 +110,28 @@ function InputAlert(props: { label: string, fetchFn: (url: string) => Promise<vo
}
export default function AddonPage<T extends object>({ CardComponent, ...props }: AddonPageProps<T>) {
useProxy(settings);
const [search, setSearch] = React.useState("");
const [sortFn, setSortFn] = React.useState<((a: unknown, b: unknown) => number) | null>(() => null);
const [sortFn, setSortFn] = React.useState<((a: T, b: T) => number) | null>(() => null);
const { bottom: bottomInset } = useSafeAreaInsets();
const navigation = NavigationNative.useNavigation();
useEffect(() => {
if (props.OptionsActionSheetComponent) {
navigation.setOptions({
headerRight: () => <IconButton
size="sm"
variant="secondary"
icon={findAssetId("MoreHorizontalIcon")}
onPress={() => showSheet("AddonMoreSheet", props.OptionsActionSheetComponent!)}
/>
});
}
}, [navigation]);
const results = useMemo(() => {
let values = props.items;
if (props.resolveItem) values = values.map(props.resolveItem);
const items = values.filter(i => i && typeof i === "object");
if (props.resolveItem) values = values.map(props.resolveItem).filter(isNotNil);
const items = values.filter(i => isNotNil(i) && typeof i === "object");
if (!search && sortFn) items.sort(sortFn);
return fuzzysort.go(search, items, { keys: props.searchKeywords, all: true });
@ -135,7 +150,7 @@ export default function AddonPage<T extends object>({ CardComponent, ...props }:
if (results.length === 0 && !search) {
return <View style={{ gap: 32, flexGrow: 1, justifyContent: "center", alignItems: "center" }}>
<View style={{ gap: 8, alignItems: "center" }}>
<Image source={findAssetId("empty_quick_switcher")} />
<Image source={findAssetId("empty_quick_switcher")!} />
<Text variant="text-lg/semibold" color="text-normal">
Oops! Nothing to see here… yet!
</Text>
@ -176,7 +191,7 @@ export default function AddonPage<T extends object>({ CardComponent, ...props }:
})}
/>}
</View>
{props.ListHeaderComponent && !search && <props.ListHeaderComponent />}
{props.ListHeaderComponent && <props.ListHeaderComponent />}
</View>
);
@ -188,12 +203,12 @@ export default function AddonPage<T extends object>({ CardComponent, ...props }:
estimatedItemSize={136}
ListHeaderComponent={headerElement}
ListEmptyComponent={() => <View style={{ gap: 12, padding: 12, alignItems: "center" }}>
<Image source={findAssetId("devices_not_found")} />
<Image source={findAssetId("devices_not_found")!} />
<Text variant="text-lg/semibold" color="text-normal">
Hmmm... could not find that!
</Text>
</View>}
contentContainerStyle={{ padding: 8, paddingHorizontal: 12 }}
contentContainerStyle={{ padding: 8, paddingHorizontal: 12, paddingBottom: 90 }}
ItemSeparatorComponent={() => <View style={{ height: 8 }} />}
ListFooterComponent={props.ListFooterComponent}
renderItem={({ item }: any) => <CardComponent item={item.obj} result={item} />}

View file

@ -11,7 +11,7 @@ export enum CheckState {
export function useFileExists(path: string, prefix?: string): [CheckState, typeof fs] {
const [state, setState] = useState<CheckState>(CheckState.LOADING);
const check = () => fs.fileExists(path, prefix)
const check = () => fs.fileExists(path, { prefix })
.then(exists => setState(exists ? CheckState.TRUE : CheckState.FALSE))
.catch(() => setState(CheckState.ERROR));

View file

@ -1,11 +1,13 @@
import AssetDisplay from "@core/ui/settings/pages/Developer/AssetDisplay";
import { assetsMap } from "@lib/api/assets";
import { iterateAssets } from "@lib/api/assets";
import { LegacyFormDivider } from "@metro/common/components";
import { ErrorBoundary, Search } from "@ui/components";
import { useMemo } from "react";
import { FlatList, View } from "react-native";
export default function AssetBrowser() {
const [search, setSearch] = React.useState("");
const all = useMemo(() => Array.from(iterateAssets()), []);
return (
<ErrorBoundary>
@ -15,7 +17,7 @@ export default function AssetBrowser() {
onChangeText={(v: string) => setSearch(v)}
/>
<FlatList
data={Object.values(assetsMap).filter(a => a.name.includes(search) || a.id.toString() === search)}
data={all.filter(a => a.name.includes(search) || a.id.toString() === search)}
renderItem={({ item }) => <AssetDisplay asset={item} />}
ItemSeparatorComponent={LegacyFormDivider}
keyExtractor={item => item.name}

View file

@ -1,4 +1,5 @@
import { Strings } from "@core/i18n";
import { PyoncordIcon } from "@core/ui/settings";
import Version from "@core/ui/settings/pages/General/Version";
import { useProxy } from "@core/vendetta/storage";
import { getDebugInfo } from "@lib/api/debug";
@ -14,7 +15,7 @@ export default function About() {
{
label: Strings.BUNNY,
version: debugInfo.bunny.version,
icon: "ic_progress_wrench_24px",
icon: { uri: PyoncordIcon },
},
{
label: "Discord",
@ -24,17 +25,17 @@ export default function About() {
{
label: "React",
version: debugInfo.react.version,
icon: "ic_category_16px",
icon: "ScienceIcon",
},
{
label: "React Native",
version: debugInfo.react.nativeVersion,
icon: "mobile",
icon: "MobilePhoneIcon",
},
{
label: Strings.BYTECODE,
version: debugInfo.hermes.bytecodeVersion,
icon: "ic_server_security_24px",
icon: "TopicsIcon",
},
];
@ -42,37 +43,37 @@ export default function About() {
{
label: Strings.LOADER,
version: `${debugInfo.bunny.loader.name} (${debugInfo.bunny.loader.version})`,
icon: "ic_download_24px",
icon: "DownloadIcon",
},
{
label: Strings.OPERATING_SYSTEM,
version: `${debugInfo.os.name} ${debugInfo.os.version}`,
icon: "ic_cog_24px"
icon: "ScreenIcon"
},
...(debugInfo.os.sdk ? [{
label: "SDK",
version: debugInfo.os.sdk,
icon: "pencil"
icon: "StaffBadgeIcon"
}] : []),
{
label: Strings.MANUFACTURER,
version: debugInfo.device.manufacturer,
icon: "ic_badge_staff"
icon: "WrenchIcon"
},
{
label: Strings.BRAND,
version: debugInfo.device.brand,
icon: "ic_settings_boost_24px"
icon: "SparklesIcon"
},
{
label: Strings.MODEL,
version: debugInfo.device.model,
icon: "ic_phonelink_24px"
icon: "MobilePhoneIcon"
},
{
label: Platform.select({ android: Strings.CODENAME, ios: Strings.MACHINE_ID })!,
version: debugInfo.device.codename,
icon: "ic_compose_24px"
icon: "TagIcon"
}
];

View file

@ -2,18 +2,19 @@ import { findAssetId } from "@lib/api/assets";
import { clipboard } from "@metro/common";
import { LegacyFormText, TableRow } from "@metro/common/components";
import { showToast } from "@ui/toasts";
import { ImageURISource } from "react-native";
interface VersionProps {
label: string;
version: string;
icon: string;
icon: string | ImageURISource;
}
export default function Version({ label, version, icon }: VersionProps) {
return (
<TableRow
label={label}
icon={<TableRow.Icon source={findAssetId(icon)} />}
icon={<TableRow.Icon source={typeof icon === "string" ? findAssetId(icon) : icon} />}
trailing={<LegacyFormText>{version}</LegacyFormText>}
onPress={() => {
clipboard.setString(`${label} - ${version}`);

View file

@ -1,14 +1,17 @@
import { isSafeMode, toggleSafeMode } from "@core/debug/safeMode";
import { Strings } from "@core/i18n";
import { PyoncordIcon } from "@core/ui/settings";
import About from "@core/ui/settings/pages/General/About";
import { useProxy } from "@core/vendetta/storage";
import { findAssetId } from "@lib/api/assets";
import { getDebugInfo, toggleSafeMode } from "@lib/api/debug";
import { getDebugInfo } from "@lib/api/debug";
import { BundleUpdaterManager } from "@lib/api/native/modules";
import { settings } from "@lib/api/settings";
import { openAlert } from "@lib/ui/alerts";
import { DISCORD_SERVER, GITHUB } from "@lib/utils/constants";
import { NavigationNative, url } from "@metro/common";
import { Stack, TableRow, TableRowGroup, TableSwitchRow } from "@metro/common/components";
import { NativeModules, ScrollView } from "react-native";
import { AlertActionButton, AlertActions, AlertModal, Stack, TableRow, TableRowGroup, TableSwitchRow } from "@metro/common/components";
import { ScrollView } from "react-native";
export default function General() {
useProxy(settings);
@ -27,14 +30,13 @@ export default function General() {
/>
<TableRow
label={"Discord"}
icon={<TableRow.Icon source={findAssetId("Discord")} />}
icon={<TableRow.Icon source={findAssetId("Discord")!} />}
trailing={<TableRow.TrailingText text={`${debugInfo.discord.version} (${debugInfo.discord.build})`} />}
/>
<TableRow
arrow
label={Strings.ABOUT}
icon={<TableRow.Icon source={findAssetId("CircleInformationIcon-primary")} />}
trailing={TableRow.Arrow}
icon={<TableRow.Icon source={findAssetId("CircleInformationIcon-primary")!} />}
onPress={() => navigation.push("BUNNY_CUSTOM_PAGE", {
title: Strings.ABOUT,
render: () => <About />,
@ -43,33 +45,51 @@ export default function General() {
</TableRowGroup>
<TableRowGroup title={Strings.LINKS}>
<TableRow
arrow={true}
label={Strings.DISCORD_SERVER}
icon={<TableRow.Icon source={findAssetId("Discord")} />}
trailing={TableRow.Arrow}
icon={<TableRow.Icon source={findAssetId("Discord")!} />}
onPress={() => url.openDeeplink(DISCORD_SERVER)}
/>
<TableRow
arrow={true}
label={Strings.GITHUB}
icon={<TableRow.Icon source={findAssetId("img_account_sync_github_white")} />}
trailing={TableRow.Arrow}
icon={<TableRow.Icon source={findAssetId("img_account_sync_github_white")!} />}
onPress={() => url.openURL(GITHUB)}
/>
</TableRowGroup>
<TableRowGroup title={Strings.ACTIONS}>
<TableRow
label={Strings.RELOAD_DISCORD}
icon={<TableRow.Icon source={findAssetId("ic_message_retry")} />}
onPress={() => NativeModules.BundleUpdaterManager.reload()}
icon={<TableRow.Icon source={findAssetId("RetryIcon")!} />}
onPress={() => BundleUpdaterManager.reload()}
/>
<TableRow
label={settings.safeMode?.enabled ? Strings.RELOAD_IN_NORMAL_MODE : Strings.RELOAD_IN_SAFE_MODE}
subLabel={settings.safeMode?.enabled ? Strings.RELOAD_IN_NORMAL_MODE_DESC : Strings.RELOAD_IN_SAFE_MODE_DESC}
icon={<TableRow.Icon source={findAssetId("ic_privacy_24px")} />}
onPress={toggleSafeMode}
<TableSwitchRow
label={"Safe Mode"}
subLabel={"Load Bunny without loading add-ons"}
icon={<TableRow.Icon source={findAssetId("ShieldIcon")!} />}
value={isSafeMode()}
onValueChange={(to: boolean) => {
toggleSafeMode({ to, reload: false });
openAlert(
"bunny-reload-safe-mode",
<AlertModal
title="Reload now?"
content={!to ? "All add-ons will load normally." : "All add-ons will be temporarily disabled upon reload."}
actions={<AlertActions>
<AlertActionButton
text="Reload Now"
variant="destructive"
onPress={() => BundleUpdaterManager.reload()}
/>
<AlertActionButton text="Later" variant="secondary" />
</AlertActions>}
/>
);
}}
/>
<TableSwitchRow
label={Strings.DEVELOPER_SETTINGS}
icon={<TableRow.Icon source={findAssetId("ic_progress_wrench_24px")} />}
icon={<TableRow.Icon source={findAssetId("WrenchIcon")!} />}
value={settings.developerSettings}
onValueChange={(v: boolean) => {
settings.developerSettings = v;
@ -80,7 +100,7 @@ export default function General() {
<TableSwitchRow
label={Strings.SETTINGS_ACTIVATE_DISCORD_EXPERIMENTS}
subLabel={Strings.SETTINGS_ACTIVATE_DISCORD_EXPERIMENTS_DESC}
icon={<TableRow.Icon source={findAssetId("ic_progress_wrench_24px")} />}
icon={<TableRow.Icon source={findAssetId("WrenchIcon")!} />}
value={settings.enableDiscordDeveloperSettings}
onValueChange={(v: boolean) => {
settings.enableDiscordDeveloperSettings = v;

View file

@ -1,42 +1,24 @@
import { installPlugin, isPluginInstalled, uninstallPlugin } from "@lib/addons/plugins";
import { BunnyPluginManifest } from "@lib/addons/plugins/types";
import { deleteRepository, installPlugin, isCorePlugin, isPluginInstalled, pluginRepositories, registeredPlugins, uninstallPlugin, updateAllRepository, updateRepository } from "@lib/addons/plugins";
import { BunnyPluginManifestInternal } from "@lib/addons/plugins/types";
import { findAssetId } from "@lib/api/assets";
import { dismissAlert, openAlert } from "@lib/ui/alerts";
import { AlertActionButton } from "@lib/ui/components/wrappers";
import { hideSheet, showSheet } from "@lib/ui/sheets";
import { showToast } from "@lib/ui/toasts";
import { safeFetch } from "@lib/utils";
import { OFFICIAL_PLUGINS_REPO_URL } from "@lib/utils/constants";
import { Button, Card, FlashList, IconButton, Stack, Text } from "@metro/common/components";
import isValidHttpUrl from "@lib/utils/isValidHttpUrl";
import { clipboard, NavigationNative } from "@metro/common";
import { ActionSheet, AlertActions, AlertModal, Button, Card, FlashList, IconButton, Stack, TableRow, TableRowGroup, Text, TextInput } from "@metro/common/components";
import { QueryClient, QueryClientProvider, useMutation, useQuery } from "@tanstack/react-query";
import { chunk } from "es-toolkit";
import { useState } from "react";
import { useEffect, useState } from "react";
import { View } from "react-native";
const queryClient = new QueryClient();
async function arrayFromAsync<T>(iterableOrArrayLike: AsyncIterable<T>): Promise<T[]> {
const arr: T[] = [];
for await (const element of iterableOrArrayLike) arr.push(element);
return arr;
}
async function fetchManifest(repoURL: string, id: string) {
const url = new URL(`plugins/${id}/manifest.json`, repoURL);
const data = await safeFetch(url).then(d => d.json());
queryClient.setQueryData(["plugin-manifest-dist", { id }], data);
return data as BunnyPluginManifest;
}
async function* getManifests(repoUrl: string) {
const rawResponse = await safeFetch(repoUrl);
const pluginIds = Object.keys(await rawResponse.json());
for (const idChunks of chunk(pluginIds, 5)) {
const manifests = idChunks.map(id => fetchManifest(OFFICIAL_PLUGINS_REPO_URL, id));
for (const manifest of manifests) {
yield await manifest;
}
}
async function getManifests() {
await updateAllRepository();
const plugins = [...registeredPlugins.values()];
return plugins.filter(p => !isCorePlugin(p.id));
}
function InstallButton(props: { id: string; }) {
@ -67,7 +49,15 @@ function TrailingButtons(props: { id: string; }) {
return <Stack spacing={8} direction="horizontal">
<IconButton
size="sm"
onPress={() => { }}
onPress={() => {
showSheet("plugin-info", () => {
return <ActionSheet>
<TableRowGroup title="Plugin Info">
<TableRow label="ID" subLabel={props.id} />
</TableRowGroup>
</ActionSheet>;
});
}}
variant="secondary"
icon={findAssetId("CircleInformationIcon")}
/>
@ -75,46 +65,52 @@ function TrailingButtons(props: { id: string; }) {
</Stack>;
}
function PluginCard(props: { repoUrl: string, id: string, manifest: BunnyPluginManifest; }) {
const { isPending, error, data: plugin } = useQuery({
queryKey: ["plugin-manifest-dist", { id: props.id }],
queryFn: () => fetchManifest(props.repoUrl, props.id)
});
function PluginCard(props: { manifest: BunnyPluginManifestInternal; }) {
const { display, version } = props.manifest;
return (
<Card>
{!plugin && <View style={{ justifyContent: "center", alignItems: "center" }}>
<Text color="text-muted" variant="heading-lg/semibold">
{isPending && "Loading..."}
{error && `An error has occured while fetching plugin: ${error.message}`}
</Text>
</View>}
{plugin && <Stack spacing={16}>
<Stack spacing={16}>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
<View style={{ flexShrink: 1 }}>
<Text numberOfLines={1} variant="heading-lg/semibold">
{plugin.name}
{display.name}
</Text>
<Text variant="text-md/semibold" color="text-muted">
by {plugin.authors.map(a => typeof a === "string" ? a : a.name).join(", ")}
by {display.authors?.map(a => a.name).join(", ") || "Unknown"} ({version})
</Text>
</View>
<View>
<TrailingButtons id={props.id} />
<TrailingButtons id={props.manifest.id} />
</View>
</View>
<Text variant="text-md/medium">
{plugin.description}
{display.description}
</Text>
</Stack>}
</Stack>
</Card>
);
}
function BrowserPage() {
const navigation = NavigationNative.useNavigation();
useEffect(() => {
navigation.setOptions({
title: "Plugin Browser",
headerRight: () => <IconButton
size="sm"
variant="secondary"
icon={findAssetId("PlusSmallIcon")}
onPress={() => {
showSheet("plugin-browser-options", PluginBrowserOptions);
}}
/>
});
}, [navigation]);
const { data, error, isPending, refetch } = useQuery({
queryKey: ["plugins-repo-fetch"],
queryFn: () => arrayFromAsync(getManifests(OFFICIAL_PLUGINS_REPO_URL))
queryFn: () => getManifests()
});
if (error) {
@ -149,12 +145,108 @@ function BrowserPage() {
contentContainerStyle={{ paddingBottom: 90, paddingHorizontal: 5 }}
renderItem={({ item: manifest }: any) => (
<View style={{ paddingVertical: 6, paddingHorizontal: 8 }}>
<PluginCard repoUrl={OFFICIAL_PLUGINS_REPO_URL} id={manifest.id} manifest={manifest} />
<PluginCard manifest={manifest} />
</View>
)}
/>;
}
function AddRepositoryAlert() {
const [value, setValue] = useState("");
return <AlertModal
title="Add Repository"
content="Enter the URL of the repository you want to add."
extraContent={<TextInput
value={value}
onChange={setValue}
placeholder="https://example.com/repo.json" />}
actions={<AlertActions>
<AlertActionButton
text="Add"
variant="primary"
disabled={!isValidHttpUrl(value)}
onPress={async () => {
try {
await updateRepository(value);
showToast("Added repository!", findAssetId("Check"));
} catch (e) {
showToast("Failed to add repository!", findAssetId("Small"));
} finally {
dismissAlert("bunny-add-plugin-repository");
showSheet("plugin-browser-options", PluginBrowserOptions);
}
}} />
</AlertActions>} />;
}
function PluginBrowserOptions() {
return <ActionSheet>
<TableRowGroup title="Repositories">
{Object.keys(pluginRepositories).map(url => {
return <RepositoryRow key={url} url={url} />;
})}
<TableRow
label="Add Repository..."
icon={<TableRow.Icon source={findAssetId("PlusMediumIcon")} />}
onPress={() => {
openAlert("bunny-add-plugin-repository", <AddRepositoryAlert />);
hideSheet("plugin-browser-options");
}} />
</TableRowGroup>
</ActionSheet>;
}
function RepositoryRow(props: { url: string; }) {
const repo = pluginRepositories[props.url];
const isOfficial = props.url === OFFICIAL_PLUGINS_REPO_URL;
return (
<TableRow
label={isOfficial ? "Bunny's Repository" : (repo.$meta?.name ?? "Unknown")}
subLabel={props.url}
trailing={(
<Stack direction="horizontal">
<IconButton
size="sm"
variant="secondary"
icon={findAssetId("LinkIcon")}
onPress={() => {
clipboard.setString(props.url);
showToast.showCopyToClipboard();
}}
/>
<IconButton
size="sm"
variant="destructive"
disabled={isOfficial}
icon={findAssetId("TrashIcon")}
onPress={() => {
openAlert("bunny-remove-repository", <AlertModal
title="Remove Repository"
content="Are you sure you want to remove this repository?"
extraContent={<Card>
<Text variant="text-md/normal">{props.url}</Text>
</Card>}
actions={<AlertActions>
<AlertActionButton
text="Remove"
variant="destructive"
onPress={async () => {
await deleteRepository(props.url);
showToast("Removed repository!", findAssetId("Trash"));
dismissAlert("bunny-remove-repository");
}}
/>
</AlertActions>}
/>);
}}
/>
</Stack>
)} />
);
}
export default function PluginBrowser() {
return (
<QueryClientProvider client={queryClient}>

View file

@ -1,4 +1,5 @@
import { CardWrapper } from "@core/ui/components/AddonCard";
import { UnifiedPluginModel } from "@core/ui/settings/pages/Plugins/models";
import { usePluginCardStyles } from "@core/ui/settings/pages/Plugins/usePluginCardStyles";
import { findAssetId } from "@lib/api/assets";
import { NavigationNative, tokens } from "@metro/common";
@ -8,8 +9,6 @@ import chroma from "chroma-js";
import { createContext, useContext, useMemo } from "react";
import { Image, View } from "react-native";
import { UnifiedPluginModel } from "..";
const CardContext = createContext<{ plugin: UnifiedPluginModel, result: Fuzzysort.KeysResult<UnifiedPluginModel>; }>(null!);
const useCardContext = () => useContext(CardContext);
@ -50,6 +49,8 @@ function Title() {
function Authors() {
const { plugin, result } = useCardContext();
const styles = usePluginCardStyles();
if (!plugin.authors) return null;
// could be empty if the author(s) are irrelevant with the search!
@ -59,24 +60,23 @@ function Authors() {
</Text>
);
if (highlightedNode.length > 0) return (
<Text variant="text-md/semibold" color="text-muted">
by {highlightedNode}
</Text>
const badges = plugin.getBadges();
const authorText = highlightedNode.length > 0 ? highlightedNode : plugin.authors.map(a => a.name).join(", ");
return (
<View style={{ flexDirection: "row", flexWrap: "wrap", flexShrink: 1, gap: 4 }}>
<Text variant="text-sm/semibold" color="text-muted">
by {authorText}
</Text>
{badges.length > 0 && <View style={styles.badgesContainer}>
{badges.map((b, i) => <Image
key={i}
source={b.source}
style={styles.badgeIcon}
/>)}
</View>}
</View>
);
const children = ["by "];
for (const author of plugin.authors) {
children.push(typeof author === "string" ? author : author.name);
children.push(", ");
}
children.pop();
return <Text variant="text-md/semibold" color="text-muted">
{children}
</Text>;
}
function Description() {

View file

@ -13,28 +13,14 @@ import { BUNNY_PROXY_PREFIX, VD_PROXY_PREFIX } from "@lib/utils/constants";
import { lazyDestructure } from "@lib/utils/lazy";
import { findByProps } from "@metro";
import { NavigationNative } from "@metro/common";
import { Card, FlashList, IconButton, Text } from "@metro/common/components";
import { Button, Card, FlashList, IconButton, Text } from "@metro/common/components";
import { ComponentProps } from "react";
import { View } from "react-native";
import { UnifiedPluginModel } from "./models";
import unifyBunnyPlugin from "./models/bunny";
import unifyVdPlugin from "./models/vendetta";
export interface UnifiedPluginModel {
id: string;
name: string;
description?: string;
authors?: Author[];
icon?: string;
isEnabled(): boolean;
usePluginState(): void;
isInstalled(): boolean;
toggle(start: boolean): void;
resolveSheetComponent(): Promise<{ default: React.ComponentType<any>; }>;
getPluginSettingsComponent(): React.ComponentType<any> | null | undefined;
}
const { openAlert } = lazyDestructure(() => findByProps("openAlert", "dismissAlert"));
const { AlertModal, AlertActions, AlertActionButton } = lazyDestructure(() => findByProps("AlertModal", "AlertActions"));
@ -53,7 +39,7 @@ function PluginPage(props: PluginPageProps) {
"description",
p => p.authors?.map(
(a: Author | string) => typeof a === "string" ? a : a.name
).join()
).join() || ""
]}
sortOptions={{
"Name (A-Z)": (a, b) => a.name.localeCompare(b.name),
@ -119,6 +105,20 @@ export default function Plugins() {
</Card>
</View>;
}}
ListFooterComponent={() => __DEV__ && (
<View style={{ alignItems: "center", justifyContent: "center", paddingTop: 16, gap: 12 }}><Button
size="lg"
text="Browse Plugins"
icon={findAssetId("CompassIcon")}
onPress={() => {
navigation.push("BUNNY_CUSTOM_PAGE", {
title: "Plugin Browser",
render: React.lazy(() => import("../PluginBrowser")),
});
}}
/>
</View>
)}
installAction={{
label: "Install a plugin",
fetchFn: async (url: string) => {

View file

@ -1,30 +1,43 @@
import { disablePlugin, enablePlugin, getId, getPluginSettingsComponent, isPluginEnabled, pluginSettings } from "@lib/addons/plugins";
import { PyoncordIcon } from "@core/ui/settings";
import { disablePlugin, enablePlugin, getPluginSettingsComponent, isPluginEnabled, pluginSettings } from "@lib/addons/plugins";
import { BunnyPluginManifest } from "@lib/addons/plugins/types";
import { useObservable } from "@lib/api/storage";
import { UnifiedPluginModel } from "..";
import { UnifiedPluginModel } from ".";
export default function unifyBunnyPlugin(manifest: BunnyPluginManifest): UnifiedPluginModel {
return {
id: manifest.id,
name: manifest.name,
description: manifest.description,
authors: manifest.authors,
isEnabled: () => isPluginEnabled(getId(manifest)),
name: manifest.display.name,
description: manifest.display.description,
authors: manifest.display.authors,
getBadges() {
return [
{ source: { uri: PyoncordIcon } },
// { source: findAssetId("CheckmarkLargeBoldIcon")! }
];
},
isEnabled: () => isPluginEnabled(manifest.id),
isInstalled: () => manifest.id in pluginSettings,
usePluginState() {
useObservable([pluginSettings]);
},
toggle(start: boolean) {
start
? enablePlugin(getId(manifest), true)
: disablePlugin(getId(manifest));
try {
start
? enablePlugin(manifest.id, true)
: disablePlugin(manifest.id);
} catch (e) {
console.error(e);
// showToast("Failed to toggle plugin " + e, findAssetId("Small"));
}
},
resolveSheetComponent() {
return import("../sheets/PluginInfoActionSheet");
},
getPluginSettingsComponent() {
return getPluginSettingsComponent(getId(manifest));
return getPluginSettingsComponent(manifest.id);
},
};
}

View file

@ -0,0 +1,23 @@
import { Author } from "@lib/addons/types";
import { ImageSourcePropType } from "react-native";
interface Badge {
source: ImageSourcePropType;
color?: string;
onPress?: () => void;
}
export interface UnifiedPluginModel {
id: string;
name: string;
description?: string;
authors?: Author[];
icon?: string;
getBadges(): Badge[];
isEnabled(): boolean;
usePluginState(): void;
isInstalled(): boolean;
toggle(start: boolean): void;
resolveSheetComponent(): Promise<{ default: React.ComponentType<any>; }>;
getPluginSettingsComponent(): React.ComponentType<any> | null | undefined;
}

View file

@ -1,7 +1,7 @@
import { VdPluginManager, VendettaPlugin } from "@core/vendetta/plugins";
import { useProxy } from "@core/vendetta/storage";
import { UnifiedPluginModel } from "..";
import { UnifiedPluginModel } from ".";
export default function unifyVdPlugin(vdPlugin: VendettaPlugin): UnifiedPluginModel {
return {
@ -11,6 +11,9 @@ export default function unifyVdPlugin(vdPlugin: VendettaPlugin): UnifiedPluginMo
authors: vdPlugin.manifest.authors,
icon: vdPlugin.manifest.vendetta?.icon,
getBadges() {
return [];
},
isEnabled: () => vdPlugin.enabled,
isInstalled: () => Boolean(vdPlugin && VdPluginManager.plugins[vdPlugin.id]),
usePluginState() {

View file

@ -1,45 +1,106 @@
import { startPlugin } from "@lib/addons/plugins";
import { findAssetId } from "@lib/api/assets";
import { hideSheet } from "@lib/ui/sheets";
import { ActionSheet, Button, Text } from "@metro/common/components";
import { ActionSheet, Card, ContextMenu, IconButton, Text } from "@metro/common/components";
import { ComponentProps } from "react";
import { ScrollView, View } from "react-native";
import { PluginInfoActionSheetProps } from "./common";
import TitleComponent from "./TitleComponent";
function PluginInfoIconButton(props: ComponentProps<typeof IconButton>) {
const { onPress } = props;
props.onPress &&= () => {
hideSheet("PluginInfoActionSheet");
onPress?.();
};
return <IconButton {...props} />;
}
export default function PluginInfoActionSheet({ plugin, navigation }: PluginInfoActionSheetProps) {
plugin.usePluginState();
return <ActionSheet>
<ScrollView contentContainerStyle={{ gap: 8, marginBottom: 12 }}>
<View style={{ flexDirection: "row", alignItems: "center", paddingVertical: 24 }}>
<View style={{ gap: 4 }}>
<Text variant="heading-xl/semibold">
{plugin.name}
</Text>
<Text variant="text-md/medium" color="text-muted">
{plugin.description}
</Text>
</View>
<View style={{ marginLeft: "auto" }}>
{plugin.getPluginSettingsComponent() && <Button
size="md"
text="Configure"
<ScrollView contentContainerStyle={{ gap: 12, marginBottom: 12 }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingVertical: 24, justifyContent: "space-between", width: "100%" }}>
<TitleComponent plugin={plugin} />
<ContextMenu
items={[
{
label: "Details",
iconSource: findAssetId("CircleInformationIcon-primary"),
action: () => {
}
},
// {
// label: true ? "Disable Updates" : "Enable Updates",
// iconSource: true ? findAssetId("ClockXIcon") : findAssetId("ClockIcon"),
// action: () => {
// }
// },
{
label: "Clear Data",
iconSource: findAssetId("FileIcon"),
variant: "destructive",
action: () => {
}
},
{
label: "Uninstall",
iconSource: findAssetId("TrashIcon"),
variant: "destructive",
action: () => {
}
}
]}
>
{props => <IconButton
{...props}
icon={findAssetId("MoreHorizontalIcon")}
variant="secondary"
icon={findAssetId("WrenchIcon")}
onPress={() => {
hideSheet("PluginInfoActionSheet");
navigation.push("BUNNY_CUSTOM_PAGE", {
title: plugin.name,
render: plugin.getPluginSettingsComponent(),
});
}}
size="sm"
/>}
</View>
</ContextMenu>
</View>
<View style={{ flexDirection: "row", justifyContent: "center", alignContent: "center" }}>
<Text variant="text-lg/medium">
Oops, you shouldn't see this!
<View style={{ flexDirection: "row", justifyContent: "space-around", alignContent: "center" }}>
<PluginInfoIconButton
label="Configure"
variant="secondary"
disabled={!plugin.getPluginSettingsComponent()}
icon={findAssetId("WrenchIcon")}
onPress={() => {
navigation.push("BUNNY_CUSTOM_PAGE", {
title: plugin.name,
render: plugin.getPluginSettingsComponent(),
});
}}
/>
<PluginInfoIconButton
label="Refetch"
variant="secondary"
icon={findAssetId("RetryIcon")}
onPress={() => {
startPlugin(plugin.id);
}}
/>
<PluginInfoIconButton
label="Copy URL"
variant="secondary"
icon={findAssetId("LinkIcon")}
onPress={() => {
}}
/>
</View>
<Card>
<Text variant="text-md/semibold" color="text-primary" style={{ marginBottom: 4 }}>
Description
</Text>
</View>
<Text variant="text-md/medium">
{plugin.description}
</Text>
</Card>
</ScrollView>
</ActionSheet>;
}

View file

@ -0,0 +1,57 @@
import { UnifiedPluginModel } from "@core/ui/settings/pages/Plugins/models";
import { lazyDestructure } from "@lib/utils/lazy";
import { findByNameLazy, findByProps } from "@metro";
import { FluxUtils } from "@metro/common";
import { Avatar, AvatarPile, Text } from "@metro/common/components";
import { UserStore } from "@metro/common/stores";
import { View } from "react-native";
const showUserProfileActionSheet = findByNameLazy("showUserProfileActionSheet");
const { getUser: maybeFetchUser } = lazyDestructure(() => findByProps("getUser", "fetchProfile"));
export default function TitleComponent({ plugin }: { plugin: UnifiedPluginModel; }) {
const users: any[] = FluxUtils.useStateFromStoresArray([UserStore], () => {
plugin.authors?.forEach(a => a.id && maybeFetchUser(a.id));
return plugin.authors?.map(a => UserStore.getUser(a.id));
});
const { authors } = plugin;
const authorTextNode = [];
if (authors) {
for (const author of authors) {
authorTextNode.push(<Text
onPress={() => showUserProfileActionSheet({ userId: author.id })}
variant="text-md/medium"
>
{author.name}
</Text>);
authorTextNode.push(", ");
}
authorTextNode.pop();
}
return <View style={{ gap: 4 }}>
<View>
<Text variant="heading-xl/semibold">
{plugin.name}
</Text>
</View>
<View style={{ flexDirection: "row", flexShrink: 1 }}>
{authors?.length && <View style={{ flexDirection: "row", gap: 8, alignItems: "center", paddingVertical: 4, paddingHorizontal: 8, backgroundColor: "#00000016", borderRadius: 32 }}>
{users.length && <AvatarPile
size="xxsmall"
names={plugin.authors?.map(a => a.name)}
totalCount={plugin.authors?.length}
>
{users.map(a => <Avatar size="xxsmall" user={a} />)}
</AvatarPile>}
<Text variant="text-md/medium">
{authorTextNode}
</Text>
</View>}
</View>
</View>;
}

View file

@ -59,7 +59,7 @@ export default function PluginInfoActionSheet({ plugin, navigation }: PluginInfo
/>
<ActionSheetRow
label={Strings.COPY_URL}
icon={<TableRow.Icon source={findAssetId("copy")} />}
icon={<TableRow.Icon source={findAssetId("LinkIcon")} />}
onPress={() => {
clipboard.setString(plugin.id);
showToast.showCopyToClipboard();
@ -67,7 +67,7 @@ export default function PluginInfoActionSheet({ plugin, navigation }: PluginInfo
/>
<ActionSheetRow
label={vdPlugin.update ? Strings.DISABLE_UPDATES : Strings.ENABLE_UPDATES}
icon={<TableRow.Icon source={findAssetId("ic_download_24px")} />}
icon={<TableRow.Icon source={findAssetId("DownloadIcon")} />}
onPress={() => {
vdPlugin.update = !vdPlugin.update;
showToast(formatString("TOASTS_PLUGIN_UPDATE", {
@ -78,7 +78,8 @@ export default function PluginInfoActionSheet({ plugin, navigation }: PluginInfo
/>
<ActionSheetRow
label={Strings.CLEAR_DATA}
icon={<TableRow.Icon source={findAssetId("ic_duplicate")} />}
icon={<TableRow.Icon variant="danger" source={findAssetId("CopyIcon")} />}
variant="danger"
onPress={() => showConfirmationAlert({
title: Strings.HOLD_UP,
content: formatString("ARE_YOU_SURE_TO_CLEAR_DATA", { name: plugin.name }),
@ -115,7 +116,8 @@ export default function PluginInfoActionSheet({ plugin, navigation }: PluginInfo
/>
<ActionSheetRow
label={Strings.DELETE}
icon={<TableRow.Icon source={findAssetId("ic_message_delete")} />}
icon={<TableRow.Icon variant="danger" source={findAssetId("TrashIcon")} />}
variant="danger"
onPress={() => showConfirmationAlert({
title: Strings.HOLD_UP,
content: formatString("ARE_YOU_SURE_TO_DELETE_PLUGIN", { name: plugin.name }),

View file

@ -6,5 +6,17 @@ export const usePluginCardStyles = createStyles({
tintColor: tokens.colors.LOGO_PRIMARY,
height: 18,
width: 18,
},
badgeIcon: {
tintColor: tokens.colors.LOGO_PRIMARY,
height: 12,
width: 12,
},
badgesContainer: {
flexWrap: "wrap",
flexDirection: "row",
gap: 6,
borderRadius: 6,
padding: 4
}
});

View file

@ -2,22 +2,21 @@ import { formatString, Strings } from "@core/i18n";
import AddonCard, { CardWrapper } from "@core/ui/components/AddonCard";
import { showConfirmationAlert } from "@core/vendetta/alerts";
import { useProxy } from "@core/vendetta/storage";
import { applyTheme, fetchTheme, removeTheme, selectTheme, Theme, themes } from "@lib/addons/themes";
import { fetchTheme, removeTheme, selectTheme, themes, VdThemeInfo } from "@lib/addons/themes";
import { findAssetId } from "@lib/api/assets";
import { settings } from "@lib/api/settings";
import { clipboard } from "@metro/common";
import { showToast } from "@ui/toasts";
function selectAndApply(value: boolean, theme: Theme) {
function selectAndApply(value: boolean, theme: VdThemeInfo) {
try {
selectTheme(value ? theme : null);
applyTheme(value ? theme : null);
} catch (e: any) {
console.error("Error while selectAndApply,", e);
}
}
export default function ThemeCard({ item: theme }: CardWrapper<Theme>) {
export default function ThemeCard({ item: theme }: CardWrapper<VdThemeInfo>) {
useProxy(theme);
const [removed, setRemoved] = React.useState(false);

View file

@ -2,26 +2,31 @@ import { formatString, Strings } from "@core/i18n";
import AddonPage from "@core/ui/components/AddonPage";
import ThemeCard from "@core/ui/settings/pages/Themes/ThemeCard";
import { useProxy } from "@core/vendetta/storage";
import { installTheme, Theme, themes } from "@lib/addons/themes";
import { getCurrentTheme, installTheme, themes, VdThemeInfo } from "@lib/addons/themes";
import { colorsPref } from "@lib/addons/themes/colors/preferences";
import { updateBunnyColor } from "@lib/addons/themes/colors/updater";
import { Author } from "@lib/addons/types";
import { findAssetId } from "@lib/api/assets";
import { settings } from "@lib/api/settings";
import { Button } from "@metro/common/components";
import { useObservable } from "@lib/api/storage";
import { ActionSheet, BottomSheetTitleHeader, Button, TableRadioGroup, TableRadioRow, TableRowIcon } from "@metro/common/components";
import { View } from "react-native";
export default function Themes() {
useProxy(settings);
useProxy(themes);
return (
<AddonPage<Theme>
<AddonPage<VdThemeInfo>
title={Strings.THEMES}
searchKeywords={[
"data.name",
"data.description",
p => p.data.authors?.map((a: Author) => a.name).join(", ")
p => p.data.authors?.map((a: Author) => a.name).join(", ") ?? ""
]}
sortOptions={{
"Name (A-Z)": (a, b) => a.name.localeCompare(b.name),
"Name (Z-A)": (a, b) => b.name.localeCompare(a.name)
"Name (A-Z)": (a, b) => a.data.name.localeCompare(b.data.name),
"Name (Z-A)": (a, b) => b.data.name.localeCompare(a.data.name)
}}
installAction={{
label: "Install a theme",
@ -38,6 +43,39 @@ export default function Themes() {
/>
}}
CardComponent={ThemeCard}
OptionsActionSheetComponent={() => {
useObservable([colorsPref]);
return <ActionSheet>
<BottomSheetTitleHeader title="Options" />
<View style={{ paddingVertical: 20, gap: 12 }}>
<TableRadioGroup
title="Override Theme Type"
value={colorsPref.type ?? "auto"}
hasIcons={true}
onChange={type => {
colorsPref.type = type !== "auto" ? type as "dark" | "light" : undefined;
getCurrentTheme()?.data && updateBunnyColor(getCurrentTheme()!.data!, { update: true });
}}
>
<TableRadioRow icon={<TableRowIcon source={findAssetId("RobotIcon")} />} label="Auto" value="auto" />
<TableRadioRow icon={<TableRowIcon source={findAssetId("ThemeDarkIcon")} />} label="Dark" value="dark" />
<TableRadioRow icon={<TableRowIcon source={findAssetId("ThemeLightIcon")} />} label="Light" value="light" />
</TableRadioGroup>
<TableRadioGroup
title="Chat Background"
value={colorsPref.customBackground ?? "shown"}
hasIcons={true}
onChange={type => {
colorsPref.customBackground = type !== "shown" ? type as "hidden" : null;
}}
>
<TableRadioRow icon={<TableRowIcon source={findAssetId("ImageIcon")} />} label="Show" value={"shown"} />
<TableRadioRow icon={<TableRowIcon source={findAssetId("DenyIcon")} />} label="Hide" value={"hidden"} />
</TableRadioGroup>
</View>
</ActionSheet>;
}}
/>
);
}

View file

@ -168,7 +168,26 @@ export const initVendettaObject = (): any => {
showInputAlert: (options: any) => alerts.showInputAlert(options)
},
assets: {
all: assets.assetsMap,
all: new Proxy<any>({}, {
get(cache, p) {
if (typeof p !== "string") return undefined;
if (cache[p]) return cache[p];
for (const asset of assets.iterateAssets()) {
if (asset.name) return cache[p] = asset;
}
},
ownKeys(cache) {
const keys = new Set<string>();
for (const asset of assets.iterateAssets()) {
cache[asset.name] = asset;
keys.add(asset.name);
}
return [...keys];
},
}),
find: (filter: (a: any) => boolean) => assets.findAsset(filter),
getAssetByName: (name: string) => assets.findAsset(name),
getAssetByID: (id: number) => assets.findAsset(id),

View file

@ -1,5 +1,5 @@
import { Emitter, EmitterEvent, EmitterListener, EmitterListenerData } from "@core/vendetta/Emitter";
import { FileManager, MMKVManager } from "@lib/api/native/modules";
import { NativeCacheModule, NativeFileModule } from "@lib/api/native/modules";
import { Platform } from "react-native";
const emitterSymbol = Symbol.for("vendetta.storage.emitter");
@ -142,7 +142,8 @@ export function wrapSync<T extends Promise<any>>(store: T): Awaited<T> {
export function awaitStorage(...stores: any[]) {
return Promise.all(
stores.map(store => new Promise<void>(res => store[syncAwaitSymbol](res)))
stores.map(store =>
new Promise<void>(res => store[syncAwaitSymbol](res)))
);
}
@ -150,11 +151,14 @@ export interface StorageBackend {
get: () => unknown | Promise<unknown>;
set: (data: unknown) => void | Promise<void>;
}
const ILLEGAL_CHARS_REGEX = /[<>:"/\\|?*]/g;
const filePathFixer = (file: string): string => Platform.select({
default: file,
ios: FileManager.saveFileToGallery ? file : `Documents/${file}`,
ios: NativeFileModule.saveFileToGallery ? file : `Documents/${file}`,
});
const getMMKVPath = (name: string): string => {
if (ILLEGAL_CHARS_REGEX.test(name)) {
// Replace forbidden characters with hyphens
@ -165,13 +169,13 @@ const getMMKVPath = (name: string): string => {
};
export const purgeStorage = async (store: string) => {
if (await MMKVManager.getItem(store)) {
MMKVManager.removeItem(store);
if (await NativeCacheModule.getItem(store)) {
NativeCacheModule.removeItem(store);
}
const mmkvPath = getMMKVPath(store);
if (await FileManager.fileExists(`${FileManager.getConstants().DocumentsDirPath}/${mmkvPath}`)) {
await FileManager.removeFile?.("documents", mmkvPath);
if (await NativeFileModule.fileExists(`${NativeFileModule.getConstants().DocumentsDirPath}/${mmkvPath}`)) {
await NativeFileModule.removeFile?.("documents", mmkvPath);
}
};
@ -180,16 +184,16 @@ export const createMMKVBackend = (store: string, defaultData = {}) => {
const defaultStr = JSON.stringify(defaultData);
return createFileBackend(mmkvPath, defaultData, (async () => {
const path = `${FileManager.getConstants().DocumentsDirPath}/${mmkvPath}`;
if (await FileManager.fileExists(path)) return;
const path = `${NativeFileModule.getConstants().DocumentsDirPath}/${mmkvPath}`;
if (await NativeFileModule.fileExists(path)) return;
let oldData = await MMKVManager.getItem(store) ?? defaultStr;
let oldData = await NativeCacheModule.getItem(store) ?? defaultStr;
// From the testing on Android, it seems to return this if the data is too large
if (oldData === "!!LARGE_VALUE!!") {
const cachePath = `${FileManager.getConstants().CacheDirPath}/mmkv/${store}`;
if (await FileManager.fileExists(cachePath)) {
oldData = await FileManager.readFile(cachePath, "utf8");
const cachePath = `${NativeFileModule.getConstants().CacheDirPath}/mmkv/${store}`;
if (await NativeFileModule.fileExists(cachePath)) {
oldData = await NativeFileModule.readFile(cachePath, "utf8");
} else {
console.log(`${store}: Experienced data loss :(`);
oldData = defaultStr;
@ -203,9 +207,9 @@ export const createMMKVBackend = (store: string, defaultData = {}) => {
oldData = defaultStr;
}
await FileManager.writeFile("documents", filePathFixer(mmkvPath), oldData, "utf8");
if (await MMKVManager.getItem(store) !== null) {
MMKVManager.removeItem(store);
await NativeFileModule.writeFile("documents", filePathFixer(mmkvPath), oldData, "utf8");
if (await NativeCacheModule.getItem(store) !== null) {
NativeCacheModule.removeItem(store);
console.log(`Successfully migrated ${store} store from MMKV storage to fs`);
}
})());
@ -215,10 +219,10 @@ export const createFileBackend = (file: string, defaultData = {}, migratePromise
return {
get: async () => {
await migratePromise;
const path = `${FileManager.getConstants().DocumentsDirPath}/${file}`;
const path = `${NativeFileModule.getConstants().DocumentsDirPath}/${file}`;
if (await FileManager.fileExists(path)) {
const content = await FileManager.readFile(path, "utf8");
if (await NativeFileModule.fileExists(path)) {
const content = await NativeFileModule.readFile(path, "utf8");
try {
return JSON.parse(content);
} catch {
@ -226,12 +230,12 @@ export const createFileBackend = (file: string, defaultData = {}, migratePromise
}
}
await FileManager.writeFile("documents", filePathFixer(file), JSON.stringify(defaultData), "utf8");
return JSON.parse(await FileManager.readFile(path, "utf8"));
await NativeFileModule.writeFile("documents", filePathFixer(file), JSON.stringify(defaultData), "utf8");
return JSON.parse(await NativeFileModule.readFile(path, "utf8"));
},
set: async data => {
await migratePromise;
await FileManager.writeFile("documents", filePathFixer(file), JSON.stringify(data), "utf8");
await NativeFileModule.writeFile("documents", filePathFixer(file), JSON.stringify(data), "utf8");
}
};
};

View file

@ -6,41 +6,24 @@ import { initVendettaObject } from "@core/vendetta/api";
import { VdPluginManager } from "@core/vendetta/plugins";
import { updateFonts } from "@lib/addons/fonts";
import { initPlugins, updatePlugins } from "@lib/addons/plugins";
import { initThemes, patchChatBackground } from "@lib/addons/themes";
import { initThemes } from "@lib/addons/themes";
import { patchCommands } from "@lib/api/commands";
import { patchLogHook } from "@lib/api/debug";
import { injectFluxInterceptor } from "@lib/api/flux";
import { writeFile } from "@lib/api/native/fs";
import { isPyonLoader, isThemeSupported } from "@lib/api/native/loader";
import { patchJsx } from "@lib/api/react/jsx";
import { logger } from "@lib/utils/logger";
import { patchSettings } from "@ui/settings";
import * as lib from "./lib";
function maybeLoadThemes() {
if (!isThemeSupported()) return;
try {
if (isPyonLoader()) {
writeFile("../vendetta_theme.json", "null");
}
initThemes();
} catch (e) {
console.error("Failed to initialize themes", e);
}
}
export default async () => {
maybeLoadThemes();
// Load everything in parallel
await Promise.all([
initThemes(),
injectFluxInterceptor(),
patchSettings(),
patchLogHook(),
patchCommands(),
patchChatBackground(),
patchJsx(),
initVendettaObject(),
initFetchI18nStrings(),

View file

@ -8,14 +8,21 @@ import { BunnyPluginObject } from "./types";
type DisposableFn = (...props: any[]) => () => unknown;
function shimDisposableFn<F extends DisposableFn>(unpatches: (() => void)[], f: F): F {
return ((...props: Parameters<F>) => {
const dummy = ((...props: Parameters<F>) => {
const up = f(...props);
unpatches.push(up);
return up;
}) as F;
for (const key in f) if (typeof f[key] === "function") {
// @ts-ignore
dummy[key] = shimDisposableFn(unpatches, f[key] as DisposableFn);
}
return dummy;
}
export function createBunnyPluginAPI(id: string) {
export function createBunnyPluginApi(id: string) {
const disposers = new Array<DisposableFn>;
// proxying this would be a good idea

View file

@ -1,11 +1,11 @@
import { getCorePlugins } from "@core/plugins";
import { readFile, removeFile, writeFile } from "@lib/api/native/fs";
import { awaitStorage, createStorage, getPreloadedStorage, preloadStorageIfExists, updateStorage } from "@lib/api/storage";
import { awaitStorage, createStorage, getPreloadedStorage, preloadStorageIfExists, purgeStorage, updateStorage } from "@lib/api/storage";
import { safeFetch } from "@lib/utils";
import { OFFICIAL_PLUGINS_REPO_URL } from "@lib/utils/constants";
import { semver } from "@metro/common";
import { createBunnyPluginAPI } from "./api";
import { createBunnyPluginApi } from "./api";
import * as t from "./types";
type PluginInstantiator = (
@ -20,13 +20,11 @@ export const corePluginInstances = new Map<string, t.PluginInstanceInternal>();
export const registeredPlugins = new Map<string, t.BunnyPluginManifest>();
export const pluginInstances = new Map<string, t.PluginInstanceInternal>();
export const apiObjects = new Map<string, ReturnType<typeof createBunnyPluginAPI>>();
export const apiObjects = new Map<string, ReturnType<typeof createBunnyPluginApi>>();
export const pluginRepositories = createStorage<t.PluginRepoStorage>("plugins/repositories.json");
export const pluginSettings = createStorage<t.PluginSettingsStorage>("plugins/settings.json");
const manifestToId = new WeakMap<t.BunnyPluginManifest, string>();
const _fetch = (repoUrl: string, path: string) => safeFetch(new URL(path, repoUrl), { cache: "no-store" });
const fetchJS = (repoUrl: string, path: string) => _fetch(repoUrl, path).then(r => r.text());
const fetchJSON = (repoUrl: string, path: string) => _fetch(repoUrl, path).then(r => r.json());
@ -38,9 +36,10 @@ function assert<T>(condition: T, id: string, attempt: string): asserts condition
/**
* Checks if a version is newer than the other. However, this comes with an additional logic,
* where if the version are equal, one with prerelease "tag" will be considered "newer"
* @internal
* @returns Whether the version is newer
*/
function newerThan(v1: string, v2: string) {
export function isGreaterVersion(v1: string, v2: string) {
if (semver.gt(v1, v2)) return true;
const coerced = semver.coerce(v1);
if (coerced == null) return false;
@ -55,12 +54,6 @@ export function isCorePlugin(id: string) {
return corePluginInstances.has(id);
}
export function getId<T extends t.BunnyPluginManifest>(manifest: T): string {
const id = manifestToId.get(manifest);
assert(id, manifest?.name ?? "unknown", "getting ID from an unregistered/invalid manifest");
return id;
}
export function getPluginSettingsComponent(id: string): React.ComponentType<any> | null {
const instance = pluginInstances.get(id);
if (!instance) return null;
@ -84,7 +77,7 @@ export function isPluginEnabled(id: string) {
* @returns The newly fetched plugin manifest
*/
export async function updateAndWritePlugin(repoUrl: string, id: string, fetchScript: boolean) {
const manifest: t.BunnyPluginManifestInternal = await fetchJSON(repoUrl, `plugins/${id}/manifest.json`);
const manifest: t.BunnyPluginManifestInternal = await fetchJSON(repoUrl, `builds/${id}/manifest.json`);
// @ts-expect-error - Setting a readonly property
manifest.parentRepository = repoUrl;
@ -93,7 +86,7 @@ export async function updateAndWritePlugin(repoUrl: string, id: string, fetchScr
// @ts-expect-error - Setting a readonly property
manifest.jsPath = `plugins/scripts/${id}.js`;
const js: string = await fetchJS(repoUrl, `plugins/${id}/index.js`);
const js: string = await fetchJS(repoUrl, `builds/${id}/index.js`);
await writeFile(manifest.jsPath, js);
}
@ -126,7 +119,6 @@ export async function refreshPlugin(id: string, repoUrl?: string) {
registeredPlugins.delete(id);
registeredPlugins.set(id, manifest);
manifestToId.set(manifest, id);
await startPlugin(id);
}
@ -160,33 +152,33 @@ export async function updateRepository(repoUrl: string) {
}
}
await Promise.all(Object.keys(repo).map(async pluginId => {
if (!storedRepo || !storedRepo[pluginId] || repo[pluginId].alwaysFetch || newerThan(repo[pluginId].version, storedRepo[pluginId].version)) {
const pluginIds = Object.keys(repo).filter(id => !id.startsWith("$"));
await Promise.all(pluginIds.map(async pluginId => {
if (!storedRepo || !storedRepo[pluginId] || repo[pluginId].alwaysFetch || isGreaterVersion(repo[pluginId].version, storedRepo[pluginId].version)) {
updated = true;
pluginRepositories[repoUrl][pluginId] = repo[pluginId];
await updateAndWritePlugin(repoUrl, pluginId, Boolean(storedRepo && pluginSettings[pluginId]));
} else {
const manifest = await preloadStorageIfExists(`plugins/manifests/${pluginId}.json`);
if (manifest === undefined) { // File does not exist, so do refetch and stuff
if (!manifest) { // File does not exist, so do refetch and stuff
await updateAndWritePlugin(repoUrl, pluginId, Boolean(storedRepo && pluginSettings[pluginId]));
}
}
}));
// Register plugins in this repository
for (const id in repo) {
for (const id of pluginIds) {
const manifest = getPreloadedStorage<t.BunnyPluginManifest>(`plugins/manifests/${id}.json`);
if (manifest === undefined) continue; // shouldn't happen, but just incase if it does
const existing = registeredPlugins.get(id);
// Skip if this version isn't any higher
if (existing && !newerThan(manifest.version, existing.version)) {
if (existing && !isGreaterVersion(manifest.version, existing.version)) {
continue;
}
registeredPlugins.set(id, manifest);
manifestToId.set(manifest, id);
}
return updated;
@ -210,11 +202,13 @@ export async function deleteRepository(repoUrl: string) {
}
// Deregister all plugins under this repository
promQueues.push(purgeStorage(`plugins/manifests/${id}.json`));
registeredPlugins.delete(id);
}
delete pluginRepositories[repoUrl];
await Promise.all(promQueues);
updateAllRepository();
}
/**
@ -225,8 +219,8 @@ export async function deleteRepository(repoUrl: string) {
export async function enablePlugin(id: string, start: boolean) {
assert(isPluginInstalled(id), id, "enable a non-installed plugin");
pluginSettings[id]!!.enabled = true;
if (start) await startPlugin(id);
pluginSettings[id]!.enabled = true;
}
/**
@ -237,7 +231,7 @@ export function disablePlugin(id: string) {
assert(isPluginInstalled(id), id, "disable a non-installed plugin");
pluginInstances.has(id) && stopPlugin(id);
pluginSettings[id]!!.enabled = false;
pluginSettings[id]!.enabled = false;
}
/**
@ -273,6 +267,7 @@ export async function uninstallPlugin(id: string) {
pluginInstances.has(id) && stopPlugin(id);
delete pluginSettings[id];
await purgeStorage(`plugins/storage/${id}.json`);
await removeFile(`plugins/scripts/${id}.js`);
}
@ -280,13 +275,12 @@ export async function uninstallPlugin(id: string) {
* Starts a registered, installed, enabled and unstarted plugin. Otherwise, would throw.
* @param id The enabled plugin ID
*/
export async function startPlugin(id: string) {
export async function startPlugin(id: string, { throwIfDisabled = false, disableWhenThrown = true } = {}) {
const manifest = registeredPlugins.get(id);
// horror
assert(manifest, id, "start a non-registered plugin");
assert(isPluginInstalled(id), id, "start a non-installed plugin");
assert(pluginSettings[id]?.enabled, id, "start a disabled plugin");
assert(!throwIfDisabled || pluginSettings[id]?.enabled, id, "start a disabled plugin");
assert(!pluginInstances.has(id), id, "start an already started plugin");
await preloadStorageIfExists(`plugins/storage/${id}.json`);
@ -308,7 +302,7 @@ export async function startPlugin(id: string) {
// Stage two, load the plugin
try {
const api = createBunnyPluginAPI(id);
const api = createBunnyPluginApi(id);
pluginInstance = instantiator(api.object, p => {
return Object.assign(p, {
manifest
@ -331,9 +325,10 @@ export async function startPlugin(id: string) {
// Stage three (of external plugins), start the plugin
try {
pluginInstance.start?.();
pluginSettings[id]!.enabled = true;
} catch (error) {
disableWhenThrown && disablePlugin(id);
throw new Error("An error occured while starting the plugin", { cause: error });
// TODO: stopPlugin? disablePlugin?
}
}
@ -351,7 +346,7 @@ export function stopPlugin(id: string) {
pluginInstances.delete(id);
}
async function updateAllRepository() {
export async function updateAllRepository() {
try {
await updateRepository(OFFICIAL_PLUGINS_REPO_URL);
} catch (error) {
@ -382,7 +377,6 @@ export async function updatePlugins() {
};
registeredPlugins.set(id, instance.manifest);
manifestToId.set(instance.manifest, id);
corePluginInstances.set(id, instance);
}

View file

@ -1,15 +1,17 @@
import { Author } from "@lib/addons/types";
import { BunnyManifest } from "@lib/addons/types";
import { createStorage } from "@lib/api/storage";
import { Logger } from "@lib/utils/logger";
export interface PluginRepo {
[id: string]: {
version: string;
// For plugin developing convenience, plugins with this on will always get fetched
alwaysFetch?: boolean;
export type PluginRepo = Record<string, {
version: string;
// For plugin developing convenience, plugins with this on will always get fetched
alwaysFetch?: boolean;
}> & {
$meta: {
name: string;
description: string;
};
}
};
export interface PluginRepoStorage {
[repoUrl: string]: PluginRepo;
@ -21,12 +23,10 @@ export interface PluginSettingsStorage {
};
}
export interface BunnyPluginManifest {
readonly id: string;
readonly name: string;
readonly description: string;
readonly version: string;
readonly authors: Author[];
export interface BunnyPluginManifest extends BunnyManifest {
readonly type: "plugin";
readonly spec: 3;
readonly main: string;
}
export interface BunnyPluginManifestInternal extends BunnyPluginManifest {

View file

@ -0,0 +1,19 @@
import patchChatBackground from "./patches/background";
import patchDefinitionAndResolver from "./patches/resolver";
import patchStorage from "./patches/storage";
import { ColorManifest } from "./types";
import { updateBunnyColor } from "./updater";
/** @internal */
export default function initColors(manifest: ColorManifest | null) {
const patches = [
patchStorage(),
patchDefinitionAndResolver(),
patchChatBackground()
];
if (manifest) updateBunnyColor(manifest, { update: false });
return () => patches.forEach(p => p());
}

View file

@ -0,0 +1,138 @@
import { findByProps } from "@metro";
import chroma from "chroma-js";
import { omit } from "es-toolkit";
import { Platform, processColor } from "react-native";
import { colorsPref } from "./preferences";
import { ColorManifest, InternalColorDefinition } from "./types";
const tokenRef = findByProps("SemanticColor");
export function parseColorManifest(manifest: ColorManifest): InternalColorDefinition {
const resolveType = (type = "dark") => (colorsPref.type ?? type) === "dark" ? "darker" : "light";
if (manifest.spec === 3) {
const semanticColorDefinitions: InternalColorDefinition["semantic"] = {};
for (const [semanticColorKey, semanticColorValue] of Object.entries(manifest.main.semantic ?? {})) {
if (typeof semanticColorValue === "object") {
const { type, value, opacity: semanticColorOpacity } = semanticColorValue;
if (type === "raw") {
semanticColorDefinitions[semanticColorKey] = {
value,
opacity: semanticColorOpacity ?? 1,
};
} else {
const rawColorValue = tokenRef.RawColor[value];
semanticColorDefinitions[semanticColorKey] = {
value: rawColorValue,
opacity: semanticColorOpacity ?? 1,
};
}
} else if (typeof semanticColorValue === "string") {
if (semanticColorValue.startsWith("#")) {
semanticColorDefinitions[semanticColorKey] = {
value: chroma.hex(semanticColorValue).hex(),
opacity: 1,
};
} else {
semanticColorDefinitions[semanticColorKey] = {
value: tokenRef.RawColor[semanticColorValue],
opacity: 1,
};
}
} else {
throw new Error(`Invalid semantic definitions: ${semanticColorValue}`);
}
}
if (Platform.OS === "android") applyAndroidAlphaKeys(manifest.main.raw);
return {
spec: 3,
reference: resolveType(manifest.type),
semantic: semanticColorDefinitions,
raw: manifest.main.raw ?? {},
background: manifest.main.background,
};
} else if (manifest.spec === 2) { // is Vendetta theme
const semanticDefinitions: InternalColorDefinition["semantic"] = {};
const background: InternalColorDefinition["background"] | undefined = manifest.background ? {
...omit(manifest.background, ["alpha"]),
opacity: manifest.background.alpha
} : undefined;
if (manifest.semanticColors) {
for (const key in manifest.semanticColors) {
const values = manifest.semanticColors[key].map(c => c || undefined).slice(0, 2);
if (!values[0]) continue;
semanticDefinitions[key] = {
value: normalizeToHex(values[resolveType() === "light" ? 1 : 0])!,
opacity: 1
};
}
}
if (manifest.rawColors) {
for (const key in manifest.rawColors) {
const value = manifest.rawColors[key];
if (!value) continue;
manifest.rawColors[key] = normalizeToHex(value)!;
}
if (Platform.OS === "android") applyAndroidAlphaKeys(manifest.rawColors);
}
return {
spec: 2,
reference: resolveType(),
semantic: semanticDefinitions,
raw: manifest.rawColors ?? {},
background
};
}
throw new Error("Invalid theme spec");
}
export function applyAndroidAlphaKeys(rawColors?: Record<string, string>) {
if (!rawColors) return;
// these are native Discord Android keys
const alphaMap: Record<string, [string, number]> = {
"BLACK_ALPHA_60": ["BLACK", 0.6],
"BRAND_NEW_360_ALPHA_20": ["BRAND_360", 0.2],
"BRAND_NEW_360_ALPHA_25": ["BRAND_360", 0.25],
"BRAND_NEW_500_ALPHA_20": ["BRAND_500", 0.2],
"PRIMARY_DARK_500_ALPHA_20": ["PRIMARY_500", 0.2],
"PRIMARY_DARK_700_ALPHA_60": ["PRIMARY_700", 0.6],
"STATUS_GREEN_500_ALPHA_20": ["GREEN_500", 0.2],
"STATUS_RED_500_ALPHA_20": ["RED_500", 0.2],
};
for (const key in alphaMap) {
const [colorKey, alpha] = alphaMap[key];
if (!rawColors[colorKey]) continue;
rawColors[key] = chroma(rawColors[colorKey]).alpha(alpha).hex();
}
return rawColors;
}
export function normalizeToHex(colorString: string | undefined): string | undefined {
if (colorString === undefined) return undefined;
if (chroma.valid(colorString)) return chroma(colorString).hex();
const color = Number(processColor(colorString));
return chroma.rgb(
color >> 16 & 0xff, // red
color >> 8 & 0xff, // green
color & 0xff, // blue
color >> 24 & 0xff // alpha
).hex();
}

View file

@ -0,0 +1,51 @@
import { colorsPref } from "@lib/addons/themes/colors/preferences";
import { _colorRef } from "@lib/addons/themes/colors/updater";
import { after } from "@lib/api/patcher";
import { useObservable } from "@lib/api/storage";
import { findInReactTree } from "@lib/utils";
import { lazyDestructure } from "@lib/utils/lazy";
import { findByNameLazy, findByProps } from "@metro";
import chroma from "chroma-js";
import { ImageBackground } from "react-native";
const MessagesWrapperConnected = findByNameLazy("MessagesWrapperConnected", false);
const { MessagesWrapper } = lazyDestructure(() => findByProps("MessagesWrapper"));
export default function patchChatBackground() {
const patches = [
after("default", MessagesWrapperConnected, (_, ret) => {
useObservable([colorsPref]);
if (!_colorRef.current || !_colorRef.current.background?.url || colorsPref.customBackground === "hidden") return ret;
return <ImageBackground
style={{ flex: 1, height: "100%" }}
source={_colorRef.current.background?.url && { uri: _colorRef.current.background.url } || 0}
blurRadius={typeof _colorRef.current.background?.blur === "number" ? _colorRef.current.background?.blur : 0}
>
{ret}
</ImageBackground>;
}),
after("render", MessagesWrapper.prototype, (_, ret) => {
if (!_colorRef.current || !_colorRef.current.background?.url) return;
const messagesComponent = findInReactTree(
ret,
x => x && "HACK_fixModalInteraction" in x.props && x?.props?.style
);
if (messagesComponent) {
const backgroundColor = chroma(
messagesComponent.props.style.backgroundColor || "black"
).alpha(
1 - (_colorRef.current.background?.opacity ?? 1)
);
messagesComponent.props.style = [
messagesComponent.props.style,
{ backgroundColor }
];
}
})
];
return () => patches.forEach(x => x());
}

View file

@ -0,0 +1,89 @@
import { _colorRef } from "@lib/addons/themes/colors/updater";
import { NativeThemeModule } from "@lib/api/native/modules";
import { before, instead } from "@lib/api/patcher";
import { findByProps } from "@metro";
import { byMutableProp } from "@metro/filters";
import { createLazyModule } from "@metro/lazy";
import chroma from "chroma-js";
const tokenReference = findByProps("SemanticColor");
const isThemeModule = createLazyModule(byMutableProp("isThemeDark"));
const SEMANTIC_FALLBACK_MAP: Record<string, string> = {
"BG_BACKDROP": "BACKGROUND_FLOATING",
"BG_BASE_PRIMARY": "BACKGROUND_PRIMARY",
"BG_BASE_SECONDARY": "BACKGROUND_SECONDARY",
"BG_BASE_TERTIARY": "BACKGROUND_SECONDARY_ALT",
"BG_MOD_FAINT": "BACKGROUND_MODIFIER_ACCENT",
"BG_MOD_STRONG": "BACKGROUND_MODIFIER_ACCENT",
"BG_MOD_SUBTLE": "BACKGROUND_MODIFIER_ACCENT",
"BG_SURFACE_OVERLAY": "BACKGROUND_FLOATING",
"BG_SURFACE_OVERLAY_TMP": "BACKGROUND_FLOATING",
"BG_SURFACE_RAISED": "BACKGROUND_MOBILE_PRIMARY"
};
export default function patchDefinitionAndResolver() {
const callback = ([theme]: any[]) => theme === _colorRef.key ? [_colorRef.current!.reference] : void 0;
Object.keys(tokenReference.RawColor).forEach(key => {
Object.defineProperty(tokenReference.RawColor, key, {
configurable: true,
enumerable: true,
get: () => {
const ret = _colorRef.current?.raw[key];
return ret || _colorRef.origRaw[key];
}
});
});
const unpatches = [
before("isThemeDark", isThemeModule, callback),
before("isThemeLight", isThemeModule, callback),
before("updateTheme", NativeThemeModule, callback),
instead("resolveSemanticColor", tokenReference.default.meta ?? tokenReference.default.internal, (args: any[], orig: any) => {
if (!_colorRef.current) return orig(...args);
if (args[0] !== _colorRef.key) return orig(...args);
args[0] = _colorRef.current.reference;
const [name, colorDef] = extractInfo(_colorRef.current!.reference, args[1]);
let semanticDef = _colorRef.current.semantic[name];
if (!semanticDef && _colorRef.current.spec === 2 && name in SEMANTIC_FALLBACK_MAP) {
semanticDef = _colorRef.current.semantic[SEMANTIC_FALLBACK_MAP[name]];
}
if (semanticDef?.value) {
if (semanticDef.opacity === 1) return semanticDef.value;
return chroma(semanticDef.value).alpha(semanticDef.opacity).hex();
}
const rawValue = _colorRef.current.raw[colorDef.raw];
if (rawValue) {
// Set opacity if needed
return colorDef.opacity === 1 ? rawValue : chroma(rawValue).alpha(colorDef.opacity).hex();
}
// Fallback to default
return orig(...args);
}),
() => {
// Not the actual module but.. yeah.
Object.defineProperty(tokenReference, "RawColor", {
configurable: true,
writable: true,
value: _colorRef.origRaw
});
}
];
return () => unpatches.forEach(p => p());
}
function extractInfo(themeName: string, colorObj: any): [name: string, colorDef: any] {
// @ts-ignore - assigning to extractInfo._sym
const propName = colorObj[extractInfo._sym ??= Object.getOwnPropertySymbols(colorObj)[0]];
const colorDef = tokenReference.SemanticColor[propName];
return [propName, colorDef[themeName]];
}

View file

@ -0,0 +1,38 @@
import { _colorRef } from "@lib/addons/themes/colors/updater";
import { after, before } from "@lib/api/patcher";
import { findInTree } from "@lib/utils";
import { proxyLazy } from "@lib/utils/lazy";
import { findByProps } from "@metro";
const mmkvStorage = proxyLazy(() => {
const newModule = findByProps("impl");
if (typeof newModule?.impl === "object") return newModule.impl;
return findByProps("storage");
});
export default function patchStorage() {
const patchedKeys = new Set(["ThemeStore", "SelectivelySyncedUserSettingsStore"]);
const patches = [
after("get", mmkvStorage, ([key], ret) => {
if (!_colorRef.current || !patchedKeys.has(key)) return;
const state = findInTree(ret._state, s => typeof s.theme === "string");
if (state) state.theme = _colorRef.key;
}),
before("set", mmkvStorage, ([key, value]) => {
if (!patchedKeys.has(key)) return;
const json = JSON.stringify(value);
const lastSetDiscordTheme = _colorRef.lastSetDiscordTheme ?? "darker";
const replaced = json.replace(
/"theme":"bn-theme-\d+"/,
`"theme":${JSON.stringify(lastSetDiscordTheme)}`
);
return [key, JSON.parse(replaced)];
})
];
return () => patches.forEach(p => p());
}

View file

@ -0,0 +1,18 @@
import { createStorage } from "@lib/api/storage";
interface BunnyColorPreferencesStorage {
selected: string | null;
type?: "dark" | "light" | null;
customBackground: "hidden" | null;
per?: Record<string, { autoUpdate?: string; } | undefined>;
}
export const colorsPref = createStorage<BunnyColorPreferencesStorage>(
"themes/colors/preferences.json",
{
dflt: {
selected: null,
customBackground: null
}
}
);

View file

@ -0,0 +1,52 @@
import { Author, BunnyManifest } from "@lib/addons/types";
interface SemanticReference {
type: "color" | "raw";
value: string;
opacity?: number;
}
interface BackgroundDefinition {
url: string;
blur?: number;
opacity?: number;
}
export interface BunnyColorManifest extends BunnyManifest {
type: "color";
spec: 3;
main: {
type: "dark" | "light";
semantic?: Record<string, string | SemanticReference>;
raw?: Record<string, string>;
background?: BackgroundDefinition;
}
}
export interface VendettaThemeManifest {
spec: 2;
name: string;
description?: string;
authors?: Author[];
semanticColors?: Record<string, (string | false)[]>;
rawColors?: Record<string, string>;
background?: {
url: string;
blur?: number;
alpha?: number;
};
}
/** @internal */
export interface InternalColorDefinition {
spec: 2 | 3;
reference: "darker" | "light";
semantic: Record<string, {
value: string;
opacity: number;
}>;
raw: Record<string, string>;
background?: BackgroundDefinition;
}
export type ColorManifest = BunnyColorManifest | VendettaThemeManifest;

View file

@ -0,0 +1,59 @@
import { settings } from "@lib/api/settings";
import { findByProps, findByPropsLazy, findByStoreNameLazy } from "@metro";
import { parseColorManifest } from "./parser";
import { ColorManifest, InternalColorDefinition } from "./types";
const tokenRef = findByProps("SemanticColor");
const origRawColor = { ...tokenRef.RawColor };
const AppearanceManager = findByPropsLazy("updateTheme");
const ThemeStore = findByStoreNameLazy("ThemeStore");
const FormDivider = findByPropsLazy("DIVIDER_COLORS");
let _inc = 1;
interface InternalColorRef {
key: `bn-theme-${string}`;
current: InternalColorDefinition | null;
readonly origRaw: Record<string, string>;
lastSetDiscordTheme: string;
}
/** @internal */
export const _colorRef: InternalColorRef = {
current: null,
key: `bn-theme-${_inc}`,
origRaw: origRawColor,
lastSetDiscordTheme: "darker"
};
export function updateBunnyColor(colorManifest: ColorManifest | null, { update = true }) {
if (settings.safeMode?.enabled) return;
const internalDef = colorManifest ? parseColorManifest(colorManifest) : null;
const ref = Object.assign(_colorRef, {
current: internalDef,
key: `bn-theme-${++_inc}`,
lastSetDiscordTheme: !ThemeStore.theme.startsWith("bn-theme-")
? ThemeStore.theme
: _colorRef.lastSetDiscordTheme
});
if (internalDef != null) {
tokenRef.Theme[ref.key.toUpperCase()] = ref.key;
FormDivider.DIVIDER_COLORS[ref.key] = FormDivider.DIVIDER_COLORS[ref.current!.reference];
Object.keys(tokenRef.Shadow).forEach(k => tokenRef.Shadow[k][ref.key] = tokenRef.Shadow[k][ref.current!.reference]);
Object.keys(tokenRef.SemanticColor).forEach(k => {
tokenRef.SemanticColor[k][ref.key] = {
...tokenRef.SemanticColor[k][ref.current!.reference]
};
});
}
if (update) {
AppearanceManager.setShouldSyncAppearanceSettings(false);
AppearanceManager.updateTheme(internalDef != null ? ref.key : ref.lastSetDiscordTheme);
}
}

View file

@ -1,77 +1,28 @@
/**
* Theming system in Bunny is currently a prototype, expect an unreadable theme implementation below
*/
import { awaitStorage, createFileBackend, createMMKVBackend, createStorage, wrapSync } from "@core/vendetta/storage";
import { Author } from "@lib/addons/types";
import { getStoredTheme, getThemeFilePath } from "@lib/api/native/loader";
import { ThemeManager } from "@lib/api/native/modules";
import { after, before, instead } from "@lib/api/patcher";
import { findInReactTree, safeFetch } from "@lib/utils";
import { lazyDestructure, proxyLazy } from "@lib/utils/lazy";
import { byMutableProp } from "@metro/filters";
import { createLazyModule } from "@metro/lazy";
import { findByNameLazy, findByProps, findByPropsLazy, findByStoreNameLazy } from "@metro/wrappers";
import chroma from "chroma-js";
import { ImageBackground, Platform, processColor } from "react-native";
import { writeFile } from "@lib/api/native/fs";
import { getStoredTheme, getThemeFilePath, isPyonLoader, isThemeSupported } from "@lib/api/native/loader";
import { awaitStorage as newAwaitStorage } from "@lib/api/storage";
import { safeFetch } from "@lib/utils";
import { Platform } from "react-native";
export interface ThemeData {
name: string;
description?: string;
authors?: Author[];
spec: number;
semanticColors?: Record<string, (string | false)[]>;
rawColors?: Record<string, string>;
background?: {
url: string;
blur?: number;
/**
* The alpha value of the background.
* `CHAT_BACKGROUND` of semanticColors alpha value will be ignored when this is specified
*/
alpha?: number;
};
}
import initColors from "./colors";
import { applyAndroidAlphaKeys, normalizeToHex } from "./colors/parser";
import { colorsPref } from "./colors/preferences";
import { VendettaThemeManifest } from "./colors/types";
import { updateBunnyColor } from "./colors/updater";
export interface Theme {
export interface VdThemeInfo {
id: string;
selected: boolean;
data: ThemeData;
data: VendettaThemeManifest;
}
//! As of 173.10, early-finding this does not work.
// Somehow, this is late enough, though?
export const color = findByPropsLazy("SemanticColor");
export const themes = wrapSync(createStorage<Record<string, VdThemeInfo>>(createMMKVBackend("VENDETTA_THEMES")));
const mmkvStorage = proxyLazy(() => {
const newModule = findByProps("impl");
if (typeof newModule?.impl === "object") return newModule.impl;
return findByProps("storage");
});
const appearanceManager = findByPropsLazy("updateTheme");
const ThemeStore = findByStoreNameLazy("ThemeStore");
const formDividerModule = findByPropsLazy("DIVIDER_COLORS");
const MessagesWrapperConnected = findByNameLazy("MessagesWrapperConnected", false);
const { MessagesWrapper } = lazyDestructure(() => findByProps("MessagesWrapper"));
const isThemeModule = createLazyModule(byMutableProp("isThemeDark"));
export const themes = wrapSync(createStorage<Record<string, Theme>>(createMMKVBackend("VENDETTA_THEMES")));
const semanticAlternativeMap: Record<string, string> = {
"BG_BACKDROP": "BACKGROUND_FLOATING",
"BG_BASE_PRIMARY": "BACKGROUND_PRIMARY",
"BG_BASE_SECONDARY": "BACKGROUND_SECONDARY",
"BG_BASE_TERTIARY": "BACKGROUND_SECONDARY_ALT",
"BG_MOD_FAINT": "BACKGROUND_MODIFIER_ACCENT",
"BG_MOD_STRONG": "BACKGROUND_MODIFIER_ACCENT",
"BG_MOD_SUBTLE": "BACKGROUND_MODIFIER_ACCENT",
"BG_SURFACE_OVERLAY": "BACKGROUND_FLOATING",
"BG_SURFACE_OVERLAY_TMP": "BACKGROUND_FLOATING",
"BG_SURFACE_RAISED": "BACKGROUND_MOBILE_PRIMARY"
};
async function writeTheme(theme: Theme | {}) {
/**
* @internal
*/
export async function writeThemeToNative(theme: VdThemeInfo | {}) {
if (typeof theme !== "object") throw new Error("Theme must be an object");
// Save the current theme as current-theme.json. When supported by loader,
@ -79,59 +30,14 @@ async function writeTheme(theme: Theme | {}) {
await createFileBackend(getThemeFilePath() || "theme.json").set(theme);
}
/**
* @internal
*/
export function patchChatBackground() {
const patches = [
after("default", MessagesWrapperConnected, (_, ret) => enabled ? React.createElement(ImageBackground, {
style: { flex: 1, height: "100%" },
source: currentTheme?.data?.background?.url && { uri: currentTheme.data.background.url } || 0,
blurRadius: typeof currentTheme?.data?.background?.blur === "number" ? currentTheme?.data?.background?.blur : 0,
children: ret,
}) : ret),
after("render", MessagesWrapper.prototype, (_, ret) => {
if (!enabled || !currentTheme?.data?.background?.url) return;
// HORRIBLE
const Messages = findInReactTree(ret, x => x && "HACK_fixModalInteraction" in x.props && x?.props?.style);
if (Messages) {
Messages.props.style = [
Messages.props.style,
{
backgroundColor: chroma(Messages.props.style.backgroundColor || "black")
.alpha(1 - (currentTheme?.data.background?.alpha ?? 1)).hex()
},
];
}
else console.error("Didn't find Messages when patching MessagesWrapper!");
})
];
return () => patches.forEach(x => x());
}
function normalizeToHex(colorString: string): string {
if (chroma.valid(colorString)) return chroma(colorString).hex();
const color = Number(processColor(colorString));
return chroma.rgb(
color >> 16 & 0xff, // red
color >> 8 & 0xff, // green
color & 0xff, // blue
color >> 24 & 0xff // alpha
).hex();
}
// Process data for some compatiblity with native side
function processData(data: ThemeData) {
function processData(data: VendettaThemeManifest) {
if (data.semanticColors) {
const { semanticColors } = data;
for (const key in semanticColors) {
for (const index in semanticColors[key]) {
semanticColors[key][index] &&= normalizeToHex(semanticColors[key][index] as string);
semanticColors[key][index] &&= normalizeToHex(semanticColors[key][index] as string) || false;
}
}
}
@ -140,7 +46,8 @@ function processData(data: ThemeData) {
const { rawColors } = data;
for (const key in rawColors) {
data.rawColors[key] = normalizeToHex(rawColors[key]);
const normalized = normalizeToHex(rawColors[key]);
if (normalized) data.rawColors[key] = normalized;
}
if (Platform.OS === "android") applyAndroidAlphaKeys(rawColors);
@ -153,63 +60,55 @@ function processData(data: ThemeData) {
return data;
}
function applyAndroidAlphaKeys(rawColors: Record<string, string>) {
// these are native Discord Android keys
const alphaMap: Record<string, [string, number]> = {
"BLACK_ALPHA_60": ["BLACK", 0.6],
"BRAND_NEW_360_ALPHA_20": ["BRAND_360", 0.2],
"BRAND_NEW_360_ALPHA_25": ["BRAND_360", 0.25],
"BRAND_NEW_500_ALPHA_20": ["BRAND_500", 0.2],
"PRIMARY_DARK_500_ALPHA_20": ["PRIMARY_500", 0.2],
"PRIMARY_DARK_700_ALPHA_60": ["PRIMARY_700", 0.6],
"STATUS_GREEN_500_ALPHA_20": ["GREEN_500", 0.2],
"STATUS_RED_500_ALPHA_20": ["RED_500", 0.2],
};
function validateTheme(themeJSON: any): boolean {
if (typeof themeJSON !== "object" || themeJSON === null) return false;
if (themeJSON.spec !== 2 && themeJSON.spec !== 3) return false;
if (themeJSON.spec === 3 && !themeJSON.main) return false;
for (const key in alphaMap) {
const [colorKey, alpha] = alphaMap[key];
if (!rawColors[colorKey]) continue;
rawColors[key] = chroma(rawColors[colorKey]).alpha(alpha).hex();
}
return true;
}
export async function fetchTheme(id: string, selected = false) {
export async function fetchTheme(url: string, selected = false) {
let themeJSON: any;
try {
themeJSON = await (await safeFetch(id, { cache: "no-store" })).json();
themeJSON = await (await safeFetch(url, { cache: "no-store" })).json();
} catch {
throw new Error(`Failed to fetch theme at ${id}`);
throw new Error(`Failed to fetch theme at ${url}`);
}
themes[id] = {
id: id,
// Validate theme
if (!validateTheme(themeJSON)) throw new Error(`Invalid theme at ${url}`);
themes[url] = {
id: url,
selected: selected,
data: processData(themeJSON),
};
// TODO: Should we prompt when the selected theme is updated?
if (selected) {
writeTheme(themes[id]);
applyTheme(themes[id], vdThemeFallback);
writeThemeToNative(themes[url]);
updateBunnyColor(themes[url].data, { update: true });
}
}
export async function installTheme(id: string) {
if (typeof id !== "string" || id in themes) throw new Error("Theme already installed");
await fetchTheme(id);
export async function installTheme(url: string) {
if (typeof url !== "string" || url in themes) throw new Error("Theme already installed");
await fetchTheme(url);
}
export function selectTheme(theme: Theme | null, write = true) {
export function selectTheme(theme: VdThemeInfo | null, write = true) {
if (theme) theme.selected = true;
Object.keys(themes).forEach(
k => themes[k].selected = themes[k].id === theme?.id
);
if (theme == null && write) {
return writeTheme({});
updateBunnyColor(null, { update: true });
return writeThemeToNative({});
} else if (theme) {
return writeTheme(theme);
updateBunnyColor(theme.data, { update: true });
return writeThemeToNative(theme);
}
}
@ -221,13 +120,6 @@ export async function removeTheme(id: string) {
return theme.selected;
}
/**
* @internal
*/
export function getThemeFromLoader(): Theme | null {
return getStoredTheme();
}
export async function updateThemes() {
await awaitStorage(themes);
const currentTheme = getThemeFromLoader();
@ -235,176 +127,34 @@ export async function updateThemes() {
}
export function getCurrentTheme() {
return currentTheme;
}
const origRawColor = { ...color.RawColor };
let inc = 0;
let vdKey = "vd-theme";
let vdThemeFallback = "darker";
let enabled = false;
let currentTheme: Theme | null;
let storageResolved = false;
const discordThemes = new Set(["darker", "midnight", "dark", "light"]);
function isDiscordTheme(name: string) {
return discordThemes.has(name);
}
function patchColor() {
const callback = ([theme]: any[]) => theme === vdKey ? [vdThemeFallback] : void 0;
Object.keys(color.RawColor).forEach(k => {
Object.defineProperty(color.RawColor, k, {
configurable: true,
enumerable: true,
get: () => {
return enabled ? currentTheme?.data?.rawColors?.[k] ?? origRawColor[k] : origRawColor[k];
}
});
});
before("isThemeDark", isThemeModule, callback);
before("isThemeLight", isThemeModule, callback);
before("updateTheme", ThemeManager, callback);
after("get", mmkvStorage, ([a], ret) => {
if (a === "SelectivelySyncedUserSettingsStore") {
storageResolved = true;
if (ret?._state?.appearance?.settings?.theme && enabled) {
vdThemeFallback = ret._state.appearance.settings.theme;
ret._state.appearance.settings.theme = vdKey;
}
} else if (a === "ThemeStore") {
storageResolved = true;
if (ret?._state?.theme && enabled) {
vdThemeFallback = ret._state.theme;
ret._state.theme = vdKey;
}
}
});
// Prevent setting to real Discord settings
before("set", mmkvStorage, args => {
if (!args[1]) return;
const key = args[0];
const value = JSON.parse(JSON.stringify(args[1]));
const interceptors: Record<string, () => void> = ({
SelectivelySyncedUserSettingsStore: () => {
if (value._state?.appearance?.settings?.theme) {
const { theme } = value._state?.appearance?.settings ?? {};
if (isDiscordTheme(theme)) {
vdThemeFallback = theme;
} else {
value._state.appearance.settings.theme = vdThemeFallback;
}
}
},
ThemeStore: () => {
if (value._state?.theme) {
const { theme } = value._state;
if (isDiscordTheme(theme)) {
vdThemeFallback = theme;
} else {
value._state.theme = vdThemeFallback;
}
}
}
});
if (!(key in interceptors)) return args;
interceptors[key]();
return [key, value];
});
instead("resolveSemanticColor", color.default.meta ?? color.default.internal, (args, orig) => {
if (!enabled || !currentTheme) return orig(...args);
if (args[0] !== vdKey) return orig(...args);
args[0] = vdThemeFallback;
const [name, colorDef] = extractInfo(vdThemeFallback, args[1]);
const themeIndex = vdThemeFallback === "midnight" ? 2 : vdThemeFallback === "light" ? 1 : 0;
//! As of 192.7, Tabs v2 uses BG_ semantic colors instead of BACKGROUND_ ones
const alternativeName = semanticAlternativeMap[name] ?? name;
const semanticColorVal = (currentTheme.data?.semanticColors?.[name] ?? currentTheme.data?.semanticColors?.[alternativeName])?.[themeIndex];
if (semanticColorVal) return semanticColorVal;
const rawValue = currentTheme.data?.rawColors?.[colorDef.raw];
if (rawValue) {
// Set opacity if needed
return colorDef.opacity === 1 ? rawValue : chroma(rawValue).alpha(colorDef.opacity).hex();
}
// Fallback to default
return orig(...args);
});
}
function getDefaultFallbackTheme(fallback: string = vdThemeFallback) {
const theme = ThemeStore.theme.toLowerCase() as string;
if (isDiscordTheme(theme)) {
return theme;
} else {
return fallback;
}
}
export function applyTheme(appliedTheme: Theme | null, fallbackTheme?: string) {
if (!fallbackTheme) fallbackTheme = getDefaultFallbackTheme();
currentTheme = appliedTheme;
enabled = !!currentTheme;
vdThemeFallback = fallbackTheme!!;
vdKey = `vd-theme-${inc++}-${fallbackTheme}`;
if (appliedTheme) {
color.Theme[vdKey.toUpperCase()] = vdKey;
formDividerModule.DIVIDER_COLORS[vdKey] = formDividerModule.DIVIDER_COLORS[vdThemeFallback];
Object.keys(color.Shadow).forEach(k => color.Shadow[k][vdKey] = color.Shadow[k][vdThemeFallback]);
Object.keys(color.SemanticColor).forEach(k => {
color.SemanticColor[k][vdKey] = {
...color.SemanticColor[k][vdThemeFallback],
override: appliedTheme?.data?.semanticColors?.[k]?.[0]
};
});
}
if (storageResolved) {
appearanceManager.setShouldSyncAppearanceSettings(false);
appearanceManager.updateTheme(appliedTheme ? vdKey : fallbackTheme);
}
return Object.values(themes).find(t => t.selected) ?? null;
}
/**
* @internal
*/
export function initThemes() {
const currentTheme = getThemeFromLoader();
enabled = Boolean(currentTheme);
patchColor();
applyTheme(currentTheme, vdThemeFallback);
updateThemes().catch(e => console.error("Failed to update themes", e));
export function getThemeFromLoader(): VdThemeInfo | null {
return getStoredTheme();
}
function extractInfo(themeName: string, colorObj: any): [name: string, colorDef: any] {
// @ts-ignore - assigning to extractInfo._sym
const propName = colorObj[extractInfo._sym ??= Object.getOwnPropertySymbols(colorObj)[0]];
const colorDef = color.SemanticColor[propName];
/**
* @internal
*/
export async function initThemes() {
if (!isThemeSupported()) return;
return [propName, colorDef[themeName]];
try {
if (isPyonLoader()) {
writeFile("../vendetta_theme.json", "null");
}
await newAwaitStorage(colorsPref);
const currentTheme = getThemeFromLoader();
initColors(currentTheme?.data ?? null);
updateThemes().catch(e => console.error("Failed to update themes", e));
} catch (e) {
console.error("Failed to initialize themes", e);
}
}

View file

@ -1,4 +1,17 @@
export interface Author {
name: string;
id: string;
export type Author = { name: string, id?: `${bigint}`; };
export interface BunnyManifest {
readonly id: string;
readonly spec: number;
readonly version: string;
readonly type: string;
readonly display: {
readonly name: string;
readonly description?: string;
readonly authors?: Author[];
};
readonly main: unknown;
readonly extras?: {
readonly [key: string]: any;
};
}

View file

@ -1,94 +0,0 @@
import { after } from "@lib/api/patcher";
import { getMetroCache, indexAssetName } from "@metro/internals/caches";
import { getImportingModuleId, requireModule } from "@metro/internals/modules";
// TODO: Deprecate this map, make another that maps to an array of assets (Asset[]) instead
/**
* @internal
* Pitfall: Multiple assets may have the same name, this is fine if we require the asset only for display,\
* but not when used to get the registered id/index. In some condition, this would break some plugins like HideGiftButton that gets id by name for patching.\
*/
export const assetsMap: Record<string, Asset> = new Proxy<any>({}, {
get(cache, p) {
if (typeof p !== "string") return undefined;
if (cache[p]) return cache[p];
const moduleIds = getMetroCache().assetsIndex[p];
if (moduleIds == null) return undefined;
for (const id in moduleIds) {
const assetIndex = requireModule(Number(id));
if (typeof assetIndex !== "number") continue;
const assetDefinition = assetsModule.getAssetByID(assetIndex);
if (!assetDefinition) continue;
assetDefinition.index ??= assetDefinition.id ??= assetIndex;
assetDefinition.moduleId ??= id;
// ??= is intended, we only assign to the first asset registered
cache[p] ??= assetDefinition;
}
return cache[p];
},
ownKeys(cache) {
const keys = [] as Array<string>;
for (const key in getMetroCache().assetsIndex) {
cache[key] = this.get!(cache, key, {});
if (cache[key]) keys.push(key);
}
return keys;
},
});
export interface Asset {
/** @deprecated */
id: number;
index: number;
name: string;
moduleId: number;
}
interface AssetModule {
registerAsset(assetDefinition: any): number;
getAssetByID(id: number): any;
}
let assetsModule: AssetModule;
/**
* @internal
*/
export function patchAssets(module: AssetModule) {
if (assetsModule) return;
assetsModule = module;
const unpatch = after("registerAsset", assetsModule, ([asset]: Asset[]) => {
const moduleId = getImportingModuleId();
if (moduleId !== -1) indexAssetName(asset.name, moduleId);
});
return unpatch;
}
/**
* Returns the first asset registry by its registry id (number), name (string) or given filter (function)
*/
export function findAsset(id: number): Asset | undefined;
export function findAsset(name: string): Asset | undefined;
export function findAsset(filter: (a: Asset) => boolean): Asset | undefined;
export function findAsset(param: number | string | ((a: Asset) => boolean)) {
if (typeof param === "number") return assetsModule.getAssetByID(param);
if (typeof param === "string") return assetsMap[param];
return Object.values(assetsMap).find(param);
}
/**
* Returns the first asset ID in the registry with the given name
*/
export function findAssetId(name: string) {
return assetsMap[name]?.index;
}

View file

@ -0,0 +1,78 @@
import { getMetroCache } from "@metro/internals/caches";
import { ModuleFlags } from "@metro/internals/enums";
import { requireModule } from "@metro/internals/modules";
import { assetsModule } from "./patches";
export interface Asset {
id: number;
name: string;
moduleId: number;
}
// Cache common usage
const _nameToAssetCache = {} as Record<string, Asset>;
export function* iterateAssets() {
const { flagsIndex } = getMetroCache();
const yielded = new Set<number>();
for (const id in flagsIndex) {
if (flagsIndex[id] & ModuleFlags.ASSET) {
const assetId = requireModule(Number(id));
if (typeof assetId !== "number" || yielded.has(assetId)) continue;
yield getAssetById(assetId);
yielded.add(assetId);
}
}
}
// Apply additional properties for convenience
function getAssetById(id: number): Asset {
const asset = assetsModule.getAssetByID(id);
if (!asset) return asset;
return Object.assign(asset, { id });
}
/**
* Returns the first asset registry by its registry id (number), name (string) or given filter (function)
*/
export function findAsset(id: number): Asset | undefined;
export function findAsset(name: string): Asset | undefined;
export function findAsset(filter: (a: Asset) => boolean): Asset | undefined;
export function findAsset(param: number | string | ((a: Asset) => boolean)) {
if (typeof param === "number") return getAssetById(param);
if (typeof param === "string" && _nameToAssetCache[param]) {
return _nameToAssetCache[param];
}
for (const asset of iterateAssets()) {
if (typeof param === "string" && asset.name === param) {
_nameToAssetCache[param] = asset;
return asset;
} else if (typeof param === "function" && param(asset)) {
return asset;
}
}
}
export function filterAssets(param: string | ((a: Asset) => boolean)) {
const filteredAssets = [] as Array<Asset>;
for (const asset of iterateAssets()) {
if (typeof param === "string" ? asset.name === param : param(asset)) {
filteredAssets.push(asset);
}
}
return filteredAssets;
}
/**
* Returns the first asset ID in the registry with the given name
*/
export function findAssetId(name: string) {
return findAsset(name)?.id;
}

View file

@ -0,0 +1,25 @@
import { after } from "@lib/api/patcher";
import { indexAssetModuleFlag } from "@metro/internals/caches";
import { getImportingModuleId } from "@metro/internals/modules";
interface AssetModule {
registerAsset(assetDefinition: any): number;
getAssetByID(id: number): any;
}
export let assetsModule: AssetModule;
/**
* @internal
*/
export function patchAssets(module: AssetModule) {
if (assetsModule) return;
assetsModule = module;
const unpatch = after("registerAsset", assetsModule, () => {
const moduleId = getImportingModuleId();
if (moduleId !== -1) indexAssetModuleFlag(moduleId);
});
return unpatch;
}

View file

@ -1,7 +1,7 @@
import { getThemeFromLoader, selectTheme, themes } from "@lib/addons/themes";
import { findAssetId } from "@lib/api/assets";
import { getLoaderName, getLoaderVersion, isThemeSupported } from "@lib/api/native/loader";
import { BundleUpdaterManager, ClientInfoManager, DeviceManager } from "@lib/api/native/modules";
import { BundleUpdaterManager, NativeClientInfoModule, NativeDeviceModule } from "@lib/api/native/modules";
import { after } from "@lib/api/patcher";
import { settings } from "@lib/api/settings";
import { logger } from "@lib/utils/logger";
@ -113,8 +113,8 @@ export function getDebugInfo() {
}
},
discord: {
version: ClientInfoManager.Version,
build: ClientInfoManager.Build,
version: NativeClientInfoModule.Version,
build: NativeClientInfoModule.Build,
},
react: {
version: React.version,
@ -149,15 +149,15 @@ export function getDebugInfo() {
manufacturer: PlatformConstants.Manufacturer,
brand: PlatformConstants.Brand,
model: PlatformConstants.Model,
codename: DeviceManager.device
codename: NativeDeviceModule.device
}
},
ios: {
device: {
manufacturer: DeviceManager.deviceManufacturer,
brand: DeviceManager.deviceBrand,
model: DeviceManager.deviceModel,
codename: DeviceManager.device
manufacturer: NativeDeviceModule.deviceManufacturer,
brand: NativeDeviceModule.deviceBrand,
model: NativeDeviceModule.deviceModel,
codename: NativeDeviceModule.device
}
}
}

View file

@ -1,12 +1,12 @@
import { FileManager } from "./modules";
import { NativeFileModule } from "./modules";
/**
* Removes all files in a directory from the path given
* @param path Path to the targeted directory
*/
export async function clearFolder(path: string, { prefix = "pyoncord/" } = {}) {
if (typeof FileManager.clearFolder !== "function") throw new Error("'fs.clearFolder' is not supported");
return void await FileManager.clearFolder("documents", `${prefix}${path}`);
if (typeof NativeFileModule.clearFolder !== "function") throw new Error("'fs.clearFolder' is not supported");
return void await NativeFileModule.clearFolder("documents", `${prefix}${path}`);
}
/**
@ -14,8 +14,8 @@ export async function clearFolder(path: string, { prefix = "pyoncord/" } = {}) {
* @param path Path to the file
*/
export async function removeFile(path: string, { prefix = "pyoncord/" } = {}) {
if (typeof FileManager.removeFile !== "function") throw new Error("'fs.removeFile' is not supported");
return void await FileManager.removeFile("documents", `${prefix}${path}`);
if (typeof NativeFileModule.removeFile !== "function") throw new Error("'fs.removeFile' is not supported");
return void await NativeFileModule.removeFile("documents", `${prefix}${path}`);
}
/**
@ -23,7 +23,7 @@ export async function removeFile(path: string, { prefix = "pyoncord/" } = {}) {
* @param path Path to the file
*/
export async function fileExists(path: string, { prefix = "pyoncord/" } = {}) {
return await FileManager.fileExists(`${FileManager.getConstants().DocumentsDirPath}/${prefix}${path}`);
return await NativeFileModule.fileExists(`${NativeFileModule.getConstants().DocumentsDirPath}/${prefix}${path}`);
}
/**
@ -33,7 +33,7 @@ export async function fileExists(path: string, { prefix = "pyoncord/" } = {}) {
*/
export async function writeFile(path: string, data: string, { prefix = "pyoncord/" } = {}): Promise<void> {
if (typeof data !== "string") throw new Error("Argument 'data' must be a string");
return void await FileManager.writeFile("documents", `${prefix}${path}`, data, "utf8");
return void await NativeFileModule.writeFile("documents", `${prefix}${path}`, data, "utf8");
}
/**
@ -43,7 +43,7 @@ export async function writeFile(path: string, data: string, { prefix = "pyoncord
*/
export async function readFile(path: string, { prefix = "pyoncord/" } = {}): Promise<string> {
try {
return await FileManager.readFile(`${FileManager.getConstants().DocumentsDirPath}/${prefix}${path}`, "utf8");
return await NativeFileModule.readFile(`${NativeFileModule.getConstants().DocumentsDirPath}/${prefix}${path}`, "utf8");
} catch (err) {
throw new Error(`An error occured while writing to '${path}'`, { cause: err });
}
@ -63,5 +63,5 @@ export async function downloadFile(url: string, path: string, { prefix = "pyonco
const arrayBuffer = await response.arrayBuffer();
const data = Buffer.from(arrayBuffer).toString("base64");
return void await FileManager.writeFile("documents", `${prefix}${path}`, data, "base64");
await NativeFileModule.writeFile("documents", `${prefix}${path}`, data, "base64");
}

View file

@ -1,4 +1,4 @@
import { Theme } from "@lib/addons/themes";
import { VdThemeInfo } from "@lib/addons/themes";
// @ts-ignore
const pyonLoaderIdentity = globalThis.__PYON_LOADER__;
@ -124,7 +124,7 @@ export function isThemeSupported() {
return false;
}
export function getStoredTheme(): Theme | null {
export function getStoredTheme(): VdThemeInfo | null {
if (isPyonLoader()) {
return pyonLoaderIdentity.storedTheme;
} else if (isVendettaLoader()) {

View file

@ -2,9 +2,10 @@ import { RNModules } from "./types";
const nmp = window.nativeModuleProxy;
export const MMKVManager = (nmp.NativeCacheModule ?? nmp.MMKVManager) as RNModules.MMKVManager;
export const FileManager = (nmp.NativeFileModule ?? nmp.RTNFileManager ?? nmp.DCDFileManager) as RNModules.FileManager;
export const ClientInfoManager = nmp.NativeClientInfoModule ?? nmp.RTNClientInfoManager ?? nmp.InfoDictionaryManager;
export const DeviceManager = nmp.NativeDeviceModule ?? nmp.RTNDeviceManager ?? nmp.DCDDeviceManager;
export const NativeCacheModule = (nmp.NativeCacheModule ?? nmp.MMKVManager) as RNModules.MMKVManager;
export const NativeFileModule = (nmp.NativeFileModule ?? nmp.RTNFileManager ?? nmp.DCDFileManager) as RNModules.FileManager;
export const NativeClientInfoModule = nmp.NativeClientInfoModule ?? nmp.RTNClientInfoManager ?? nmp.InfoDictionaryManager;
export const NativeDeviceModule = nmp.NativeDeviceModule ?? nmp.RTNDeviceManager ?? nmp.DCDDeviceManager;
export const NativeThemeModule = nmp.NativeThemeModule ?? nmp.RTNThemeManager ?? nmp.DCDTheme;
export const { BundleUpdaterManager } = nmp;
export const ThemeManager = nmp.NativeThemeModule ?? nmp.RTNThemeManager ?? nmp.DCDTheme;

View file

@ -169,10 +169,10 @@ export async function preloadStorageIfExists(path: string): Promise<boolean> {
const backend = createFileBackend(path);
if (await backend.exists()) {
_loadedStorage[path] = await backend.get();
return false;
return true;
}
return true;
return false;
}
export async function purgeStorage(path: string) {

4
src/lib/ui/alerts.ts Normal file
View file

@ -0,0 +1,4 @@
import { lazyDestructure } from "@lib/utils/lazy";
import { findByProps } from "@metro";
export const { openAlert, dismissAlert } = lazyDestructure(() => findByProps("openAlert", "dismissAlert"));

View file

@ -1,6 +1,5 @@
import { color } from "@lib/addons/themes";
import { constants } from "@metro/common";
import { findByStoreNameLazy } from "@metro/wrappers";
import { findByProps, findByStoreNameLazy } from "@metro/wrappers";
//! This module is only found on 165.0+, under the assumption that iOS 165.0 is the same as Android 165.0.
//* In 167.1, most if not all traces of the old color modules were removed.
@ -10,6 +9,8 @@ import { findByStoreNameLazy } from "@metro/wrappers";
// ? These comments are preserved for historical purposes.
// const colorModule = findByPropsProxy("colors", "meta");
const color = findByProps("SemanticColor");
// ? SemanticColor and default.colors are effectively ThemeColorMap
export const semanticColors = (color?.default?.colors ?? constants?.ThemeColorMap) as Record<string, any>;

View file

@ -1,4 +1,5 @@
export * as alerts from "./alerts";
export * as components from "./components";
export * as settings from "./settings";
export * as sheets from "./sheets";

View file

@ -1,7 +1,7 @@
import { lazyDestructure, proxyLazy } from "@lib/utils/lazy";
import { findByProps, findByPropsLazy } from "@metro/wrappers";
import { isSemanticColor, resolveSemanticColor } from "@ui/color";
import { DiscordTextStyles } from "@ui/types";
import { TextStyles } from "@ui/types";
import { ImageStyle, StyleSheet, TextStyle, ViewStyle } from "react-native";
type NamedStyles<T> = { [P in keyof T]: ViewStyle | TextStyle | ImageStyle };
@ -10,7 +10,7 @@ const Styles = findByPropsLazy("createStyles");
export const { ThemeContext } = lazyDestructure(() => findByProps("ThemeContext"), { hint: "object" });
export const { TextStyleSheet } = lazyDestructure(() => findByProps("TextStyleSheet")) as unknown as {
TextStyleSheet: Record<DiscordTextStyles, TextStyle>;
TextStyleSheet: Record<TextStyles, TextStyle>;
};
/**

File diff suppressed because one or more lines are too long

1
src/lib/utils/types.ts Normal file
View file

@ -0,0 +1 @@
export type Nullish = null | undefined;

View file

@ -12,7 +12,7 @@ const bySingularProp = createFilterDefinition<[string]>(
);
const findSingular = (prop: string) => proxyLazy(() => findExports(bySingularProp(prop))?.[prop]);
const findProp = (prop: string) => proxyLazy(() => findByProps(prop)[prop]);
const findProp = (...props: string[]) => proxyLazy(() => findByProps(...props)[props[0]]);
// Discord
export const LegacyAlert = findByDisplayNameLazy("FluxContainer(Alert)");
@ -27,18 +27,22 @@ export const ActionSheetRow = findProp("ActionSheetRow");
// Buttons
export const Button = findSingular("Button") as t.Button;
export const TwinButtons = findProp("TwinButtons");
export const TwinButtons = findProp("TwinButtons") as t.TwinButtons;
export const IconButton = findSingular("IconButton") as t.IconButton;
export const RowButton = findProp("RowButton") as t.RowButton;
export const PressableScale = findProp("PressableScale");
// Tables
export const TableRow = findProp("TableRow");
export const TableRowIcon = findProp("TableRowIcon");
export const TableRowTrailingText = findProp("TableRowTrailingText");
export const TableRowGroup = findProp("TableRowGroup");
export const TableSwitchRow = findProp("TableSwitchRow");
export const TableRow = findProp("TableRow") as t.TableRow;
export const TableRowIcon = findProp("TableRowIcon") as t.TableRowIcon;
export const TableRowTrailingText = findProp("TableRowTrailingText") as t.TableRowTrailingText;
export const TableRowGroup = findProp("TableRowGroup") as t.TableRowGroup;
export const TableRadioGroup = findProp("TableRadioGroup") as t.TableRadioGroup;
export const TableRadioRow = findProp("TableRadioRow") as t.TableRadioRow;
export const TableSwitchRow = findProp("TableSwitchRow") as t.TableSwitchRow;
export const TableCheckboxRow = findProp("TableCheckboxRow") as t.TableCheckboxRow;
export const TableSwitch = findSingular("FormSwitch");
export const TableRadio = findSingular("FormRadio");
export const TableCheckbox = findSingular("FormCheckbox");
@ -51,11 +55,23 @@ export const FormCheckbox = findSingular("FormCheckbox");
export const Card = findProp("Card");
export const RedesignCompat = proxyLazy(() => findByProps("RedesignCompat").RedesignCompat);
// Alert
export const AlertModal = findProp("AlertModal");
export const AlertActionButton = findProp("AlertActionButton");
export const AlertActions = findProp("AlertActions");
// Pile
export const AvatarPile = findSingular("AvatarPile");
// Misc.
export const ContextMenu = findProp("ContextMenu") as t.ContextMenu;
export const Stack = findProp("Stack") as t.Stack;
export const Avatar = findProp("default", "AvatarSizes", "getStatusSize");
// Inputs
export const TextInput = findSingular("TextInput") as t.TextInput;
export const TextArea = findSingular("TextArea");
// SegmentedControl
export const SegmentedControl = findProp("SegmentedControl") as t.SegmentedControl;

View file

@ -1,3 +1,4 @@
import { lazyDestructure } from "@lib/utils/lazy";
import { findByFilePathLazy, findByProps, findByPropsLazy } from "@metro/wrappers";
import type { Dispatcher } from "./types/flux";
@ -18,13 +19,16 @@ export const toasts = findByFilePathLazy("modules/toast/native/ToastActionCreato
export const messageUtil = findByPropsLazy("sendBotMessage");
export const navigationStack = findByPropsLazy("createStackNavigator");
export const NavigationNative = findByPropsLazy("NavigationContainer");
export const tokens = findByPropsLazy("colors", "unsafe_rawColors");
export const semver = findByPropsLazy("parse", "clean");
export const tokens = findByPropsLazy("unsafe_rawColors", "colors");
export const { useToken } = lazyDestructure(() => findByProps("useToken"));
// Flux
export const Flux = findByPropsLazy("connectStores");
// TODO: Making this a proxy/lazy fuck things up for some reason
export const FluxDispatcher = findByProps("_interceptors") as Dispatcher;
export const FluxUtils = findByProps("useStateFromStores");
// React
export const React = window.React = findByPropsLazy("createElement") as typeof import("react");

View file

@ -0,0 +1,3 @@
import { findByStoreNameLazy } from "@metro/wrappers";
export const UserStore = findByStoreNameLazy("UserStore");

View file

@ -1,5 +1,7 @@
import { DiscordTextStyles } from "@ui/types";
import { MutableRefObject, ReactNode, RefObject } from "react";
import { Nullish } from "@lib/utils/types";
import { TextStyles, ThemeColors } from "@ui/types";
import { Falsey } from "lodash";
import { FC, MutableRefObject, PropsWithoutRef, ReactNode, RefObject } from "react";
import type * as RN from "react-native";
import { ImageSourcePropType, PressableProps } from "react-native";
import { SharedValue } from "react-native-reanimated";
@ -29,6 +31,12 @@ interface ButtonProps {
export type Button = React.ForwardRefExoticComponent<ButtonProps>;
interface TwinButtonsProps {
children: ReactNode;
}
export type TwinButtons = React.ComponentType<TwinButtonsProps>;
// Segmented Control
interface SegmentedControlItem {
id: string;
@ -103,7 +111,7 @@ interface TextInputProps extends Omit<RN.TextInputProps, "onChange" | "onChangeT
trailingIcon?: React.FC<any>;
trailingPressableProps?: PressableProps;
trailingText?: string;
value?: string | RN.Falsy;
value?: string | Nullish;
}
export type TextInput = React.FC<TextInputProps>;
@ -134,6 +142,7 @@ interface FABProps {
style?: Style;
onPress: () => void;
positionBottom?: number;
}
export type FloatingActionButton = React.FC<FABProps>;
@ -145,8 +154,8 @@ interface ActionSheetProps {
export type ActionSheet = React.FC<React.PropsWithChildren<ActionSheetProps>>;
type TextProps = React.ComponentProps<typeof RN.Text> & {
variant?: DiscordTextStyles;
color?: string; // TODO: type this
variant?: TextStyles;
color?: ThemeColors;
lineClamp?: number;
maxFontSizeMultiplier?: number;
style?: RN.TextStyle;
@ -155,6 +164,7 @@ type TextProps = React.ComponentProps<typeof RN.Text> & {
export type Text = React.FC<TextProps>;
interface IconButtonProps {
label?: string;
icon: ImageSourcePropType | ReactNode;
onPress: () => void;
disabled?: boolean;
@ -164,3 +174,93 @@ interface IconButtonProps {
}
export type IconButton = React.FC<IconButtonProps>;
export type PressableScale = React.FC<PropsWithoutRef<typeof RN.Pressable>>;
interface TableRowBaseProps {
arrow?: boolean;
label: string | ReactNode;
subLabel?: string | ReactNode;
variant?: LiteralUnion<"danger", string>;
icon?: JSX.Element | Falsey;
disabled?: boolean;
trailing?: ReactNode | React.ComponentType<any>;
}
interface TableRowProps extends TableRowBaseProps {
onPress?: () => void;
}
export type TableRow = React.FC<TableRowProps> & {
Icon: TableRowIcon;
TrailingText: TableRowTrailingText;
Arrow: FC<{}>;
};
interface TableRowTrailingTextProps {
text: string;
}
export type TableRowTrailingText = FC<TableRowTrailingTextProps>;
interface TableRowIconProps {
style?: RN.ImageStyle;
variant?: LiteralUnion<"danger", string>,
source: ImageSourcePropType | undefined;
}
export type TableRowIcon = React.FC<TableRowIconProps>;
interface TableRowGroupProps {
title: string;
children: ReactNode;
}
export type TableRowGroup = React.FC<TableRowGroupProps>;
interface TableRadioGroupProps {
title: string;
value: string;
hasIcons?: boolean;
onChange: <T extends string>(type: T) => void;
children: ReactNode;
}
export type TableRadioGroup = FC<TableRadioGroupProps>;
interface TableRadioRowProps extends TableRowBaseProps {
value: string;
}
export type TableRadioRow = FC<TableRadioRowProps>;
interface TableSwitchRowProps extends TableRowBaseProps {
value: boolean;
onValueChange: (value: boolean) => void;
}
export type TableSwitchRow = FC<TableSwitchRowProps>;
interface TableCheckboxRowProps extends TableRowBaseProps {
checked: boolean;
onPress: () => void;
}
export type TableCheckboxRow = FC<TableCheckboxRowProps>;
interface ContextMenuItem {
label: string;
variant?: LiteralUnion<"destructive", string>;
iconSource?: number;
action: () => unknown;
}
interface ContextMenuProps {
triggerOnLongPress?: boolean;
items: ContextMenuItem[] | ContextMenuItem[][];
align?: "left" | "right" | "above" | "below" | "auto" | null;
title?: string;
children: React.FC<Record<"onPress" | "onLongPress" | "accessibilityActions" | "onAccessibilityAction", any>>;
}
export type ContextMenu = FC<ContextMenuProps>;

View file

@ -63,6 +63,7 @@ export function findModuleId<A extends unknown[]>(filter: FilterFn<A>) {
export function findExports<A extends unknown[]>(filter: FilterFn<A>) {
const { id, defaultExport } = findModule(filter);
if (id == null) return;
return defaultExport ? requireModule(id).default : requireModule(id);
}

View file

@ -1,10 +1,11 @@
import { ClientInfoManager, MMKVManager } from "@lib/api/native/modules";
import { fileExists, readFile, writeFile } from "@lib/api/native/fs";
import { NativeClientInfoModule } from "@lib/api/native/modules";
import { debounce } from "es-toolkit";
import { ModuleFlags, ModulesMapInternal } from "./enums";
const CACHE_VERSION = 52;
const BUNNY_METRO_CACHE_KEY = "__bunny_metro_cache_key__";
const CACHE_VERSION = 100;
const BUNNY_METRO_CACHE_PATH = "caches/metro_modules.json";
type ModulesMap = {
[flag in number | `_${ModulesMapInternal}`]?: ModuleFlags;
@ -17,16 +18,15 @@ export const getMetroCache = window.__getMetroCache = () => _metroCache;
function buildInitCache() {
const cache = {
_v: CACHE_VERSION,
_buildNumber: ClientInfoManager.Build as number,
_buildNumber: NativeClientInfoModule.Build as number,
_modulesCount: Object.keys(window.modules).length,
exportsIndex: {} as Record<string, number>,
flagsIndex: {} as Record<string, number>,
findIndex: {} as Record<string, ModulesMap | undefined>,
polyfillIndex: {} as Record<string, ModulesMap | undefined>,
assetsIndex: {} as Record<string, ModulesMap | undefined>
polyfillIndex: {} as Record<string, ModulesMap | undefined>
} as const;
// Force load all modules so useful modules are pre-cached. Delay by a second
// because force loading it all will results in an unexpected crash.
// Force load all modules so useful modules are pre-cached. Add a minor
// delay so the cache is initialized before the modules are loaded.
setTimeout(() => {
for (const id in window.modules) {
require("./modules").requireModule(id);
@ -37,11 +37,10 @@ function buildInitCache() {
return cache;
}
// TODO: Store in file system... is a better idea?
/** @internal */
export async function initMetroCache() {
const rawCache = await MMKVManager.getItem(BUNNY_METRO_CACHE_KEY);
if (rawCache == null) return void buildInitCache();
if (!await fileExists(BUNNY_METRO_CACHE_PATH)) return void buildInitCache();
const rawCache = await readFile(BUNNY_METRO_CACHE_PATH);
try {
_metroCache = JSON.parse(rawCache);
@ -49,7 +48,7 @@ export async function initMetroCache() {
_metroCache = null!;
throw "cache invalidated; cache version outdated";
}
if (_metroCache._buildNumber !== ClientInfoManager.Build) {
if (_metroCache._buildNumber !== NativeClientInfoModule.Build) {
_metroCache = null!;
throw "cache invalidated; version mismatch";
}
@ -63,7 +62,7 @@ export async function initMetroCache() {
}
const saveCache = debounce(() => {
MMKVManager.setItem(BUNNY_METRO_CACHE_KEY, JSON.stringify(_metroCache));
writeFile(BUNNY_METRO_CACHE_PATH, JSON.stringify(_metroCache));
}, 1000);
function extractExportsFlags(moduleExports: any) {
@ -77,13 +76,18 @@ function extractExportsFlags(moduleExports: any) {
export function indexExportsFlags(moduleId: number, moduleExports: any) {
const flags = extractExportsFlags(moduleExports);
if (flags && flags !== ModuleFlags.EXISTS) {
_metroCache.exportsIndex[moduleId] = flags;
_metroCache.flagsIndex[moduleId] = flags;
}
}
/** @internal */
export function indexBlacklistFlag(id: number) {
_metroCache.exportsIndex[id] |= ModuleFlags.BLACKLISTED;
_metroCache.flagsIndex[id] |= ModuleFlags.BLACKLISTED;
}
/** @internal */
export function indexAssetModuleFlag(id: number) {
_metroCache.flagsIndex[id] |= ModuleFlags.ASSET;
}
/** @internal */
@ -124,11 +128,3 @@ export function getPolyfillModuleCacher(name: string) {
}
};
}
/** @internal */
export function indexAssetName(name: string, moduleId: number) {
if (!isNaN(moduleId)) {
(_metroCache.assetsIndex[name] ??= {})[moduleId] = 1;
saveCache();
}
}

View file

@ -1,6 +1,7 @@
export enum ModuleFlags {
EXISTS = 1 << 0,
BLACKLISTED = 1 << 1
BLACKLISTED = 1 << 1,
ASSET = 1 << 2,
}
export enum ModulesMapInternal {

View file

@ -23,7 +23,7 @@ for (const key in metroModules) {
const id = Number(key);
const metroModule = metroModules[id];
const cache = getMetroCache().exportsIndex[id];
const cache = getMetroCache().flagsIndex[id];
if (cache & ModuleFlags.BLACKLISTED) {
blacklistModule(id);
continue;
@ -86,20 +86,23 @@ function onModuleRequire(moduleExports: any, id: Metro.ModuleID) {
moduleExports.default.track = () => Promise.resolve();
if (moduleExports.registerAsset) {
require("@lib/api/assets").patchAssets(moduleExports);
require("@lib/api/assets/patches").patchAssets(moduleExports);
}
// There are modules registering the same native component
if (!patchedNativeComponentRegistry && ["customBubblingEventTypes", "customDirectEventTypes", "register", "get"].every(x => moduleExports[x])) {
instead("register", moduleExports, (args: any, origFunc: any) => {
instead("register", moduleExports, ([name, cb]: any, origFunc: any) => {
try {
return origFunc(...args);
} catch { }
return origFunc(name, cb);
} catch {
return name;
}
});
patchedNativeComponentRegistry = true;
}
// Hook DeveloperExperimentStore
if (moduleExports?.default?.constructor?.displayName === "DeveloperExperimentStore") {
moduleExports.default = new Proxy(moduleExports.default, {
@ -130,7 +133,7 @@ function onModuleRequire(moduleExports: any, id: Metro.ModuleID) {
patchedInspectSource = true;
}
// Explosion (no, I can't explain this, don't ask) ((hi rosie))
//
if (moduleExports.findHostInstance_DEPRECATED) {
const prevExports = metroModules[id - 1]?.publicModule.exports;
const inc = prevExports.default?.reactProfilingEnabled ? 1 : -1;

View file

@ -1,4 +1,4 @@
type Nullish = null | undefined;
import { Nullish } from "@lib/utils/types";
/** @see {@link https://github.com/facebook/metro/blob/c2d7539dfc10aacb2f99fcc2f268a3b53e867a90/packages/metro-runtime/src/polyfills/require.js} */
export namespace Metro {

View file

@ -1,5 +1,5 @@
import { byDisplayName, byFilePath,byName, byProps, byStoreName, byTypeName } from "./filters";
import { findAllExports,findExports } from "./finders";
import { byDisplayName, byFilePath, byName, byProps, byStoreName, byTypeName } from "./filters";
import { findAllExports, findExports } from "./finders";
import { createLazyModule } from "./lazy";
export const findByProps = (...props: string[]) => findExports(byProps(...props));