diff --git a/.forgejo/ISSUE_TEMPLATE/feature-request.yml b/.forgejo/ISSUE_TEMPLATE/feature-request.yml deleted file mode 100644 index bacf7ed..0000000 --- a/.forgejo/ISSUE_TEMPLATE/feature-request.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Feature request -description: Suggest an idea for Kettu -title: "[feature]: " -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 diff --git a/assets/logo/logo-dark.svg b/assets/logo/logo-dark.svg deleted file mode 100644 index cb34519..0000000 --- a/assets/logo/logo-dark.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/logo/logo-light.svg b/assets/logo/logo-light.svg deleted file mode 100644 index b16e22f..0000000 --- a/assets/logo/logo-light.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/wordmark/wordmark+slogan-dark.svg b/assets/wordmark/wordmark+slogan-dark.svg deleted file mode 100644 index 2f3c148..0000000 --- a/assets/wordmark/wordmark+slogan-dark.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/assets/wordmark/wordmark+slogan-light.svg b/assets/wordmark/wordmark+slogan-light.svg deleted file mode 100644 index eefd512..0000000 --- a/assets/wordmark/wordmark+slogan-light.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/docs/2_building.md b/docs/2_building.md deleted file mode 100644 index 9405fa7..0000000 --- a/docs/2_building.md +++ /dev/null @@ -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://: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. diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index ac3cc11..0000000 --- a/docs/README.md +++ /dev/null @@ -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) \ No newline at end of file diff --git a/scripts/build.mjs b/scripts/build.mjs index fea5c91..75e3ffb 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -74,7 +74,7 @@ const config = { constModules: { globals: { "bunny-build-info": { - version: `"1.2.0"` + version: `"1.3.0"` } } }, diff --git a/src/assets/icons/codeberg-logo_icon_white.png b/src/assets/icons/codeberg-logo_icon_white.png new file mode 100644 index 0000000..873d3c7 Binary files /dev/null and b/src/assets/icons/codeberg-logo_icon_white.png differ diff --git a/src/assets/icons/revenge.png b/src/assets/icons/revenge.png deleted file mode 100644 index f9032bf..0000000 Binary files a/src/assets/icons/revenge.png and /dev/null differ diff --git a/src/core/commands/debug.ts b/src/core/commands/debug.ts index 2cf12df..b1975c6 100644 --- a/src/core/commands/debug.ts +++ b/src/core/commands/debug.ts @@ -28,7 +28,8 @@ export default () => { 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}); } } }; diff --git a/src/core/commands/plugins.ts b/src/core/commands/plugins.ts index b4ba1d8..8933713 100644 --- a/src/core/commands/plugins.ts +++ b/src/core/commands/plugins.ts @@ -36,7 +36,8 @@ export default () => { 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}); } } }; diff --git a/src/core/i18n/default.json b/src/core/i18n/default.json index 33ea5fb..afd4443 100644 --- a/src/core/i18n/default.json +++ b/src/core/i18n/default.json @@ -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" } \ No newline at end of file diff --git a/src/core/plugins/badges/index.tsx b/src/core/plugins/badges/index.tsx index c8d7d10..79baa15 100644 --- a/src/core/plugins/badges/index.tsx +++ b/src/core/plugins/badges/index.tsx @@ -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; - const badgeCache = {} as Record; - - 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; + + 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(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([]); + + 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: " _", }); }); } }); } -}); +}); \ No newline at end of file diff --git a/src/core/plugins/index.ts b/src/core/plugins/index.ts index e270de7..5bc2f76 100644 --- a/src/core/plugins/index.ts +++ b/src/core/plugins/index.ts @@ -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 => ({ - "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; } diff --git a/src/core/plugins/messagefix/index.ts b/src/core/plugins/messagefix/index.ts new file mode 100644 index 0000000..146bf2b --- /dev/null +++ b/src/core/plugins/messagefix/index.ts @@ -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"); + }, +}); diff --git a/src/core/plugins/notrack/index.ts b/src/core/plugins/notrack/index.ts new file mode 100644 index 0000000..8459303 --- /dev/null +++ b/src/core/plugins/notrack/index.ts @@ -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): 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; + + const sentrified: ConsoleFunctions = {}; + + try { + // We need to use Object.keys because TypeScript doesn't allow indexing Console with strings + (Object.keys(console) as Array).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"); + }, +}); diff --git a/src/core/plugins/quickinstall/index.ts b/src/core/plugins/quickinstall/index.ts index 51f5e94..d8f25da 100644 --- a/src/core/plugins/quickinstall/index.ts +++ b/src/core/plugins/quickinstall/index.ts @@ -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()]; diff --git a/src/core/ui/components/AddonPage.tsx b/src/core/ui/components/AddonPage.tsx index c802d6f..8d624a5 100644 --- a/src/core/ui/components/AddonPage.tsx +++ b/src/core/ui/components/AddonPage.tsx @@ -175,7 +175,7 @@ export default function AddonPage({ CardComponent, ...props }: setSearch(v)} /> {props.sortOptions && showSimpleActionSheet({ diff --git a/src/core/ui/reporter/components/ErrorBoundaryScreen.tsx b/src/core/ui/reporter/components/ErrorBoundaryScreen.tsx index bf4824e..29b5213 100644 --- a/src/core/ui/reporter/components/ErrorBoundaryScreen.tsx +++ b/src/core/ui/reporter/components/ErrorBoundaryScreen.tsx @@ -33,7 +33,7 @@ export default function ErrorBoundaryScreen(props: { Uh oh. - A crash occurred while rendering a component. This could be caused by a plugin, Revenge, or Discord itself. + A crash occurred while rendering a component. This could be caused by a plugin, Kettu, or Discord itself. {debugInfo.os.name}; {debugInfo.discord.build} ({debugInfo.discord.version}); {debugInfo.bunny.version} diff --git a/src/core/ui/settings/index.ts b/src/core/ui/settings/index.ts index 1b76226..a671937 100644 --- a/src/core/ui/settings/index.ts +++ b/src/core/ui/settings/index.ts @@ -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: [] + }); } \ No newline at end of file diff --git a/src/core/ui/settings/pages/Developer/index.tsx b/src/core/ui/settings/pages/Developer/index.tsx index bb710ae..6f66a81 100644 --- a/src/core/ui/settings/pages/Developer/index.tsx +++ b/src/core/ui/settings/pages/Developer/index.tsx @@ -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={} + icon={} onPress={() => { openAlert("pupu-clear-bundle-reload-confirmation", } onPress={() => Linking.openURL(DISCORD_SERVER)} /> + } + onPress={() => Linking.openURL(CODEBERG)} + /> 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({ - 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 | 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 | null | undefined) => { const s = new Set(prev); s.delete(normId); return s; }); + setRefreshTick((t: number) => t + 1); } - }); + }; - return