Merge branch 'main' into github

This commit is contained in:
cocobo1 2025-10-11 17:21:05 +03:00
commit d15443b50a
37 changed files with 1587 additions and 826 deletions

View file

@ -1,46 +0,0 @@
name: Feature request
description: Suggest an idea for Kettu
title: "[feature]: <insert title here>"
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
# Revenge feature request
Before creating a new feature request, please keep the following in mind:
- **Do not submit a duplicate feature request**: Search for existing feature requests [here](https://github.com/revenge-mod/revenge-bundle/issues?q=label%3A%22Feature+request%22).
- **Review the contribution guidelines**: Make sure your feature request adheres to it. You can find the guidelines [here](https://github.com/revenge-mod/revenge-bundle/blob/main/CONTRIBUTING.md).
- **Do not use the issue page for support**: If you need help or have questions, join us on [Discord](https://discord.gg/ddcQf3s2Uq).
- type: textarea
attributes:
label: Feature description
description: What feature you want Kettu to have? Provide detailed description about what it should look like or where it should be added.
validations:
required: true
- type: textarea
attributes:
label: Motivation
description: |
A strong motivation is necessary for a feature request to be considered.
- Why should this feature be implemented?
- What is the explicit use case?
- What are the benefits?
- What makes this feature important?
validations:
required: true
- type: checkboxes
id: acknowledgements
attributes:
label: Acknowledgements
description: Your feature request will be closed if you don't follow the checklist below.
options:
- label: I have checked all open and closed feature requests and this is not a duplicate
required: true
- label: I have chosen an appropriate title.
required: true
- label: All requested information has been provided properly.
required: true

View file

@ -1,4 +0,0 @@
<svg width="320" height="320" viewBox="0 0 320 320" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.26979 99H294.723L312.234 215.273C312.723 222.597 310.021 229.993 304.839 235.184L265.514 274.935C260.741 279.717 254.27 283.273 247.514 283.273H182.785C168.723 283.273 152.981 270.126 152.981 256.073V255.61C152.981 250.056 156.895 244.851 160.088 240.605L160.088 240.605L160.288 240.339L75.9944 236.224C73.9944 236.135 69.0077 236.055 62.9899 235.984C43.5233 235.762 27.1144 221.37 24.4033 202.09L8.74994 103.006C8.84706 101.648 9.02178 100.311 9.26979 99ZM84.5458 137.592L139.983 152.446C139.994 152.788 140 153.132 140 153.477C140 170.045 126.568 183.477 110 183.477C93.4314 183.477 79.9999 170.045 79.9999 153.477C79.9999 147.643 81.665 142.198 84.5458 137.592ZM180.019 152.4L235.429 137.553C238.325 142.168 240 147.627 240 153.477C240 170.045 226.568 183.477 210 183.477C193.431 183.477 180 170.045 180 153.477C180 153.116 180.006 152.757 180.019 152.4Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M288.328 82.0258C288.569 83.4188 289.198 84.7073 290.018 85.8593C292.71 89.6425 294.372 94.0776 294.688 98.766L295.899 106.809C296.628 111.647 292.881 116 287.988 116H17.6382C13.7018 116 10.3504 113.136 9.73617 109.248L8.89299 103.911C8.79798 103.31 8.76815 102.697 8.82742 102.091C10.4271 85.7347 23.3039 72.6711 39.7278 70.9082L87.1856 63.328C89.9772 62.8821 92.7967 63.9426 94.6025 66.1175L105.915 79.7431C107.719 81.9152 110.533 82.9759 113.322 82.5343L203.656 68.2267C205.851 67.8792 207.802 66.6347 209.042 64.7913L222.878 44.2296C224.116 42.3892 226.063 41.1457 228.253 40.7958L247.639 37.6994C265.683 35.7705 282.181 48.0371 285.523 65.8683L288.328 82.0258Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -1,4 +0,0 @@
<svg width="320" height="320" viewBox="0 0 320 320" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.26979 99H294.723L312.234 215.273C312.723 222.597 310.021 229.993 304.839 235.184L265.514 274.935C260.741 279.717 254.27 283.273 247.514 283.273H182.785C168.723 283.273 152.981 270.126 152.981 256.073V255.61C152.981 250.056 156.895 244.851 160.088 240.605L160.088 240.605L160.288 240.339L75.9944 236.224C73.9944 236.135 69.0077 236.055 62.9899 235.984C43.5233 235.762 27.1144 221.37 24.4033 202.09L8.74994 103.006C8.84706 101.648 9.02178 100.311 9.26979 99ZM84.5458 137.592L139.983 152.446C139.994 152.788 140 153.132 140 153.477C140 170.045 126.568 183.477 110 183.477C93.4314 183.477 79.9999 170.045 79.9999 153.477C79.9999 147.643 81.665 142.198 84.5458 137.592ZM180.019 152.4L235.429 137.553C238.325 142.168 240 147.627 240 153.477C240 170.045 226.568 183.477 210 183.477C193.431 183.477 180 170.045 180 153.477C180 153.116 180.006 152.757 180.019 152.4Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M288.328 82.0258C288.569 83.4188 289.198 84.7073 290.018 85.8593C292.71 89.6425 294.372 94.0776 294.688 98.766L295.899 106.809C296.628 111.647 292.881 116 287.988 116H17.6382C13.7018 116 10.3504 113.136 9.73617 109.248L8.89299 103.911C8.79798 103.31 8.76815 102.697 8.82742 102.091C10.4271 85.7347 23.3039 72.6711 39.7278 70.9082L87.1856 63.328C89.9772 62.8821 92.7967 63.9426 94.6025 66.1175L105.915 79.7431C107.719 81.9152 110.533 82.9759 113.322 82.5343L203.656 68.2267C205.851 67.8792 207.802 66.6347 209.042 64.7913L222.878 44.2296C224.116 42.3892 226.063 41.1457 228.253 40.7958L247.639 37.6994C265.683 35.7705 282.181 48.0371 285.523 65.8683L288.328 82.0258Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 19 KiB

View file

@ -1,29 +0,0 @@
## Building
1. Install a Revenge loader with config support (any mentioned in the [Installing](#installing) section).
2. Go to **Settings** > **General** and enable **Developer Settings**.
3. Clone the repository
```sh
git clone https://github.com/revenge-mod/revenge-bundle.git
```
4. Install dependencies
```
bun i
```
5. Build Revenge's code
```
bun run build
```
6. In the newly created `dist` directory, run a HTTP server. I recommend [http-server](https://www.npmjs.com/package/http-server).
7. Go to **Settings** > **Developer** enabled earlier. Enable `Load from custom URL` and input the IP address and port of the server (e.g. `http://192.168.1.236:4040/revenge.js`) in the new input box labeled `Revenge URL`.
8. Restart Discord. Upon reload, you should notice that your device will download Revenge's bundled code from your server, rather than GitHub.
9. Make your changes, rebuild, reload, go wild!
Alternatively, you can directly *serve* the bundled code by running `bun serve`. `revenge.js` will be served on your local address under the port 4040. You will then insert `http://<local ip address>:4040/revenge.js` as a custom URL and reload. Whenever you restart your mobile client, the script will rebuild the bundle as your client fetches it.
If the bundle keeps being cached and not updated, you can instead tap the **Settings** > **Developer** > **Clear JS bundle** option which will prompt you to reload.

View file

@ -1,13 +0,0 @@
# 👊 Revenge
This documentation explains how to use [Revenge](https://github.com/revenge-mod/revenge-bundle).
## 📖 Table of contents
TODO.
## ⏭️ Start here
The next page will tell you about the prerequisites for using Revenge.
Continue: [💼 Prerequisites](0_prerequisites.md)

View file

@ -74,7 +74,7 @@ const config = {
constModules: {
globals: {
"bunny-build-info": {
version: `"1.2.0"`
version: `"1.3.0"`
}
}
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -28,7 +28,8 @@ export default () => <ApplicationCommand>{
if (ephemeral?.value) {
messageUtil.sendBotMessage(ctx.channel.id, content);
} else {
messageUtil.sendMessage(ctx.channel.id, { content });
const fixNonce = Date.now().toString();
messageUtil.sendMessage(ctx.channel.id, { content }, void 0, {nonce:fixNonce});
}
}
};

View file

@ -36,7 +36,8 @@ export default () => <ApplicationCommand>{
if (ephemeral?.value) {
messageUtil.sendBotMessage(ctx.channel.id, content);
} else {
messageUtil.sendMessage(ctx.channel.id, { content });
const fixNonce = Date.now().toString();
messageUtil.sendMessage(ctx.channel.id, { content }, void 0, {nonce:fixNonce});
}
}
};

View file

@ -8,6 +8,7 @@
"BRAND": "Brand",
"PUPU": "Kettu",
"PUPU_URL": "Kettu URL",
"BROWSER": "Browser",
"BYTECODE": "Bytecode",
"CANCEL": "Cancel",
"CLEAR": "Clear",
@ -16,6 +17,7 @@
"CLEAR_DATA": "Clear data",
"CLEAR_DATA_FAILED": "Failed to clear data for {name}",
"CLEAR_DATA_SUCCESSFUL": "Cleared data for {name}",
"CODEBERG": "Codeberg",
"CODENAME": "Codename",
"COMMAND_DEBUG_DESC": "Send Kettu debug info.",
"COMMAND_DEBUG_OPT_EPHEMERALLY": "Send debug info ephemerally.",
@ -113,7 +115,7 @@
"UH_OH": "Uh Oh",
"UNINSTALL": "Uninstall",
"UNINSTALL_TITLE": "Uninstall {title}",
"URL_PLACEHOLDER": "https://github.com/C0C0B01/Kettu",
"URL_PLACEHOLDER": "https://codeberg.org/cocobo1/Kettu",
"VERSION": "Version",
"VERSIONS": "Versions"
}

View file

@ -2,7 +2,6 @@ import { after } from "@lib/api/patcher";
import { onJsxCreate } from "@lib/api/react/jsx";
import { findByName } from "@metro";
import { useEffect, useState } from "react";
import { defineCorePlugin } from "..";
interface BunnyBadge {
@ -15,47 +14,79 @@ const useBadgesModule = findByName("useBadges", false);
export default defineCorePlugin({
manifest: {
id: "bunny.badges",
name: "Badges",
version: "1.0.0",
description: "Adds badges to user's profile",
authors: [{ name: "pylixonly" }]
version: "1.1.0",
type: "plugin",
spec: 3,
main: "",
display: {
name: "Badges",
description: "Adds badges to user's profile",
authors: [{ name: "cocobo1"}, { name: "pylixonly" }]
}
},
start() {
const propHolder = {} as Record<string, any>;
const badgeCache = {} as Record<string, BunnyBadge[]>;
onJsxCreate("RenderedBadge", (_, ret) => {
if (ret.props.id.match(/bunny-\d+-\d+/)) {
Object.assign(ret.props, propHolder[ret.props.id]);
let allBadges: { [x: string]: any; } | null = null;
const badgeProps = {} as Record<string, any>;
onJsxCreate("ProfileBadge", (component, ret) => {
if (ret.props.id?.startsWith("bunny-")) {
const cachedProps = badgeProps[ret.props.id];
if (cachedProps) {
ret.props.source = cachedProps.source;
ret.props.label = cachedProps.label;
ret.props.id = cachedProps.id;
}
}
});
after("default", useBadgesModule, ([user], r) => {
const [badges, setBadges] = useState<BunnyBadge[]>(user ? badgeCache[user.userId] ??= [] : []);
useEffect(() => {
if (user) {
fetch(`https://raw.githubusercontent.com/pyoncord/badges/refs/heads/main/${user.userId}.json`)
.then(r => r.json())
.then(badges => setBadges(badgeCache[user.userId] = badges));
onJsxCreate("RenderedBadge", (component, ret) => {
if (ret.props.id?.startsWith("bunny-")) {
const cachedProps = badgeProps[ret.props.id];
if (cachedProps) {
Object.assign(ret.props, cachedProps);
}
}, [user]);
if (user) {
badges.forEach((badges, i) => {
propHolder[`bunny-${user.userId}-${i}`] = {
source: { uri: badges.url },
id: `bunny-${i}`,
label: badges.label
}
});
after("default", useBadgesModule, ([user], result) => {
const [badges, setBadges] = useState<BunnyBadge[]>([]);
useEffect(() => {
if (!user) return;
if (!allBadges) {
fetch("https://codeberg.org/raincord/badges/raw/branch/main/badges.json")
.then(r => r.json())
.then(data => {
allBadges = data;
//@ts-expect-error
setBadges(allBadges[user.userId] || []);
})
} else {
//user has no badges, but maybe they should get some by contributing
setBadges(allBadges[user.userId] || []);
}
}, [user?.userId]);
if (user && badges.length > 0) {
badges.forEach((badge, i) => {
const badgeId = `bunny-${user.userId}-${i}`;
badgeProps[badgeId] = {
id: badgeId,
source: { uri: badge.url },
label: badge.label,
userId: user.userId,
};
r.push({
id: `bunny-${user.userId}-${i}`,
description: badges.label,
icon: "_",
result.push({
id: badgeId,
description: badge.label,
icon: " _",
});
});
}
});
}
});
});

View file

@ -1,21 +1,25 @@
import { PluginInstanceInternal } from "@lib/addons/plugins/types";
interface CorePlugin {
default: PluginInstanceInternal;
preenabled: boolean;
default: PluginInstanceInternal;
preenabled: boolean;
}
// Called from @lib/plugins
export const getCorePlugins = (): Record<string, CorePlugin> => ({
"bunny.quickinstall": require("./quickinstall"),
"bunny.badges": require("./badges")
"bunny.quickinstall": require("./quickinstall"),
"bunny.badges": require("./badges"),
"bunny.notrack": { ...require("./notrack"), preenabled: true },
"bunny.messagefix": { default: require("./messagefix"), preenabled: true },
});
/**
* @internal
*/
export function defineCorePlugin(instance: PluginInstanceInternal): PluginInstanceInternal {
// @ts-expect-error
instance[Symbol.for("bunny.core.plugin")] = true;
return instance;
export function defineCorePlugin(
instance: PluginInstanceInternal,
): PluginInstanceInternal {
// @ts-expect-error
instance[Symbol.for("bunny.core.plugin")] = true;
return instance;
}

View file

@ -0,0 +1,56 @@
import { defineCorePlugin } from "..";
import { findByProps } from "@metro";
import { logger } from "@lib/utils/logger";
// Find the MessageActions module
const MessageActions = findByProps("sendMessage");
let originalSendMessage: Function;
export default defineCorePlugin({
manifest: {
id: "bunny.messagefix",
version: "1.0.0",
type: "plugin",
spec: 3,
main: "",
display: {
name: "MessageFix",
description: "Ensures messages include the required nonce parameter",
authors: [{ name: "ShiggyCord Team" }],
},
},
start() {
// Save original function
originalSendMessage = MessageActions.sendMessage;
// Replace with our implementation
MessageActions.sendMessage = function (
channelId,
message,
replyRef,
options,
) {
// Ensure options exists and has a nonce
options = options || {};
options.nonce = options.nonce || Date.now().toString();
// Call original with fixed parameters
return originalSendMessage.call(
this,
channelId,
message,
replyRef,
options,
);
};
logger.log("MessageFix: Enabled - adding nonce to all messages");
},
stop() {
// Restore original function
if (originalSendMessage) MessageActions.sendMessage = originalSendMessage;
logger.log("MessageFix: Disabled");
},
});

View file

@ -0,0 +1,230 @@
import { defineCorePlugin } from "..";
import { findByProps } from "@metro";
import { instead } from "@lib/api/patcher";
import { logger } from "@lib/utils/logger";
// Type for functions that need to be cleaned up when plugin is disabled
type PatchCleanupFn = () => void;
// Store all active patches for cleanup
let patches: PatchCleanupFn[] = [];
// AnalyticsUtils and other tracking utilities
const AnalyticsUtils = findByProps("AnalyticsActionHandlers");
const SuperPropUtils = findByProps("encodeProperties", "track");
const VoiceStateUtils = findByProps("getVoiceStateMetadata");
const CrashReportUtils = findByProps("submitLiveCrashReport");
const MetricsUtils = findByProps("_metrics");
// Sentry interface definition to avoid TypeScript errors
interface SentryClient {
getOptions: () => { enabled: boolean };
close: () => void;
open: () => void;
}
interface SentryScope {
clear: () => void;
}
interface SentryHub {
getClient: () => SentryClient | undefined;
getStackTop: () => { scope: SentryScope };
getScope: () => SentryScope;
addBreadcrumb?: (breadcrumb: unknown) => void;
}
interface GlobalSentry {
hub?: SentryHub;
}
// Get Sentry objects with proper type checking
const sentryGlobal = (globalThis as unknown as { __SENTRY__?: GlobalSentry })
.__SENTRY__;
const sentryHub = sentryGlobal?.hub;
const sentryClient = sentryHub?.getClient();
// Sentry related objects with type safety
const Sentry = {
initializer: findByProps("initSentry"),
main: sentryHub,
client: sentryClient,
};
// Utility function to create no-operation patches
const noop = (prop: string, parent: Record<string, any>): PatchCleanupFn => {
try {
return instead(prop, parent, () => undefined);
} catch (e) {
return () => false;
}
};
// Network tracking prevention
function patchNetwork(): PatchCleanupFn {
// Analytics and tracking endpoints to block
const analyticsTest =
/client-analytics\.braintreegateway\.com|discord\.com\/api\/v9\/(science|track)|app\.adjust\..*|.*\.ingest\.sentry\.io/;
try {
const unpatch = instead(
"send",
XMLHttpRequest.prototype,
function (
this: XMLHttpRequest & { __sentry_xhr__?: { url: string } },
args: unknown[],
orig: Function,
) {
if (
this.__sentry_xhr__?.url &&
analyticsTest.test(this.__sentry_xhr__.url)
) {
return undefined;
}
return orig.apply(this, args);
},
);
return unpatch;
} catch (e) {
return () => false;
}
}
// Console patching to remove Sentry wrappers
function patchConsole(): PatchCleanupFn {
type ConsoleFunction = Function & { __sentry_original__?: Function };
type ConsoleFunctions = Record<string, ConsoleFunction>;
const sentrified: ConsoleFunctions = {};
try {
// We need to use Object.keys because TypeScript doesn't allow indexing Console with strings
(Object.keys(console) as Array<keyof Console>).forEach((key) => {
const consoleFunc = (console as unknown as ConsoleFunctions)[key];
if (consoleFunc) {
sentrified[key] = consoleFunc;
const originalFunc = consoleFunc.__sentry_original__;
(console as unknown as ConsoleFunctions)[key] =
originalFunc ?? consoleFunc;
}
});
} catch (e) {
logger.log("Failed to de-sentrify console functions!", e);
}
return () => {
Object.keys(sentrified).forEach((key) => {
if (sentrified[key]) {
(console as unknown as ConsoleFunctions)[key] = sentrified[key];
}
});
};
}
// Miscellaneous tracking functions
function patchMiscellaneous(): PatchCleanupFn {
const miscPatches = [
// Global analytics utilities
AnalyticsUtils?.AnalyticsActionHandlers &&
noop("handleTrack", AnalyticsUtils.AnalyticsActionHandlers),
AnalyticsUtils?.AnalyticsActionHandlers &&
noop("handleFingerprint", AnalyticsUtils.AnalyticsActionHandlers),
// Super properties tracking
SuperPropUtils && noop("track", SuperPropUtils),
// Voice state metadata tracking
VoiceStateUtils && noop("trackWithMetadata", VoiceStateUtils),
// Crash reporter
CrashReportUtils && noop("submitLiveCrashReport", CrashReportUtils),
// Metrics
MetricsUtils?._metrics && noop("push", MetricsUtils._metrics),
].filter(Boolean) as PatchCleanupFn[];
return () => miscPatches.forEach((p) => p());
}
// Sentry error reporting prevention
function patchSentry(): PatchCleanupFn {
const sentryPatches: PatchCleanupFn[] = [];
// Add patches if Sentry components exist
if (Sentry.initializer) {
sentryPatches.push(noop("initSentry", Sentry.initializer));
}
if (Sentry.main && Sentry.main.addBreadcrumb) {
sentryPatches.push(noop("addBreadcrumb", Sentry.main));
}
// Disable Sentry client
if (Sentry.client) {
try {
Sentry.client.getOptions().enabled = false;
Sentry.client.close();
if (Sentry.main) {
if (Sentry.main.getStackTop) {
Sentry.main.getStackTop().scope.clear();
}
if (Sentry.main.getScope) {
Sentry.main.getScope().clear();
}
}
} catch (e) {
// Silent fail
}
}
return () => {
try {
sentryPatches.forEach((p) => p());
// Re-enable Sentry client on cleanup
if (Sentry.client) {
Sentry.client.getOptions().enabled = true;
Sentry.client.open();
}
} catch (e) {
// Silent fail
}
};
}
export default defineCorePlugin({
manifest: {
id: "bunny.notrack",
version: "1.0.0",
type: "plugin",
spec: 3,
main: "",
display: {
name: "NoTrack",
description: "Disables Discord's telemetry",
authors: [{ name: "maisymoe" }],
},
},
start() {
// Apply all patches
patches = [
patchNetwork(),
patchConsole(),
patchMiscellaneous(),
patchSentry(),
].filter(Boolean);
logger.log("NoTrack: Enabled - all telemetry tracking disabled");
},
stop() {
// Call all cleanup functions
patches.forEach((p) => p?.());
patches = [];
logger.log("NoTrack: Disabled - telemetry tracking restored");
},
});

View file

@ -7,10 +7,15 @@ let patches = [] as (() => unknown)[];
export default defineCorePlugin({
manifest: {
id: "bunny.quickinstall",
name: "QuickInstall",
version: "1.0.0",
description: "Quickly install Vendetta plugins and themes",
authors: [{ name: "Vendetta Team" }]
type: "plugin",
spec: 3,
main: "",
display: {
name: "QuickInstall",
description: "Quickly install Vendetta plugins and themes",
authors: [{ name: "Vendetta Team" }]
}
},
start() {
patches = [patchForumPost(), patchUrl()];

View file

@ -175,7 +175,7 @@ export default function AddonPage<T extends object>({ CardComponent, ...props }:
<View style={{ flexDirection: "row", gap: 8 }}>
<Search style={{ flexGrow: 1 }} isRound={!!props.sortOptions} onChangeText={v => setSearch(v)} />
{props.sortOptions && <IconButton
icon={findAssetId("ic_forum_channel_sort_order_24px")}
icon={findAssetId("MoreVerticalIcon")}
variant="tertiary"
disabled={!!search}
onPress={() => showSimpleActionSheet({

View file

@ -33,7 +33,7 @@ export default function ErrorBoundaryScreen(props: {
<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-md/normal">A crash occurred while rendering a component. This could be caused by a plugin, Kettu, 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 }}>

View file

@ -41,6 +41,12 @@ export default function initSettings() {
render: () => import("@core/ui/settings/pages/Fonts"),
usePredicate: () => isFontSupported()
},
{
key: "KETTU_BROWSER",
title: () => Strings.BROWSER,
icon: findAssetId("UploadIcon"),
render: () => import("@core/ui/settings/pages/PluginBrowser"),
},
{
key: "BUNNY_DEVELOPER",
title: () => Strings.DEVELOPER,
@ -51,12 +57,6 @@ export default function initSettings() {
]
});
// Compat with Vendetta Plugins that use configs in settings
registerSection({
name: "Vendetta",
items: []
});
// Compat with Bunny Plugins that use configs in settings
registerSection({
name: "Bunny",
@ -68,4 +68,10 @@ export default function initSettings() {
name: "Revenge",
items: []
});
// Compat with Vendetta Plugins that use configs in settings
registerSection({
name: "Vendetta",
items: []
});
}

View file

@ -21,7 +21,7 @@ const { showSimpleActionSheet } = lazyDestructure(() => findByProps("showSimpleA
const { openAlert } = lazyDestructure(() => findByProps("openAlert", "dismissAlert"));
const { AlertModal, AlertActionButton } = lazyDestructure(() => findByProps("AlertModal", "AlertActions"));
const RDT_EMBED_LINK = "https://github.com/revenge-mod/react-devtools-core/releases/latest/download/rdtc.js";
const RDT_EMBED_LINK = "";
const useStyles = createStyles({
leadingText: {
@ -103,7 +103,7 @@ export default function Developer() {
// Thanks for this Revenge team!
label={Strings.CLEAR_BUNDLE}
icon={<TableRow.Icon source={findAssetId("trash")} />}
icon={<TableRow.Icon source={findAssetId("TrashIcon")} />}
onPress={() => {
openAlert("pupu-clear-bundle-reload-confirmation", <AlertModal
title={Strings.MODAL_RELOAD_REQUIRED}

View file

@ -63,7 +63,7 @@ export default function About() {
{
label: Strings.BRAND,
version: debugInfo.device.brand,
icon: "SparklesIcon"
icon: "MagicWandIcon"
},
{
label: Strings.MODEL,

View file

@ -8,10 +8,11 @@ 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 { DISCORD_SERVER, GITHUB, CODEBERG } from "@lib/utils/constants";
import { NavigationNative } from "@metro/common";
import { AlertActionButton, AlertActions, AlertModal, Stack, TableRow, TableRowGroup, TableSwitchRow } from "@metro/common/components";
import { Linking, ScrollView } from "react-native";
import CodebergIcon from "@assets/icons/codeberg-logo_icon_white.png";
export default function General() {
useProxy(settings);
@ -50,6 +51,12 @@ export default function General() {
icon={<TableRow.Icon source={findAssetId("Discord")!} />}
onPress={() => Linking.openURL(DISCORD_SERVER)}
/>
<TableRow
arrow={true}
label={Strings.CODEBERG}
icon={<TableRow.Icon source={{ uri: CodebergIcon}} />}
onPress={() => Linking.openURL(CODEBERG)}
/>
<TableRow
arrow={true}
label={Strings.GITHUB}

View file

@ -1,73 +1,223 @@
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 { OFFICIAL_PLUGINS_REPO_URL } from "@lib/utils/constants";
import { NEXPID_PLUGINS_REPO_URL } from "@lib/utils/constants";
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 { useEffect, useState } from "react";
import { React, NavigationNative } from "@metro/common";
import { View } from "react-native";
import { Stack, Button, IconButton, Text, Card, FlashList } from "@metro/common/components";
import { findAssetId } from "@lib/api/assets";
import safeFetch from "@lib/utils/safeFetch";
import { showToast } from "@ui/toasts";
import Search from "@ui/components/Search";
import { VdPluginManager } from "@core/vendetta/plugins";
import { installTheme, themes, removeTheme } from "@lib/addons/themes";
import { clipboard } from "@metro/common";
import { hideSheet, showSheet } from "@lib/ui/sheets";
import { AlertActionButton } from "@lib/ui/components/wrappers";
import { dismissAlert, openAlert } from "@lib/ui/alerts";
import { ActionSheet, AlertModal, AlertActions, TableRow, TableRowGroup } from "@metro/common/components";
import { lazyDestructure } from "@lib/utils/lazy";
import { findByProps } from "@metro";
const queryClient = new QueryClient();
const { showSimpleActionSheet, hideActionSheet } = lazyDestructure(() => findByProps("showSimpleActionSheet"));
async function getManifests() {
await updateAllRepository();
const plugins = [...registeredPlugins.values()];
return plugins.filter(p => !isCorePlugin(p.id));
interface BaseAddonData {
name: string;
description: string;
authors: string[];
installUrl: string;
}
function InstallButton(props: { id: string; }) {
const [installed, setInstalled] = useState(isPluginInstalled(props.id));
const installationState = useMutation<void, Error, { install: boolean; }>({
mutationFn: async ({ install }) => {
await (install ? installPlugin : uninstallPlugin)(props.id, true);
},
onSettled() {
setInstalled(isPluginInstalled(props.id));
},
onError(error) {
showToast(error instanceof Error ? error.message : String(error));
interface PluginData extends BaseAddonData {
status: "working" | "broken" | "warning" | string;
sourceUrl: string;
warningMessage?: string;
}
interface ThemeData extends BaseAddonData {}
type AddonData = PluginData | ThemeData;
const PLUGIN_URL = "https://raw.githubusercontent.com/Purple-EyeZ/Plugins-List/refs/heads/main/src/plugins-data.json";
const THEME_URL = "https://raw.githubusercontent.com/kmmiio99o/theme-marketplace/refs/heads/main/themes.json";
function normalizeIdFromInstallUrl(url: string) {
return url.endsWith("/") ? url : url + "/";
}
// @ts-ignore (i cant be bothered to type these)
function InstallButton({ addon, isPluginMode, installing, setInstalling, setRefreshTick }) {
const normId = normalizeIdFromInstallUrl(addon.installUrl);
const [installed, setInstalled] = React.useState(() =>
isPluginMode ? Boolean(VdPluginManager.plugins[normId]) : Boolean(themes[addon.installUrl])
);
React.useEffect(() => {
setInstalled(isPluginMode ? Boolean(VdPluginManager.plugins[normId]) : Boolean(themes[addon.installUrl]));
}, [addon.installUrl, setRefreshTick, isPluginMode]);
const installAddon = async () => {
if (installing.has(normId)) return;
setInstalling((prev: Iterable<unknown> | null | undefined) => new Set(prev).add(normId));
try {
if (isPluginMode) {
await VdPluginManager.installPlugin(normId, true);
} else {
await installTheme(addon.installUrl);
}
showToast(`Installed ${addon.name}`, findAssetId("CheckIcon"));
setInstalled(true);
} catch (e) {
showToast(e instanceof Error ? e.message : String(e), findAssetId("CircleXIcon-primary"));
} finally {
setInstalling((prev: Iterable<unknown> | null | undefined) => { const s = new Set(prev); s.delete(normId); return s; });
setRefreshTick((t: number) => t + 1);
}
});
};
return <Button
size="sm"
loading={installationState.isPending}
text={!installed ? "Install" : "Uninstall"}
onPress={() => installationState.mutate({ install: !installed })}
variant={!installed ? "primary" : "destructive"}
icon={findAssetId(!installed ? "DownloadIcon" : "TrashIcon")}
/>;
}
const uninstallAddon = async () => {
try {
if (isPluginMode) {
await VdPluginManager.removePlugin(normId);
} else {
await removeTheme(addon.installUrl);
}
showToast(`Uninstalled ${addon.name}`, findAssetId("TrashIcon"));
setInstalled(false);
} catch (e) {
showToast(e instanceof Error ? e.message : String(e), findAssetId("CircleXIcon-primary"));
} finally {
setRefreshTick((t: number) => t + 1);
}
};
function TrailingButtons(props: { id: string; }) {
return <Stack spacing={8} direction="horizontal">
<IconButton
const promptInstall = () => {
if (!isPluginMode) return installAddon();
const plugin = addon as PluginData;
const needsWarn = (plugin.status && plugin.status !== "working") || (plugin.warningMessage && plugin.warningMessage.trim().length > 0);
if (!needsWarn) return installAddon();
const lines: string[] = [];
if (plugin.status && plugin.status !== "working") {
if (plugin.status === "broken") lines.push("This plugin is marked as broken, please be aware you may encounter issues");
else if (plugin.status === "warning") lines.push("This plugin may have issues");
else lines.push(`Status: ${plugin.status}`);
}
if (plugin.warningMessage) lines.push(plugin.warningMessage);
openAlert("plugins-list-install-warning", (
<AlertModal
title="Warning!"
content="This plugin may not work as expected."
extraContent={<Text variant="text-sm/normal" color="text-muted">{lines.join("\n\n")}</Text>}
actions={<AlertActions>
<AlertActionButton
text="Install Anyway"
variant="primary"
onPress={() => { dismissAlert("plugins-list-install-warning"); installAddon(); }}
/>
<AlertActionButton
text="Cancel"
variant="secondary"
onPress={() => dismissAlert("plugins-list-install-warning")}
/>
</AlertActions>}
/>
));
};
return (
<Button
size="sm"
onPress={() => {
showSheet("plugin-info", () => {
return <ActionSheet>
<TableRowGroup title="Plugin Info">
<TableRow label="ID" subLabel={props.id} />
</TableRowGroup>
</ActionSheet>;
});
}}
variant="secondary"
icon={findAssetId("CircleInformationIcon")}
loading={installing.has(normId)}
text={!installed ? (installing.has(normId) ? "Installing..." : "Install") : "Uninstall"}
disabled={installing.has(normId)}
onPress={!installed ? promptInstall : uninstallAddon}
variant={!installed ? "primary" : "destructive"}
icon={findAssetId(!installed ? "DownloadIcon" : "TrashIcon")}
/>
<InstallButton id={props.id} />
</Stack>;
);
}
function PluginCard(props: { manifest: BunnyPluginManifestInternal; }) {
const { display, version } = props.manifest;
// @ts-ignore (i cant be bothered to type these)
function TrailingButtons({ addon, isPluginMode, installing, setInstalling, setRefreshTick }) {
const copyAddonLink = () => {
clipboard.setString(addon.installUrl);
// @ts-ignore
showToast.showCopyToClipboard?.();
};
const copySourceUrl = () => {
const plugin = addon as PluginData;
clipboard.setString(plugin.sourceUrl);
// @ts-ignore
showToast.showCopyToClipboard?.();
};
const openAddonMenu = () => {
const actions = [
{
label: `Copy ${isPluginMode ? 'Plugin' : 'Theme'} Link`,
icon: findAssetId("CopyIcon"),
onPress: copyAddonLink
}
];
if (isPluginMode && (addon as PluginData).sourceUrl) {
actions.push({
label: "Copy Source URL",
icon: findAssetId("CopyIcon"),
onPress: copySourceUrl
});
}
const sheetKey = `${isPluginMode ? 'plugin' : 'theme'}-menu`;
showSheet(sheetKey, () => (
<ActionSheet>
<TableRowGroup title={`${isPluginMode ? 'Plugin' : 'Theme'} Info`}>
{actions.map((action, index) => (
<TableRow
key={index}
label={action.label}
icon={<TableRow.Icon source={action.icon} />}
onPress={() => {
action.onPress();
hideSheet(sheetKey);
}}
/>
))}
</TableRowGroup>
</ActionSheet>
));
};
return (
<Stack spacing={8} direction="horizontal">
<IconButton
size="sm"
onPress={openAddonMenu}
variant="secondary"
icon={findAssetId("MoreHorizontalIcon")}
/>
<InstallButton
addon={addon}
isPluginMode={isPluginMode}
installing={installing}
setInstalling={setInstalling}
setRefreshTick={setRefreshTick}
/>
</Stack>
);
}
// @ts-ignore (i cant be bothered to type these)
function AddonCard({ addon, isPluginMode, installing, setInstalling, setRefreshTick }) {
const { name, description, authors } = addon;
const plugin = addon as PluginData;
let statusColor = "text-normal";
if (isPluginMode) {
if (plugin.status === "working") statusColor = "#4ADE80";
if (plugin.status === "broken") statusColor = "#EF4444";
if (plugin.status === "warning") statusColor = "#F59E0B";
}
return (
<Card>
@ -75,184 +225,275 @@ function PluginCard(props: { manifest: BunnyPluginManifestInternal; }) {
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
<View style={{ flexShrink: 1 }}>
<Text numberOfLines={1} variant="heading-lg/semibold">
{display.name}
{name}
</Text>
<Text variant="text-md/semibold" color="text-muted">
by {display.authors?.map(a => a.name).join(", ") || "Unknown"} ({version})
by {authors?.join(", ") || "Unknown"}
</Text>
{isPluginMode && (
<Text variant="text-md/semibold" style={{ color: statusColor }}>
Status: {plugin.status}
</Text>
)}
</View>
<View>
<TrailingButtons id={props.manifest.id} />
<TrailingButtons
addon={addon}
isPluginMode={isPluginMode}
installing={installing}
setInstalling={setInstalling}
setRefreshTick={setRefreshTick}
/>
</View>
</View>
<Text variant="text-md/medium">
{display.description}
{description}
</Text>
{isPluginMode && plugin.warningMessage && (
<Text variant="text-sm/medium" color="text-muted">
Warning: {plugin.warningMessage}
</Text>
)}
</Stack>
</Card>
);
}
function BrowserPage() {
enum Sort {
DateNewest = "Newest",
DateOldest = "Oldest",
NameAZ = "Name (A–Z)",
NameZA = "Name (Z–A)",
WorkingFirst = "Working First",
BrokenFirst = "Broken First",
}
export default function BrowserPage() {
const navigation = NavigationNative.useNavigation();
useEffect(() => {
const [mode, setMode] = React.useState<"plugins" | "themes">("plugins");
const [plugins, setPlugins] = React.useState<PluginData[]>([]);
const [themesList, setThemesList] = React.useState<ThemeData[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [searchQuery, setSearchQuery] = React.useState("");
const [installing, setInstalling] = React.useState<Set<string>>(new Set());
const [refreshTick, setRefreshTick] = React.useState(0);
const [sort, setSort] = React.useState<Sort>(Sort.DateNewest);
React.useEffect(() => {
navigation.setOptions({
title: "Plugin Browser",
headerRight: () => <IconButton
size="sm"
variant="secondary"
icon={findAssetId("PlusSmallIcon")}
onPress={() => {
showSheet("plugin-browser-options", PluginBrowserOptions);
}}
/>
title: "Browser"
});
}, [navigation]);
const { data, error, isPending, refetch } = useQuery({
queryKey: ["plugins-repo-fetch"],
queryFn: () => getManifests()
});
const fetchData = React.useCallback(async (isPluginMode: boolean) => {
setLoading(true);
setError(null);
try {
const url = isPluginMode ? PLUGIN_URL : THEME_URL;
const response = await safeFetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const data = await response.json();
let addonList: AddonData[] = [];
if (Array.isArray(data)) {
addonList = data;
} else if (isPluginMode && data.OFFICIAL_PLUGINS) {
addonList = data.OFFICIAL_PLUGINS;
} else if (!isPluginMode) {
// Handle any other structure for themes - try common property names
addonList = data.OFFICIAL_THEMES || data.themes || data.THEMES || data.items || [];
}
if (isPluginMode) {
setPlugins(addonList as PluginData[]);
} else {
setThemesList(addonList as ThemeData[]);
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
if (isPluginMode) {
setPlugins([]);
} else {
setThemesList([]);
}
} finally {
setLoading(false);
}
}, []);
const fetchPlugins = React.useCallback(() => fetchData(true), [fetchData]);
const fetchThemes = React.useCallback(() => fetchData(false), [fetchData]);
React.useEffect(() => {
fetchPlugins();
fetchThemes();
}, [fetchPlugins, fetchThemes]);
const filterList = (list: AddonData[]) => {
if (!list) return [] as AddonData[];
const q = searchQuery.toLowerCase();
if (!q) return list;
return list.filter(p =>
p.name.toLowerCase().includes(q)
|| p.description.toLowerCase().includes(q)
|| (p.authors || []).some(a => a.toLowerCase().includes(q))
);
};
const sortedAndFiltered = React.useMemo(() => {
const list = filterList(mode === "plugins" ? plugins : themesList);
const getStatusPriority = (status: PluginData["status"], sortBy: Sort): number => {
if (sortBy === Sort.WorkingFirst) {
return status === "working" || status === "warning" ? 0 : 1;
}
if (sortBy === Sort.BrokenFirst) {
return status === "broken" ? 0 : 1;
}
return 0;
};
switch (sort) {
case Sort.DateNewest:
return [...list].reverse();
case Sort.DateOldest:
return [...list];
case Sort.NameAZ:
return [...list].sort((a, b) => a.name.localeCompare(b.name));
case Sort.NameZA:
return [...list].sort((a, b) => b.name.localeCompare(a.name));
case Sort.WorkingFirst:
if (mode === "plugins") {
return [...list].sort((a, b) => {
const pa = getStatusPriority((a as PluginData).status, Sort.WorkingFirst);
const pb = getStatusPriority((b as PluginData).status, Sort.WorkingFirst);
return pa !== pb ? pa - pb : a.name.localeCompare(b.name);
});
}
return list;
case Sort.BrokenFirst:
if (mode === "plugins") {
return [...list].sort((a, b) => {
const pa = getStatusPriority((a as PluginData).status, Sort.BrokenFirst);
const pb = getStatusPriority((b as PluginData).status, Sort.BrokenFirst);
return pa !== pb ? pa - pb : a.name.localeCompare(b.name);
});
}
return list;
default:
return list;
}
}, [plugins, themesList, mode, searchQuery, sort]);
if (error) {
return <View style={{
flex: 1,
paddingHorizontal: 8,
justifyContent: "center",
alignItems: "center",
}}>
<Card style={{ gap: 8 }}>
<Text style={{ textAlign: "center" }} variant="heading-lg/bold">
An error occured while fetching the repository!
</Text>
<Text style={{ textAlign: "center" }} variant="text-sm/medium" color="text-muted">
{error instanceof Error ? error.message : String(error)}
</Text>
<Button
size="lg"
text="Refetch"
onPress={refetch}
icon={findAssetId("RetryIcon")}
/>
</Card>
</View>;
return (
<View style={{ flex: 1, paddingHorizontal: 8, justifyContent: "center", alignItems: "center" }}>
<Card style={{ gap: 8 }}>
<Text style={{ textAlign: "center" }} variant="heading-lg/bold">
An error occurred while fetching the repository
</Text>
<Text style={{ textAlign: "center" }} variant="text-sm/medium" color="text-muted">
{error}
</Text>
<Button
size="lg"
text="Refetch"
onPress={() => fetchData(mode === "plugins")}
icon={findAssetId("RetryIcon")}
/>
</Card>
</View>
);
}
return <FlashList
data={data}
refreshing={isPending}
onRefresh={refetch}
estimatedItemSize={136}
contentContainerStyle={{ paddingBottom: 90, paddingHorizontal: 5 }}
renderItem={({ item: manifest }: any) => (
<View style={{ paddingVertical: 6, paddingHorizontal: 8 }}>
<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 isOfficialBunny = props.url === OFFICIAL_PLUGINS_REPO_URL;
const isNexpid = props.url === NEXPID_PLUGINS_REPO_URL;
return (
<TableRow
label={isOfficialBunny ? "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={isOfficialBunny}
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>}
/>);
}}
/>
<View style={{ flex: 1 }}>
<View style={{ justifyContent: "center", alignItems: "center", paddingHorizontal: 10 }}>
<Stack spacing={12}>
<View style={{ flexDirection: "row", justifyContent: "center", alignItems: 'center', paddingTop: 10 }}>
<Stack spacing={10} style={{ flexDirection: "row", justifyContent: "center", alignItems: 'center' }}>
<Button
size="sm"
text="Plugins"
variant={mode === "plugins" ? "primary" : "secondary"}
onPress={() => setMode("plugins")}
/>
<Button
size="sm"
text="Themes"
variant={mode === "themes" ? "primary" : "secondary"}
onPress={() => setMode("themes")}
/>
</Stack>
</View>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingBottom: 6 }}>
<Search
placeholder={`Search ${mode}...`}
onChangeText={setSearchQuery}
style={{ flex: 1 }}
/>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<IconButton
size="sm"
variant="tertiary"
icon={findAssetId("MoreVerticalIcon")}
disabled={!!searchQuery}
onPress={() => showSimpleActionSheet({
key: "AddonListSortOptions",
header: {
title: "Sort Options",
onClose: () => hideActionSheet("AddonListSortOptions"),
},
options: Object.entries(Sort).map(([key, value]) => ({
label: value,
onPress: () => {
setSort(value as Sort);
}
}))
})}
/>
</View>
</View>
</Stack>
)} />
</View>
<FlashList
data={sortedAndFiltered}
refreshing={loading}
onRefresh={mode === "plugins" ? fetchPlugins : fetchThemes}
estimatedItemSize={200}
contentContainerStyle={{ paddingBottom: 90, paddingHorizontal: 5 }}
ListHeaderComponent={mode === "plugins" ? (
<View style={{ paddingVertical: 6, paddingHorizontal: 8 }}>
<Card border="strong">
<View style={{ flex: 1, justifyContent: "center", alignItems: "center", flexDirection: "row" }}>
<View style={{ gap: 6, flexShrink: 1 }}>
<Text variant="heading-md/bold">Unproxied Plugins</Text>
<Text variant="text-sm/medium" color="text-muted">
Plugins installed from this source have not been checked for safety, install at your own risk
</Text>
</View>
</View>
</Card>
</View>
) : null}
//@ts-ignore
renderItem={({ item: addon }) => (
<View style={{ paddingVertical: 6, paddingHorizontal: 8 }}>
<AddonCard
addon={addon}
isPluginMode={mode === "plugins"}
installing={installing}
setInstalling={setInstalling}
setRefreshTick={setRefreshTick}
/>
</View>
)}
/>
</View>
);
}
export default function PluginBrowser() {
return (
<QueryClientProvider client={queryClient}>
<BrowserPage />
</QueryClientProvider>
);
}
}

View file

@ -3,154 +3,182 @@ 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";
import { Card, IconButton, Stack, TableSwitch, Text } from "@metro/common/components";
import {
Card,
IconButton,
Stack,
TableSwitch,
Text,
} from "@metro/common/components";
import { showSheet } from "@ui/sheets";
import chroma from "chroma-js";
import { createContext, useContext, useMemo } from "react";
import { Image, View } from "react-native";
const CardContext = createContext<{ plugin: UnifiedPluginModel, result: Fuzzysort.KeysResult<UnifiedPluginModel>; }>(null!);
const CardContext = createContext<{
plugin: UnifiedPluginModel;
result: Fuzzysort.KeysResult<UnifiedPluginModel>;
}>(null!);
const useCardContext = () => useContext(CardContext);
function getHighlightColor(): import("react-native").ColorValue {
return chroma(tokens.unsafe_rawColors.YELLOW_300).alpha(0.3).hex();
return chroma(tokens.unsafe_rawColors.YELLOW_300).alpha(0.3).hex();
}
function Title() {
const styles = usePluginCardStyles();
const { plugin, result } = useCardContext();
const styles = usePluginCardStyles();
const { plugin, result } = useCardContext();
// could be empty if the plugin name is irrelevant!
const highlightedNode = result[0].highlight((m, i) =>
<Text key={i} style={{ backgroundColor: getHighlightColor() }}>
{m}
</Text>
);
// could be empty if the plugin name is irrelevant!
const highlightedNode = result[0].highlight((m, i) => (
<Text key={i} style={{ backgroundColor: getHighlightColor() }}>
{m}
</Text>
));
const icon = plugin.icon && findAssetId(plugin.icon);
const icon = plugin.icon && findAssetId(plugin.icon);
const textNode = (
<Text
numberOfLines={1}
variant="heading-lg/semibold"
>
{highlightedNode.length ? highlightedNode : plugin.name}
</Text>
);
const textNode = (
<Text numberOfLines={1} variant="heading-lg/semibold">
{highlightedNode.length ? highlightedNode : plugin.name}
</Text>
);
return <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
{icon && <Image
style={styles.smallIcon}
source={icon}
/>}
{textNode}
</View>;
return (
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
{icon && <Image style={styles.smallIcon} source={icon} />}
{textNode}
</View>
);
}
function Authors() {
const { plugin, result } = useCardContext();
const styles = usePluginCardStyles();
const { plugin, result } = useCardContext();
const styles = usePluginCardStyles();
if (!plugin.authors) return null;
if (!plugin.authors) return null;
// could be empty if the author(s) are irrelevant with the search!
const highlightedNode = result[2].highlight((m, i) =>
<Text key={i} style={{ backgroundColor: getHighlightColor() }}>
{m}
</Text>
);
// could be empty if the author(s) are irrelevant with the search!
const highlightedNode = result[2].highlight((m, i) => (
<Text key={i} style={{ backgroundColor: getHighlightColor() }}>
{m}
</Text>
));
const badges = plugin.getBadges();
const authorText = highlightedNode.length > 0 ? highlightedNode : plugin.authors.map(a => a.name).join(", ");
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>}
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>
);
}
function Description() {
const { plugin, result } = useCardContext();
const { plugin, result } = useCardContext();
// could be empty if the description is irrelevant with the search!
const highlightedNode = result[1].highlight((m, i) =>
<Text key={i} style={{ backgroundColor: getHighlightColor() }}>{m}</Text>
);
// could be empty if the description is irrelevant with the search!
const highlightedNode = result[1].highlight((m, i) => (
<Text key={i} style={{ backgroundColor: getHighlightColor() }}>
{m}
</Text>
));
return <Text variant="text-md/medium">
{highlightedNode.length ? highlightedNode : plugin.description}
</Text>;
return (
<Text variant="text-md/medium">
{highlightedNode.length ? highlightedNode : plugin.description}
</Text>
);
}
const Actions = () => {
const { plugin } = useCardContext();
const navigation = NavigationNative.useNavigation();
const { plugin } = useCardContext();
const navigation = NavigationNative.useNavigation();
return <View style={{ flexDirection: "row", gap: 6 }}>
<IconButton
size="sm"
variant="secondary"
icon={findAssetId("WrenchIcon")}
disabled={!plugin.getPluginSettingsComponent()}
onPress={() => navigation.push("PUPU_CUSTOM_PAGE", {
title: plugin.name,
render: plugin.getPluginSettingsComponent(),
})}
/>
<IconButton
size="sm"
variant="secondary"
icon={findAssetId("CircleInformationIcon-primary")}
onPress={() => void showSheet(
"PluginInfoActionSheet",
plugin.resolveSheetComponent(),
{ plugin, navigation }
)}
/>
</View>;
return (
<View style={{ flexDirection: "row", gap: 6 }}>
<IconButton
size="sm"
variant="secondary"
icon={findAssetId("WrenchIcon")}
disabled={!plugin.getPluginSettingsComponent()}
onPress={() =>
navigation.push("PUPU_CUSTOM_PAGE", {
title: plugin.name,
render: plugin.getPluginSettingsComponent(),
})
}
/>
<IconButton
size="sm"
variant="secondary"
icon={findAssetId("CircleInformationIcon-primary")}
onPress={() =>
void showSheet(
"PluginInfoActionSheet",
plugin.resolveSheetComponent(),
{ plugin, navigation },
)
}
/>
</View>
);
};
export default function PluginCard({ result, item: plugin }: CardWrapper<UnifiedPluginModel>) {
plugin.usePluginState();
export default function PluginCard({
result,
item: plugin,
}: CardWrapper<UnifiedPluginModel>) {
plugin.usePluginState();
const [, forceUpdate] = React.useReducer(() => ({}), 0);
const cardContextValue = useMemo(() => ({ plugin, result }), [plugin, result]);
const [, forceUpdate] = React.useReducer(() => ({}), 0);
const cardContextValue = useMemo(
() => ({ plugin, result }),
[plugin, result],
);
return (
<CardContext.Provider value={cardContextValue}>
<Card>
<Stack spacing={16}>
<View style={{ flexDirection: "row", justifyContent: "space-between" }}>
<View style={{ flexShrink: 1 }}>
<Title />
<Authors />
</View>
<View>
<Stack spacing={12} direction="horizontal">
<Actions />
<TableSwitch
value={plugin.isEnabled()}
onValueChange={(v: boolean) => {
plugin.toggle(v);
forceUpdate();
}}
/>
</Stack>
</View>
</View>
<Description />
</Stack>
</Card>
</CardContext.Provider>
);
return (
<CardContext.Provider value={cardContextValue}>
<Card>
<Stack spacing={16}>
<View
style={{ flexDirection: "row", justifyContent: "space-between" }}
>
<View style={{ flexShrink: 1 }}>
<Title />
<Authors />
</View>
<View>
<Stack spacing={12} direction="horizontal">
<Actions />
<TableSwitch
value={plugin.isEnabled()}
onValueChange={(v: boolean) => {
plugin.toggle(v);
forceUpdate();
}}
/>
</Stack>
</View>
</View>
<Description />
</Stack>
</Card>
</CardContext.Provider>
);
}

View file

@ -3,7 +3,7 @@ import AddonPage from "@core/ui/components/AddonPage";
import PluginCard from "@core/ui/settings/pages/Plugins/components/PluginCard";
import { VdPluginManager } from "@core/vendetta/plugins";
import { useProxy } from "@core/vendetta/storage";
import { isCorePlugin, isPluginInstalled, pluginSettings, registeredPlugins } from "@lib/addons/plugins";
import { isCorePlugin, isPluginInstalled, pluginSettings, registeredPlugins, corePluginInstances } from "@lib/addons/plugins";
import { Author } from "@lib/addons/types";
import { findAssetId } from "@lib/api/assets";
import { settings } from "@lib/api/settings";
@ -13,124 +13,203 @@ 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 { Button, 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 type { BunnyPluginManifest } from "@lib/addons/plugins/types";
import { UnifiedPluginModel } from "./models";
import unifyBunnyPlugin from "./models/bunny";
import unifyVdPlugin from "./models/vendetta";
const { openAlert } = lazyDestructure(() => findByProps("openAlert", "dismissAlert"));
const { AlertModal, AlertActions, AlertActionButton } = lazyDestructure(() => findByProps("AlertModal", "AlertActions"));
const { openAlert } = lazyDestructure(() =>
findByProps("openAlert", "dismissAlert"),
);
const { AlertModal, AlertActions, AlertActionButton } = lazyDestructure(() =>
findByProps("AlertModal", "AlertActions"),
);
interface PluginPageProps extends Partial<ComponentProps<typeof AddonPage<UnifiedPluginModel>>> {
useItems: () => unknown[];
interface PluginPageProps
extends Partial<ComponentProps<typeof AddonPage<UnifiedPluginModel>>> {
useItems: () => unknown[];
}
function PluginPage(props: PluginPageProps) {
const items = props.useItems();
const items = props.useItems();
return <AddonPage<UnifiedPluginModel>
CardComponent={PluginCard}
title={Strings.PLUGINS}
searchKeywords={[
"name",
"description",
p => p.authors?.map(
(a: Author | string) => typeof a === "string" ? a : a.name
).join() || ""
]}
sortOptions={{
"Name (A-Z)": (a, b) => a.name.localeCompare(b.name),
"Name (Z-A)": (a, b) => b.name.localeCompare(a.name)
}}
safeModeHint={{ message: Strings.SAFE_MODE_NOTICE_PLUGINS }}
items={items}
{...props}
/>;
return (
<AddonPage<UnifiedPluginModel>
CardComponent={PluginCard}
title={Strings.PLUGINS}
searchKeywords={[
"name",
"description",
(p) =>
p.authors
?.map((a: Author | string) => (typeof a === "string" ? a : a.name))
.join() || "",
]}
sortOptions={{
"Name (A-Z)": (a, b) => a.name.localeCompare(b.name),
"Name (Z-A)": (a, b) => b.name.localeCompare(a.name),
}}
safeModeHint={{ message: Strings.SAFE_MODE_NOTICE_PLUGINS }}
items={items}
{...props}
/>
);
}
export default function Plugins() {
useProxy(settings);
const navigation = NavigationNative.useNavigation();
useProxy(settings);
const navigation = NavigationNative.useNavigation();
return <PluginPage
useItems={() => {
useProxy(VdPluginManager.plugins);
useObservable([pluginSettings]);
return (
<PluginPage
useItems={() => {
useProxy(VdPluginManager.plugins);
useObservable([pluginSettings]);
const corePlugins = [...corePluginInstances.keys()]
.map((id) => registeredPlugins.get(id))
.filter((manifest): manifest is BunnyPluginManifest => manifest !== undefined)
.map(unifyBunnyPlugin);
const vdPlugins = Object.values(VdPluginManager.plugins).map(unifyVdPlugin);
const bnPlugins = [...registeredPlugins.values()].filter(p => isPluginInstalled(p.id) && !isCorePlugin(p.id)).map(unifyBunnyPlugin);
return [...vdPlugins, ...bnPlugins];
return [... corePlugins, ...vdPlugins, ...bnPlugins];
}}
ListHeaderComponent={() => {
const unproxiedPlugins = Object.values(VdPluginManager.plugins).filter(p => !p.id.startsWith(VD_PROXY_PREFIX) && !p.id.startsWith(BUNNY_PROXY_PREFIX));
if (!unproxiedPlugins.length) return null;
return <View style={{ marginVertical: 12, marginHorizontal: 10 }}>
<Card border="strong">
<View style={{ flex: 1, justifyContent: "center", alignItems: "center", flexDirection: "row" }}>
<View style={{ gap: 6, flexShrink: 1 }}>
<Text variant="heading-md/bold">Unproxied Plugins Found</Text>
<Text variant="text-sm/medium" color="text-muted">
Plugins installed from unproxied sources may run unverified code in this app without your awareness.
</Text>
</View>
<View style={{ marginLeft: "auto" }}>
<IconButton
size="sm"
variant="secondary"
icon={findAssetId("CircleInformationIcon-primary")}
style={{ marginLeft: 8 }}
onPress={() => {
navigation.push("PUPU_CUSTOM_PAGE", {
title: "Unproxied Plugins",
render: () => {
return <FlashList
data={unproxiedPlugins}
contentContainerStyle={{ padding: 8 }}
ItemSeparatorComponent={() => <View style={{ height: 8 }} />}
renderItem={({ item: p }: any) => <Card>
<Text variant="heading-md/semibold">{p.id}</Text>
</Card>}
/>;
}
});
}}
return (
<View style={{ marginVertical: 12, marginHorizontal: 10 }}>
<Card border="strong">
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
flexDirection: "row",
}}
>
<View style={{ gap: 6, flexShrink: 1 }}>
<Text variant="heading-md/bold">Unproxied Plugins Found</Text>
<Text variant="text-sm/medium" color="text-muted">
Plugins installed from unproxied sources may run unverified
code in this app without your awareness.
</Text>
</View>
<View style={{ marginLeft: "auto" }}>
<IconButton
size="sm"
variant="secondary"
icon={findAssetId("CircleInformationIcon-primary")}
style={{ marginLeft: 8 }}
onPress={() => {
navigation.push("PUPU_CUSTOM_PAGE", {
title: "Unproxied Plugins",
render: () => {
return (
<FlashList
data={unproxiedPlugins}
contentContainerStyle={{ padding: 8 }}
ItemSeparatorComponent={() => (
<View style={{ height: 8 }} />
)}
renderItem={({ item: p }: any) => (
<Card>
<Text variant="heading-md/semibold">
{p.id}
</Text>
</Card>
)}
/>
</View>
</View>
</Card>
</View>;
}}
installAction={{
label: "Install a plugin",
fetchFn: async (url: string) => {
if (!url.startsWith(VD_PROXY_PREFIX) && !url.startsWith(BUNNY_PROXY_PREFIX) && !settings.developerSettings) {
openAlert("bunny-plugin-unproxied-confirmation", <AlertModal
title="Hold On!"
content="You're trying to install a plugin from an unproxied external source. This means you're trusting the creator to run their code in this app without your knowledge. Are you sure you want to continue?"
extraContent={<Card><Text variant="text-md/bold">{url}</Text></Card>}
actions={<AlertActions>
<AlertActionButton text="Continue" variant="primary" onPress={() => {
VdPluginManager.installPlugin(url)
.then(() => showToast(Strings.TOASTS_INSTALLED_PLUGIN, findAssetId("Check")))
.catch(e => openAlert("bunny-plugin-install-failed", <AlertModal
title="Install Failed"
content={`Unable to install plugin from '${url}':`}
extraContent={<Card><Text variant="text-md/normal">{e instanceof Error ? e.message : String(e)}</Text></Card>}
actions={<AlertActionButton text="Okay" variant="primary" />}
/>));
}} />
<AlertActionButton text="Cancel" variant="secondary" />
</AlertActions>}
/>);
} else {
return await VdPluginManager.installPlugin(url);
);
},
});
}}
/>
</View>
</View>
</Card>
</View>
);
}}
installAction={{
label: "Install a plugin",
fetchFn: async (url: string) => {
if (
!url.startsWith(VD_PROXY_PREFIX) &&
!url.startsWith(BUNNY_PROXY_PREFIX) &&
!settings.developerSettings
) {
openAlert(
"bunny-plugin-unproxied-confirmation",
<AlertModal
title="Hold On!"
content="You're trying to install a plugin from an unproxied external source. This means you're trusting the creator to run their code in this app without your knowledge. Are you sure you want to continue?"
extraContent={
<Card>
<Text variant="text-md/bold">{url}</Text>
</Card>
}
}
}}
/>;
actions={
<AlertActions>
<AlertActionButton
text="Continue"
variant="primary"
onPress={() => {
VdPluginManager.installPlugin(url)
.then(() =>
showToast(
Strings.TOASTS_INSTALLED_PLUGIN,
findAssetId("Check"),
),
)
.catch((e) =>
openAlert(
"bunny-plugin-install-failed",
<AlertModal
title="Install Failed"
content={`Unable to install plugin from '${url}':`}
extraContent={
<Card>
<Text variant="text-md/normal">
{e instanceof Error
? e.message
: String(e)}
</Text>
</Card>
}
actions={
<AlertActionButton
text="Okay"
variant="primary"
/>
}
/>,
),
);
}}
/>
<AlertActionButton text="Cancel" variant="secondary" />
</AlertActions>
}
/>,
);
} else {
return await VdPluginManager.installPlugin(url);
}
},
}}
/>
);
}

View file

@ -1,43 +1,52 @@
import { PupuIcon } from "@core/ui/settings";
import { disablePlugin, enablePlugin, getPluginSettingsComponent, isPluginEnabled, pluginSettings } from "@lib/addons/plugins";
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 ".";
export default function unifyBunnyPlugin(manifest: BunnyPluginManifest): UnifiedPluginModel {
return {
id: manifest.id,
name: manifest.display.name,
description: manifest.display.description,
authors: manifest.display.authors,
export default function unifyBunnyPlugin(
manifest: BunnyPluginManifest,
): UnifiedPluginModel {
return {
id: manifest.id,
name: manifest.display.name,
description: manifest.display.description,
authors: manifest.display.authors,
getBadges() {
return [
{ source: { uri: PupuIcon } },
// { source: findAssetId("CheckmarkLargeBoldIcon")! }
];
},
isEnabled: () => isPluginEnabled(manifest.id),
isInstalled: () => manifest.id in pluginSettings,
usePluginState() {
useObservable([pluginSettings]);
},
toggle(start: boolean) {
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(manifest.id);
},
};
getBadges() {
return [
{ source: { uri: PupuIcon } },
// { source: findAssetId("CheckmarkLargeBoldIcon")! }
];
},
isEnabled: () => isPluginEnabled(manifest.id),
isInstalled: () => manifest.id in pluginSettings,
usePluginState() {
useObservable([pluginSettings]);
},
toggle(start: boolean) {
try {
start ? enablePlugin(manifest.id, true) : disablePlugin(manifest.id);
} catch (e) {
console.error(e);
// showToast("Failed to toggle plugin " + e, findAssetId("Small"));
}
},
resolveSheetComponent() {
// Return a Promise resolving to the sheet component for interface consistency
return Promise.resolve({
default: require("../sheets/PluginInfoActionSheet").default,
});
},
getPluginSettingsComponent() {
return getPluginSettingsComponent(manifest.id);
},
};
}

View file

@ -3,32 +3,38 @@ import { useProxy } from "@core/vendetta/storage";
import { UnifiedPluginModel } from ".";
export default function unifyVdPlugin(vdPlugin: VendettaPlugin): UnifiedPluginModel {
return {
id: vdPlugin.id,
name: vdPlugin.manifest.name,
description: vdPlugin.manifest.description,
authors: vdPlugin.manifest.authors,
icon: vdPlugin.manifest.vendetta?.icon,
export default function unifyVdPlugin(
vdPlugin: VendettaPlugin,
): UnifiedPluginModel {
return {
id: vdPlugin.id,
name: vdPlugin.manifest.name,
description: vdPlugin.manifest.description,
authors: vdPlugin.manifest.authors,
icon: vdPlugin.manifest.vendetta?.icon,
getBadges() {
return [];
},
isEnabled: () => vdPlugin.enabled,
isInstalled: () => Boolean(vdPlugin && VdPluginManager.plugins[vdPlugin.id]),
usePluginState() {
useProxy(VdPluginManager.plugins[vdPlugin.id]);
},
toggle(start: boolean) {
start
? VdPluginManager.startPlugin(vdPlugin.id)
: VdPluginManager.stopPlugin(vdPlugin.id);
},
resolveSheetComponent() {
return import("../sheets/VdPluginInfoActionSheet");
},
getPluginSettingsComponent() {
return VdPluginManager.getSettings(vdPlugin.id);
},
};
getBadges() {
return [];
},
isEnabled: () => vdPlugin.enabled,
isInstalled: () =>
Boolean(vdPlugin && VdPluginManager.plugins[vdPlugin.id]),
usePluginState() {
useProxy(VdPluginManager.plugins[vdPlugin.id]);
},
toggle(start: boolean) {
start
? VdPluginManager.startPlugin(vdPlugin.id)
: VdPluginManager.stopPlugin(vdPlugin.id);
},
resolveSheetComponent() {
// Return a Promise resolving to the unified PluginInfoActionSheet for consistency
return Promise.resolve({
default: require("../sheets/PluginInfoActionSheet").default,
});
},
getPluginSettingsComponent() {
return VdPluginManager.getSettings(vdPlugin.id);
},
};
}

View file

@ -1,106 +1,211 @@
import { startPlugin } from "@lib/addons/plugins";
import { findAssetId } from "@lib/api/assets";
import { hideSheet } from "@lib/ui/sheets";
import { ActionSheet, Card, ContextMenu, IconButton, Text } from "@metro/common/components";
import { ComponentProps } from "react";
import { ActionSheet, Card, IconButton, Text } from "@metro/common/components";
import { clipboard } from "@metro/common";
import { showToast } from "@lib/ui/toasts";
import { showConfirmationAlert } from "@core/vendetta/alerts";
import { VdPluginManager } from "@core/vendetta/plugins";
import { purgeStorage as purgeVdStorage } from "@core/vendetta/storage";
import { purgeStorage } from "@lib/api/storage";
import { findAssetId } from "@lib/api/assets";
import { useState } from "react";
import { ScrollView, View } from "react-native";
import { PluginInfoActionSheetProps } from "./common";
import TitleComponent from "./TitleComponent";
import { PluginInfoActionSheetProps } from "./common";
function PluginInfoIconButton(props: ComponentProps<typeof IconButton>) {
const { onPress } = props;
props.onPress &&= () => {
function PluginInfoIconButton(props) {
const { onPress } = props;
props.onPress &&= () => {
hideSheet("PluginInfoActionSheet");
onPress?.();
};
return <IconButton {...props} />;
}
export default function PluginInfoActionSheet({
plugin,
navigation,
}: PluginInfoActionSheetProps) {
plugin.usePluginState();
const [loading, setLoading] = useState(false);
// Determine plugin type
const isVendettaPlugin = plugin.id.includes("/");
const isCorePlugin =
plugin.id.startsWith("bunny.") || plugin.id.startsWith("vendetta.");
const copyPluginUrl = () => {
let url = plugin.id;
if (isVendettaPlugin) {
url = plugin.id;
} else {
try {
const pluginAny = plugin;
const repoUrl =
pluginAny._manifest?.parentRepository ||
pluginAny.manifest?.parentRepository;
url = repoUrl ? `${repoUrl}/builds/${plugin.id}` : plugin.id;
} catch (e) {
url = plugin.id;
}
}
clipboard.setString(url);
showToast("Copied to clipboard!", findAssetId("toast_copy_link"));
};
const refetchPlugin = async () => {
setLoading(true);
try {
if (isVendettaPlugin) {
const vdPlugin = VdPluginManager.plugins[plugin.id];
if (vdPlugin.enabled) VdPluginManager.stopPlugin(plugin.id, false);
await VdPluginManager.fetchPlugin(plugin.id);
if (vdPlugin.enabled) await VdPluginManager.startPlugin(plugin.id);
showToast("Plugin refreshed successfully");
} else {
// For Bunny plugins
// If you have a refreshPlugin function, call it here
showToast("Plugin refreshed successfully");
}
} catch (e) {
showToast("Failed to refresh plugin");
} finally {
setLoading(false);
}
};
const clearPluginData = () => {
showConfirmationAlert({
title: "Clear Data",
content:
"Are you sure you want to clear all data for this plugin? This action cannot be undone.",
confirmText: "Clear",
confirmColor: "red",
cancelText: "Cancel",
onConfirm: async () => {
hideSheet("PluginInfoActionSheet");
onPress?.();
};
try {
if (isVendettaPlugin) {
const vdPlugin = VdPluginManager.plugins[plugin.id];
if (vdPlugin.enabled) VdPluginManager.stopPlugin(plugin.id, false);
await purgeVdStorage(plugin.id);
if (vdPlugin.enabled) await VdPluginManager.startPlugin(plugin.id);
} else {
await purgeStorage(`plugins/storage/${plugin.id}.json`);
}
showToast("Plugin data cleared successfully");
} catch (e) {
showToast("Failed to clear plugin data");
}
},
});
};
return <IconButton {...props} />;
}
export default function PluginInfoActionSheet({ plugin, navigation }: PluginInfoActionSheetProps) {
plugin.usePluginState();
return <ActionSheet>
<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"
size="sm"
/>}
</ContextMenu>
</View>
<View style={{ flexDirection: "row", justifyContent: "space-around", alignContent: "center" }}>
<PluginInfoIconButton
label="Configure"
variant="secondary"
disabled={!plugin.getPluginSettingsComponent()}
icon={findAssetId("WrenchIcon")}
onPress={() => {
navigation.push("PUPU_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>
<Text variant="text-md/medium">
{plugin.description}
</Text>
</Card>
</ScrollView>
</ActionSheet>;
const uninstallPluginHandler = () => {
if (isCorePlugin) {
showToast("Core plugins cannot be uninstalled");
return;
}
showConfirmationAlert({
title: "Uninstall Plugin",
content:
"Are you sure you want to uninstall this plugin? This action cannot be undone.",
confirmText: "Uninstall",
confirmColor: "red",
cancelText: "Cancel",
onConfirm: async () => {
hideSheet("PluginInfoActionSheet");
try {
if (isVendettaPlugin) {
await VdPluginManager.removePlugin(plugin.id);
} else {
// If you have an uninstallPlugin function, call it here
}
showToast("Plugin uninstalled successfully");
} catch (e) {
showToast(
`Failed to uninstall plugin: ${e instanceof Error ? e.message : String(e)}`,
);
}
},
});
};
return (
<ActionSheet>
<ScrollView contentContainerStyle={{ gap: 12, marginBottom: 12 }}>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingVertical: 24,
justifyContent: "space-between",
width: "100%",
}}
>
<TitleComponent plugin={plugin} />
</View>
<View
style={{
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
flexWrap: "wrap",
gap: 12,
paddingHorizontal: 4,
}}
>
<PluginInfoIconButton
label="Configure"
variant="secondary"
disabled={!plugin.getPluginSettingsComponent()}
icon={findAssetId("WrenchIcon")}
onPress={() => {
navigation.push("PUPU_CUSTOM_PAGE", {
title: plugin.name,
render: plugin.getPluginSettingsComponent(),
});
}}
/>
<PluginInfoIconButton
label="Refetch"
variant="secondary"
icon={findAssetId("RetryIcon")}
onPress={refetchPlugin}
disabled={loading}
/>
<PluginInfoIconButton
label="Copy URL"
variant="secondary"
icon={findAssetId("LinkIcon")}
onPress={copyPluginUrl}
/>
<PluginInfoIconButton
label="Clear Data"
variant="secondary"
icon={findAssetId("FileIcon")}
onPress={clearPluginData}
/>
{!isCorePlugin && (
<PluginInfoIconButton
label="Uninstall"
variant="secondary"
icon={findAssetId("TrashIcon")}
onPress={uninstallPluginHandler}
/>
)}
</View>
<Card>
<Text
variant="text-md/semibold"
color="text-primary"
style={{ marginBottom: 4 }}
>
Description
</Text>
<Text variant="text-md/medium">{plugin.description}</Text>
</Card>
</ScrollView>
</ActionSheet>
);
}

View file

@ -2,80 +2,141 @@ 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 { getCurrentTheme, installTheme, themes, VdThemeInfo } 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 { useObservable } from "@lib/api/storage";
import { ActionSheet, BottomSheetTitleHeader, Button, TableRadioGroup, TableRadioRow, TableRowIcon } from "@metro/common/components";
import {
ActionSheet,
BottomSheetTitleHeader,
Button,
TableRowGroup,
TableSwitchRow,
TableRowIcon,
} from "@metro/common/components";
import { View } from "react-native";
/**
* Theme options have been changed from radio groups to individual switches
* for better UX. Each option can now be toggled independently.
*/
export default function Themes() {
useProxy(settings);
useProxy(themes);
useProxy(settings);
useProxy(themes);
return (
<AddonPage<VdThemeInfo>
title={Strings.THEMES}
searchKeywords={[
"data.name",
"data.description",
p => p.data.authors?.map((a: Author) => a.name).join(", ") ?? ""
]}
sortOptions={{
"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",
fetchFn: installTheme
}}
items={Object.values(themes)}
safeModeHint={{
message: formatString("SAFE_MODE_NOTICE_THEMES", { enabled: Boolean(settings.safeMode?.currentThemeId) }),
footer: settings.safeMode?.currentThemeId && <Button
size="small"
text={Strings.DISABLE_THEME}
onPress={() => delete settings.safeMode?.currentThemeId}
style={{ marginTop: 8 }}
return (
<AddonPage<VdThemeInfo>
title={Strings.THEMES}
searchKeywords={[
"data.name",
"data.description",
(p) => p.data.authors?.map((a: Author) => a.name).join(", ") ?? "",
]}
sortOptions={{
"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",
fetchFn: installTheme,
}}
items={Object.values(themes)}
safeModeHint={{
message: formatString("SAFE_MODE_NOTICE_THEMES", {
enabled: Boolean(settings.safeMode?.currentThemeId),
}),
footer: settings.safeMode?.currentThemeId && (
<Button
size="small"
text={Strings.DISABLE_THEME}
onPress={() => delete settings.safeMode?.currentThemeId}
style={{ marginTop: 8 }}
/>
),
}}
CardComponent={ThemeCard}
OptionsActionSheetComponent={() => {
useObservable([colorsPref]);
return (
<ActionSheet>
<BottomSheetTitleHeader title="Options" />
<View style={{ paddingVertical: 20, gap: 12 }}>
{/* Changed from TableRadioGroup to individual TableSwitchRow components
for better UX - users can now toggle options individually */}
<TableRowGroup title="Override Theme Type">
<TableSwitchRow
label="Auto"
icon={<TableRowIcon source={findAssetId("RobotIcon")} />}
value={!colorsPref.type}
onValueChange={(enabled: boolean) => {
if (enabled) {
colorsPref.type = undefined;
} else {
colorsPref.type = "dark";
}
getCurrentTheme()?.data &&
updateBunnyColor(getCurrentTheme()!.data!, {
update: true,
});
}}
/>
}}
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>;
}}
/>
);
<TableSwitchRow
label="Dark"
icon={<TableRowIcon source={findAssetId("ThemeDarkIcon")} />}
value={colorsPref.type === "dark"}
onValueChange={(enabled: boolean) => {
colorsPref.type = enabled ? "dark" : undefined;
getCurrentTheme()?.data &&
updateBunnyColor(getCurrentTheme()!.data!, {
update: true,
});
}}
/>
<TableSwitchRow
label="Light"
icon={<TableRowIcon source={findAssetId("ThemeLightIcon")} />}
value={colorsPref.type === "light"}
onValueChange={(enabled: boolean) => {
colorsPref.type = enabled ? "light" : undefined;
getCurrentTheme()?.data &&
updateBunnyColor(getCurrentTheme()!.data!, {
update: true,
});
}}
/>
</TableRowGroup>
<TableRowGroup title="Chat Background">
<TableSwitchRow
label="Show Background"
icon={<TableRowIcon source={findAssetId("ImageIcon")} />}
value={!colorsPref.customBackground}
onValueChange={(enabled: boolean) => {
colorsPref.customBackground = enabled ? null : "hidden";
}}
/>
<TableSwitchRow
label="Hide Background"
icon={<TableRowIcon source={findAssetId("DenyIcon")} />}
value={colorsPref.customBackground === "hidden"}
onValueChange={(enabled: boolean) => {
colorsPref.customBackground = enabled ? "hidden" : null;
}}
/>
</TableRowGroup>
</View>
</ActionSheet>
);
}}
/>
);
}

View file

@ -5,7 +5,7 @@ const { instead } = require("spitroast");
// @ts-ignore - window is defined later in the bundle, so we assign it early
globalThis.window = globalThis;
async function initializeRevenge() {
async function initializeKettu() {
try {
// Make 'freeze' and 'seal' do nothing
Object.freeze = Object.seal = Object;
@ -31,7 +31,6 @@ if (typeof window.__r === "undefined") {
var _requireFunc: any;
// Calls from the native side are deferred until the index.ts(x) is loaded
// Revenge delays the execution of index.ts(x) because Revenge's initialization is asynchronous
interface DeferredQueue {
object: any;
method: string;
@ -102,7 +101,7 @@ if (typeof window.__r === "undefined") {
}
const startDiscord = async () => {
await initializeRevenge();
await initializeKettu();
for (const unpatch of unpatches) unpatch();
unpatches.length = 0;
@ -143,7 +142,5 @@ if (typeof window.__r === "undefined") {
}
});
} else {
// It is too late to late to hook __r, so we just initialize Revenge here
// Likely because of using the legacy loader (from Vendetta)
initializeRevenge();
initializeKettu();
}

View file

@ -1,6 +1,6 @@
import { StatusBar } from "react-native";
import { findByStoreNameLazy } from "@metro/wrappers";
import { _colorRef } from "./updater"; // <-- import your colorRef
import { _colorRef } from "./updater";
const ThemeStore = findByStoreNameLazy("ThemeStore");

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("icon-search")!} />;
}
export default ({ onChangeText, placeholder, style, isRound }: SearchProps) => {

View file

@ -1,4 +1,5 @@
export const DISCORD_SERVER = "https://discord.gg/6cN7wKa8gp";
export const CODEBERG = "https://codeberg.org/cocobo1/Kettu";
export const GITHUB = "https://github.com/C0C0B01/Kettu";
export const HTTP_REGEX = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&/=]*)$/;
export const HTTP_REGEX_MULTI = /https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;
@ -6,7 +7,8 @@ export const HTTP_REGEX_MULTI = /https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,25
export const BUNNY_PROXY_PREFIX = "https://bn-plugins.github.io/vd-proxy";
export const NEXPID_PLUGINS_REPO_URL = "https://revenge.nexpid.xyz";
export const OFFICIAL_PLUGINS_REPO_URL = "https://bn-plugins.github.io/dist/repo.json";
//export const OFFICIAL_PLUGINS_REPO_URL = "https://bn-plugins.github.io/dist/repo.json";
export const OFFICIAL_PLUGINS_REPO_URL = "https://raw.githubusercontent.com/Purple-EyeZ/Plugins-List/refs/heads/main/src/plugins-data.json";
export const VD_PROXY_PREFIX = "https://vd-plugins.github.io/proxy";
export const VD_DISCORD_SERVER_ID = "1015931589865246730";

View file

@ -20,7 +20,6 @@ export const CompatButton = findByPropsLazy("Looks", "Colors", "Sizes");
export const HelpMessage = findByNameLazy("HelpMessage");
// React Native's included SafeAreaView only adds padding on iOS.
// The project doesnt build without it so it kinda has to sit here
export const { SafeAreaView, SafeAreaProvider, useSafeAreaInsets } = lazyDestructure(() => findByProps("useSafeAreaInsets"));
// ActionSheet

View file

@ -1,4 +1,3 @@
import { FilterCheckDef, FilterDefinition, ModuleExports } from "./types";
export function createFilterDefinition<A extends unknown[]>(