chore: merge upstream/main to dev

This commit is contained in:
PalmDevs 2024-12-27 02:01:12 +07:00
parent 89f1b18ebb
commit 395e1e9ef9
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
71 changed files with 1839 additions and 819 deletions

191
.github/ISSUE_TEMPLATE/bug-report.yml vendored Normal file
View file

@ -0,0 +1,191 @@
name: Bug report
description: Create a bug report to help us improve
title: "[bug]: <insert title here>"
labels: [bug]
body:
- type: markdown
attributes:
value: |
> [!IMPORTANT]
> ## Before you submit this issue...
> We might reject any issues that can't be solved within the repository, or reports that are too general. For example:
>
> <details>
> <summary><b>"Bunny crashes upon starting..."</b></summary>
> > There are lots of different things that could be causing this, but it's probably not related to this repository.
> </details>
> <details>
> <summary><b>"Bunny freezes on splash screen..."</b></summary>
> > The issue is too general and should be reported only when it occurs after an update to a specific Discord version or after a specific action or configuration has been made within Bunny.
> </details>
> <details>
> <summary><b>"Bunny refuses to install/stuck on downloading..."</b></summary>
> > This is an external issue (sideloading) that does not have a place in this repository. If it's a problem with Bunny Manager, report it to the <a href="https://github.com/pyoncord/BunnyManager">appropriate repository</a>.
> </details>
>
> If you think that your problem belongs to one of these, you should avoid submitting this report unless you are told to do so.
>
> Please report the problem or ask for help in the appropriate place. For example, if the problem occurs in BunnyManager, report it in the BunnyManager repository. If an error occurs when trying to sideload the prepatched IPA, reach out support for the sideloader you're using.
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: You should ensure the completion of the task before proceeding to check it off the checklist. Neglecting to do so may impede the efficiency of the issue resolution process. The developer has the right to delete the issue directly if you check the list blindly.
options:
- label: I am able to reproduce the bug with the latest version of Bunny and Discord.
required: true
- label: I've checked that there is no open or closed issue about this bug.
required: true
- label: The title of this issue accurately describes the bug.
required: true
- label: This is not a feature request or a general question.
required: true
- label: This bug can be reproduced consistently and is not caused by a plugin.
required: true
- label: I have read the notes above and ensured that my issue is clear and within this repository.
required: true
- type: textarea
id: reproduce-steps
attributes:
label: Steps to reproduce the bug
description: What did you do for the bug to show up?
placeholder: |
Example:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
placeholder: |
Example:
"This should happen..."
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: Actual behavior
placeholder: |
Example:
"This happened instead..."
validations:
required: true
- type: textarea
id: sreen-media
attributes:
label: Screenshots/Screen recordings
description: |
A picture or video helps us understand the bug more.
You can upload them directly in the text box.
- type: textarea
id: logs
attributes:
label: Logs
description: |
Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: shell
- type: dropdown
id: platform
attributes:
label: What platform did you produce this issue on?
options:
- Android
- iOS
validations:
required: true
- type: input
id: discord-version
attributes:
label: Discord version
description: |
The version of Discord you are using. You can find this in the settings. You may leave both the version number and the build number.
placeholder: |
Example:
"253.2 (66074)"
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: |
The version of Bunny the issue is occuring on. You can find this in the settings. Bunny version is in formatted like the following: `beaf689-main` and NOT like this: `0.2.3`.
placeholder: |
Example:
"beaf689-main"
- type: input
id: loader-version
attributes:
label: Loader version
description: |
The version of the loader you are using. You can find this in the settings.
placeholder: |
Example:
"BunnyTweak (0.3.2)"
validations:
required: true
- type: input
id: device
attributes:
label: Device
description: |
The device model you are using.
placeholder: |
Example:
"iPhone 13 Pro Max"
validations:
required: true
- type: input
id: os-version
attributes:
label: OS version
description: |
The version of the operating system you are using.
placeholder: |
Example:
"iOS 15.0.1"
validations:
required: true
- type: textarea
id: debug-info
attributes:
label: Debug information
description: |
Debug information is important for the developer to understand the environment in which the bug occurred. You can get this information by sending `/debug` in the chat.
placeholder: |
Example:
Bunny: beaf689-main (BunnyXposed 0.2.4)
Discord: 255.2 (255202)
React: 18.3.1 (RN 0.74.5-discord-11)
Hermes: for RN 0.74.5-discord-11 (bcv96)
System: Android 13 (SDK 33)
Device: M2101K7BG (secret)
- type: textarea
id: additional-information
attributes:
label: Additional information
placeholder: |
Example:
"I have tried to do this..."
"This only happens in this situation..."
"This could be related to..."

View file

@ -1,5 +1,9 @@
<<<<<<< HEAD
blank_issues_enabled: false
contact_links:
- name: 🗨 Discussions
url: https://discord.gg/ddcQf3s2Uq
about: Have something unspecific to Revenge in mind? Join us on Discord!
=======
blank_issues_enabled: false
>>>>>>> upstream/main

View file

@ -0,0 +1,43 @@
name: Feature request
description: Suggest an idea for Bunny
title: "[feature]: <insert title here>"
labels: [enhancement]
body:
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: You should ensure the completion of the task before proceeding to check it off the checklist. Neglecting to do so may impede the efficiency of the issue resolution process. The developer has the right to delete the issue directly if you check the list blindly.
options:
- label: This is not a plugin request.
required: true
- label: I've checked that there is no other issue about this feature request.
required: true
- label: This issue contains only one feature request.
required: true
- label: The title of this issue accurately describes the feature request.
required: true
- type: textarea
id: feature-description
attributes:
label: Feature description
description: What feature you want Bunny to have? Provide detailed description about what it should look like or where it should be added.
validations:
required: true
- type: textarea
id: why-is-the-feature-requested
attributes:
label: Why do you want this feature?
description: Describe the problem or limitation that motivates you to want this feature to be added.
validations:
required: true
- type: textarea
id: additional-information
attributes:
label: Additional information
description: Add any other context or screenshots about the feature request here.
placeholder: |
Additional details and attachments.

53
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,53 @@
name: Build
on:
push:
branches: [main, dev]
paths-ignore:
- "**/*.md"
jobs:
build:
name: Build and push
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Checkout builds
uses: actions/checkout@v4
with:
repository: "bunny-mod/builds"
path: "builds"
token: ${{ secrets.BUILDS_TOKEN }}
ref: ${{ github.ref_name }}
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm and dependencies
run: |
npm i -g pnpm
pnpm i\
- name: Build
run: pnpm build --release-branch=${{ github.ref_name }} --build-minify
# TODO: Check for linting?
- name: Push builds
run: |
rm $GITHUB_WORKSPACE/builds/* || true
cp -r dist/* $GITHUB_WORKSPACE/builds || true
cd $GITHUB_WORKSPACE/builds
git config --local user.email "actions@github.com"
git config --local user.name "GitHub Actions"
git add .
git commit -m "Build $GITHUB_SHA" || exit 0
git push
- name: Purge CDN cache
run: |
curl https://purge.jsdelivr.net/gh/pyoncord/bunny-build-infos@${{ github.ref_name }}/bunny.js

View file

@ -26,6 +26,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, proxyLazy } from "@lib/utils/lazy";
import { findByProps } from "@metro";
import { clipboard } from "@metro/common";
import { Button, FlashList, FloatingActionButton, HelpMessage, IconButton, Stack, Text, TextInput } 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,13 +34,14 @@ 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>;
}
const useSafeAreaInsets = proxyLazy(() => findByProps("useSafeAreaInsets").useSafeAreaInsets);
function InputAlert(props: { label: string, fetchFn: (url: string) => Promise<void>; }) {
const [value, setValue] = React.useState("");
const [error, setError] = React.useState("");
@ -57,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
@ -109,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 insets = useSafeAreaInsets();
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 });
@ -137,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>
@ -178,7 +191,7 @@ export default function AddonPage<T extends object>({ CardComponent, ...props }:
})}
/>}
</View>
{props.ListHeaderComponent && !search && <props.ListHeaderComponent />}
{props.ListHeaderComponent && <props.ListHeaderComponent />}
</View>
);
@ -190,25 +203,21 @@ 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} />}
/>
{props.installAction && (
<View style={{ paddingBottom: insets.bottom }}>
<FloatingActionButton
positionBottom={insets.bottom + 16}
icon={findAssetId("PlusLargeIcon")}
onPress={onInstallPress}
/>
</View>
)}
{props.installAction && <FloatingActionButton
positionBottom={bottomInset + 8}
icon={findAssetId("PlusLargeIcon")}
onPress={onInstallPress}
/>}
</ErrorBoundary>
);
}

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

@ -5,7 +5,7 @@ import { settings } from "@lib/api/settings";
import { Codeblock, ErrorBoundary } from "@lib/ui/components";
import { createStyles } from "@lib/ui/styles";
import { tokens } from "@metro/common";
import { Button, Card, SafeAreaView, Text } from "@metro/common/components";
import { Button, Card, SafeAreaProvider, SafeAreaView, Text } from "@metro/common/components";
import { ScrollView, View } from "react-native";
import ErrorComponentStackCard from "./ErrorComponentStackCard";
@ -29,22 +29,24 @@ export default function ErrorBoundaryScreen(props: {
const debugInfo = getDebugInfo();
return <ErrorBoundary>
<SafeAreaView style={styles.container}>
<View style={{ gap: 4 }}>
<Text variant="display-lg">Uh oh.</Text>
<Text variant="text-md/normal">A crash occurred while rendering a component. This could be caused by a plugin, Revenge, or Discord itself.</Text>
<Text variant="text-sm/normal" color="text-muted">{debugInfo.os.name}; {debugInfo.discord.build} ({debugInfo.discord.version}); {debugInfo.bunny.version}</Text>
</View>
<ScrollView fadingEdgeLength={64} contentContainerStyle={{ gap: 12 }}>
<Codeblock selectable={true}>{props.error.message}</Codeblock>
{hasStack(props.error) && <ErrorStackCard error={props.error} />}
{isComponentStack(props.error) ? <ErrorComponentStackCard componentStack={props.error.componentStack} /> : null}
</ScrollView>
<Card style={{ gap: 6 }}>
<Button text="Reload Discord" onPress={() => BundleUpdaterManager.reload()} />
{!settings.safeMode?.enabled && <Button text="Reload in Safe Mode" onPress={() => toggleSafeMode()} />}
<Button variant="destructive" text="Retry Render" onPress={() => props.rerender()} />
</Card>
</SafeAreaView>
<SafeAreaProvider>
<SafeAreaView style={styles.container}>
<View style={{ gap: 4 }}>
<Text variant="display-lg">Uh oh.</Text>
<Text variant="text-md/normal">A crash occurred while rendering a component. This could be caused by a plugin, Revenge, or Discord itself.</Text>
<Text variant="text-sm/normal" color="text-muted">{debugInfo.os.name}; {debugInfo.discord.build} ({debugInfo.discord.version}); {debugInfo.bunny.version}</Text>
</View>
<ScrollView fadingEdgeLength={64} contentContainerStyle={{ gap: 12 }}>
<Codeblock selectable={true}>{props.error.message}</Codeblock>
{hasStack(props.error) && <ErrorStackCard error={props.error} />}
{isComponentStack(props.error) ? <ErrorComponentStackCard componentStack={props.error.componentStack} /> : null}
</ScrollView>
<Card style={{ gap: 6 }}>
<Button text="Reload Discord" onPress={() => BundleUpdaterManager.reload()} />
{!settings.safeMode?.enabled && <Button text="Reload in Safe Mode" onPress={() => toggleSafeMode()} />}
<Button variant="destructive" text="Retry Render" onPress={() => props.rerender()} />
</Card>
</SafeAreaView>
</SafeAreaProvider>
</ErrorBoundary>;
}

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 { Text } 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>
@ -17,9 +19,10 @@ export default function AssetBrowser() {
<View style={{ flex: 1, borderRadius: 16, paddingHorizontal: 12, overflow: 'hidden', backgroundColor: 'transparent' }}>
<Text variant='text-sm/medium' color='text-danger' style={{ marginBottom: 16 }}>Some assets types cannot be displayed and will be marked in red.</Text>
<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 }: any) => <AssetDisplay asset={item} />}
contentContainerStyle={{ overflow: 'hidden', backgroundColor: 'transparent', borderRadius: 16 }}
keyExtractor={a => a.name}
/>
</View>
</View>

View file

@ -27,10 +27,10 @@ export default function AssetDisplay({ asset }: AssetDisplayProps) {
<TableRow
variant={displayable.has(asset.type) ? 'default' : 'danger'}
label={asset.name}
subLabel={`Index: ${asset.index} Type: ${asset.type}`}
subLabel={`Index: ${asset.id} Type: ${asset.type}`}
icon={
displayable.has(asset.type)
? <Image source={asset.index} style={{ width: 32, height: 32 }} />
? <Image source={asset.id} style={{ width: 32, height: 32 }} />
: <TableRow.Icon
variant='danger'
source={findAssetId(asset.type in iconMap ? iconMap[asset.type as keyof typeof iconMap] : iconMap.default)}
@ -39,10 +39,10 @@ export default function AssetDisplay({ asset }: AssetDisplayProps) {
onPress={() =>
openAlert("revenge-asset-display-details", <AlertModal
title={asset.name}
content={`Index: ${asset.index}\nModule ID: ${asset.moduleId}\nType: ${asset.type}`}
content={`Index: ${asset.id}\nModule ID: ${asset.moduleId}\nType: ${asset.type}`}
extraContent={
displayable.has(asset.type)
? <Image resizeMode="contain" source={asset.index} style={{ flex: 1, width: 'auto', height: 192 }} />
? <Image resizeMode="contain" source={asset.id} style={{ flex: 1, width: 'auto', height: 192 }} />
: (<Text variant='text-sm/medium' color="text-danger" style={{ width: '100%', textAlign: 'center' }}>
Asset type {asset.type.toUpperCase()} is not supported for preview.
</Text>)
@ -50,7 +50,7 @@ export default function AssetDisplay({ asset }: AssetDisplayProps) {
actions={
<Stack>
<AlertActionButton text="Copy asset name" variant="primary" onPress={() => copyToClipboard(asset.name)} />
<AlertActionButton text="Copy asset index" variant="secondary" onPress={() => copyToClipboard(asset.index.toString())} />
<AlertActionButton text="Copy asset index" variant="secondary" onPress={() => copyToClipboard(asset.id.toString())} />
</Stack>
}
/>)

View file

@ -172,7 +172,7 @@ export default function Developer() {
label={Strings.ENABLE_EVAL_COMMAND}
subLabel={Strings.ENABLE_EVAL_COMMAND_DESC}
icon={<TableRow.Icon source={findAssetId("PencilIcon")} />}
value={settings.enableEvalCommand}
value={!!settings.enableEvalCommand}
onValueChange={(v: boolean) => {
settings.enableEvalCommand = v;
}}

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_copy_id"
icon: "TagIcon"
}
];

View file

@ -2,19 +2,20 @@ import { findAssetId } from "@lib/api/assets";
import { clipboard } from "@metro/common";
import { TableRow, TableRowTrailingText } 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)} />}
trailing={<TableRowTrailingText text={version} />}
icon={<TableRow.Icon source={typeof icon === "string" ? findAssetId(icon) : icon} />}
onPress={() => {
clipboard.setString(`${label} - ${version}`);
showToast.showCopyToClipboard();

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,34 +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()}
/>
<TableSwitchRow
label={Strings.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")} />}
value={settings.safeMode?.enabled}
onValueChange={toggleSafeMode}
icon={<TableRow.Icon source={findAssetId("ShieldIcon")!} />}
value={!!settings.safeMode?.enabled}
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;
@ -81,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

@ -1,4 +1,4 @@
import { UnifiedPluginModel } from "..";
import { UnifiedPluginModel } from "../models";
export interface PluginInfoActionSheetProps {
plugin: UnifiedPluginModel;

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

@ -7,42 +7,25 @@ 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([
wrapSafeAreaProvider(),
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 { createStorage } from "@core/vendetta/storage";
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 {
@ -47,7 +47,7 @@ export interface PluginInstanceInternal extends PluginInstance {
export interface BunnyPluginProperty {
readonly manifest: BunnyPluginManifestInternal;
readonly logger: Logger;
createStorage<T>(): ReturnType<typeof createStorage<T>>;
createStorage<T extends object>(): ReturnType<typeof createStorage<T>>;
}
export type BunnyPluginObject = typeof window.bunny & {

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,95 +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;
type: 'jsona' | 'jpg' | 'png' | 'svg' | 'lottie' | 'ttf' | 'webm'
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,79 @@
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;
type: string;
}
// 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,8 +23,8 @@ export async function removeFile(path: string, { prefix = "pyoncord/" } = {}) {
* @param path Path to the file
*/
export async function removeCacheFile(path: string, prefix = "pyoncord/") {
if (typeof FileManager.removeFile !== "function") throw new Error("'fs.removeFile' is not supported");
return void await FileManager.removeFile("cache", `${prefix}${path}`);
if (typeof NativeFileModule.removeFile !== "function") throw new Error("'fs.removeFile' is not supported");
return void await NativeFileModule.removeFile("cache", `${prefix}${path}`);
}
/**
@ -32,7 +32,7 @@ export async function removeCacheFile(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}`);
}
/**
@ -42,7 +42,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");
}
/**
@ -52,7 +52,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 });
}
@ -72,5 +72,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";
import { removeCacheFile } from "./fs";
// @ts-ignore
@ -125,7 +125,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,10 +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 { BundleUpdaterManager } = nmp;
export const ThemeManager = nmp.NativeThemeModule ?? nmp.RTNThemeManager ?? nmp.DCDTheme;
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;

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

@ -12,7 +12,7 @@ export interface SearchProps {
}
function SearchIcon() {
return <Image style={{ width: 16, height: 16 }} source={findAssetId("search")} />;
return <Image style={{ width: 16, height: 16 }} source={findAssetId("search")!} />;
}
export default ({ onChangeText, placeholder, style, isRound }: SearchProps) => {

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,6 +1,7 @@
import { after } from "@lib/api/patcher";
import { findInReactTree } from "@lib/utils";
import { i18n } from "@metro/common";
import { TableRow } from "@metro/common/components";
import { findByNameLazy, findByPropsLazy } from "@metro/wrappers";
import { registeredSections } from "@ui/settings";
@ -24,7 +25,7 @@ export function patchTabsUI(unpatches: (() => void | boolean)[]) {
type: "pressable",
title: row.title,
icon: row.icon,
IconComponent: row.IconComponent ?? (row.icon && (() => <TableRowIcon source={row.icon} />)),
IconComponent: () => <TableRow.Icon source={row.icon} />,
usePredicate: row.usePredicate,
useTrailing: row.useTrailing,
onPress: wrapOnPress(row.onPress, null, row.render, row.title()),
@ -76,6 +77,7 @@ export function patchTabsUI(unpatches: (() => void | boolean)[]) {
if (useIsFirstRender()) return; // :shrug:
const { sections } = findInReactTree(ret, i => i.props?.sections).props;
// TODO: Use new i18n lib
let index = -~sections.findIndex((i: any) => i.label === i18n.Messages.ACCOUNT_SETTINGS) || 1;
Object.keys(registeredSections).forEach(sect => {

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)");
@ -20,25 +20,29 @@ export const CompatButton = findByPropsLazy("Looks", "Colors", "Sizes");
export const HelpMessage = findByNameLazy("HelpMessage");
// React Native's included SafeAreaView only adds padding on iOS.
export const SafeAreaView = proxyLazy(() => findByProps("useSafeAreaInsets").SafeAreaView);
export const { SafeAreaView, SafeAreaProvider, useSafeAreaInsets } = lazyDestructure(() => findByProps("useSafeAreaInsets"));
// ActionSheet
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>;
@ -133,7 +141,7 @@ interface FABProps {
icon: ImageSourcePropType | ReactNode;
style?: Style;
onPress: () => void;
positionBottom: number;
positionBottom?: number;
}
export type FloatingActionButton = React.FC<FABProps>;
@ -145,8 +153,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 +163,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 +173,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));