├── .github
├── ISSUE_TEMPLATE
│ ├── bug-report.yml
│ ├── config.yml
│ └── feature-request.yml
├── config.yml
└── workflows
│ └── release.yml
├── .gitignore
├── .releaserc.js
├── .vscode
└── settings.json
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── assets
├── logo
│ ├── logo-dark.svg
│ └── logo-light.svg
└── wordmark
│ ├── wordmark+slogan-dark.svg
│ └── wordmark+slogan-light.svg
├── docs
├── 2_building.md
└── README.md
├── eslint.config.mjs
├── package.json
├── pnpm-lock.yaml
├── scripts
├── adb.mjs
├── build.mjs
├── serve.mjs
└── util.mjs
├── shims
├── asyncIteratorSymbol.js
├── depsModule.ts
├── emptyModule.ts
├── jsxRuntime.ts
└── promiseAllSettled.js
├── src
├── assets
│ └── icons
│ │ └── revenge.png
├── core
│ ├── commands
│ │ ├── debug.ts
│ │ ├── eval.ts
│ │ └── plugins.ts
│ ├── debug
│ │ ├── patches
│ │ │ └── patchErrorBoundary.tsx
│ │ └── safeMode.ts
│ ├── fixes.ts
│ ├── i18n
│ │ ├── default.json
│ │ └── index.ts
│ ├── plugins
│ │ ├── badges
│ │ │ └── index.tsx
│ │ ├── index.ts
│ │ └── quickinstall
│ │ │ ├── forumPost.tsx
│ │ │ ├── index.ts
│ │ │ └── url.tsx
│ ├── ui
│ │ ├── components
│ │ │ ├── AddonCard.tsx
│ │ │ └── AddonPage.tsx
│ │ ├── hooks
│ │ │ └── useFS.ts
│ │ ├── reporter
│ │ │ ├── components
│ │ │ │ ├── ErrorBoundaryScreen.tsx
│ │ │ │ ├── ErrorCard.tsx
│ │ │ │ ├── ErrorComponentStackCard.tsx
│ │ │ │ ├── ErrorDetailsActionSheet.tsx
│ │ │ │ └── ErrorStackCard.tsx
│ │ │ └── utils
│ │ │ │ ├── isStack.tsx
│ │ │ │ ├── parseComponentStack.tsx
│ │ │ │ └── parseErrorStack.ts
│ │ └── settings
│ │ │ ├── index.ts
│ │ │ └── pages
│ │ │ ├── Developer
│ │ │ ├── AssetBrowser.tsx
│ │ │ ├── AssetDisplay.tsx
│ │ │ └── index.tsx
│ │ │ ├── Fonts
│ │ │ ├── FontCard.tsx
│ │ │ ├── FontEditor.tsx
│ │ │ └── index.tsx
│ │ │ ├── General
│ │ │ ├── About.tsx
│ │ │ ├── Version.tsx
│ │ │ └── index.tsx
│ │ │ ├── PluginBrowser
│ │ │ └── index.tsx
│ │ │ ├── Plugins
│ │ │ ├── components
│ │ │ │ └── PluginCard.tsx
│ │ │ ├── index.tsx
│ │ │ ├── models
│ │ │ │ ├── bunny.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── vendetta.ts
│ │ │ ├── sheets
│ │ │ │ ├── PluginInfoActionSheet.tsx
│ │ │ │ ├── TitleComponent.tsx
│ │ │ │ ├── VdPluginInfoActionSheet.tsx
│ │ │ │ └── common.ts
│ │ │ └── usePluginCardStyles.ts
│ │ │ └── Themes
│ │ │ ├── ThemeCard.tsx
│ │ │ └── index.tsx
│ └── vendetta
│ │ ├── Emitter.ts
│ │ ├── alerts.ts
│ │ ├── api.tsx
│ │ ├── plugins.ts
│ │ └── storage.ts
├── entry.ts
├── global.d.ts
├── index.ts
├── lib
│ ├── addons
│ │ ├── fonts
│ │ │ └── index.ts
│ │ ├── plugins
│ │ │ ├── api.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── themes
│ │ │ ├── colors
│ │ │ │ ├── index.ts
│ │ │ │ ├── parser.ts
│ │ │ │ ├── patches
│ │ │ │ │ ├── background.tsx
│ │ │ │ │ ├── resolver.ts
│ │ │ │ │ └── storage.ts
│ │ │ │ ├── preferences.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── updater.ts
│ │ │ └── index.ts
│ │ └── types.ts
│ ├── api
│ │ ├── assets
│ │ │ ├── index.ts
│ │ │ └── patches.ts
│ │ ├── commands
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── debug.ts
│ │ ├── flux
│ │ │ └── index.ts
│ │ ├── index.ts
│ │ ├── native
│ │ │ ├── fs.ts
│ │ │ ├── index.ts
│ │ │ ├── loader.ts
│ │ │ └── modules
│ │ │ │ ├── index.ts
│ │ │ │ └── types.ts
│ │ ├── patcher.ts
│ │ ├── react
│ │ │ ├── index.ts
│ │ │ └── jsx.ts
│ │ ├── settings.ts
│ │ └── storage
│ │ │ └── index.ts
│ ├── index.ts
│ ├── ui
│ │ ├── alerts.ts
│ │ ├── color.ts
│ │ ├── components
│ │ │ ├── Codeblock.tsx
│ │ │ ├── ErrorBoundary.tsx
│ │ │ ├── InputAlert.tsx
│ │ │ ├── Search.tsx
│ │ │ ├── Summary.tsx
│ │ │ ├── index.ts
│ │ │ └── wrappers
│ │ │ │ ├── AlertModal.tsx
│ │ │ │ └── index.ts
│ │ ├── index.ts
│ │ ├── settings
│ │ │ ├── index.tsx
│ │ │ └── patches
│ │ │ │ ├── shared.tsx
│ │ │ │ └── tabs.tsx
│ │ ├── sheets.ts
│ │ ├── styles.ts
│ │ ├── toasts.ts
│ │ └── types.ts
│ └── utils
│ │ ├── constants.ts
│ │ ├── cyrb64.ts
│ │ ├── findInReactTree.ts
│ │ ├── findInTree.ts
│ │ ├── hookDefineProperty.ts
│ │ ├── index.ts
│ │ ├── invariant.ts
│ │ ├── isValidHttpUrl.ts
│ │ ├── lazy.ts
│ │ ├── logger.ts
│ │ ├── safeFetch.ts
│ │ └── types.ts
├── metro
│ ├── common
│ │ ├── components.ts
│ │ ├── index.ts
│ │ ├── stores.ts
│ │ └── types
│ │ │ ├── components.ts
│ │ │ └── flux.ts
│ ├── factories.ts
│ ├── filters.ts
│ ├── finders.ts
│ ├── index.ts
│ ├── internals
│ │ ├── caches.ts
│ │ ├── enums.ts
│ │ └── modules.ts
│ ├── lazy.ts
│ ├── polyfills
│ │ └── redesign.ts
│ ├── types.ts
│ └── wrappers.ts
└── modules.d.ts
└── tsconfig.json
/.github/ISSUE_TEMPLATE/bug-report.yml:
--------------------------------------------------------------------------------
1 | name: 🐞 Bug report
2 | description: Report a bug or an issue.
3 | title: 'bug: '
4 | labels: ['Bug report']
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | # Revenge bug report
10 |
11 | Before creating a new bug report, please keep the following in mind:
12 |
13 | - **Do not submit a duplicate bug report**: Search for existing bug reports [here](https://github.com/revenge-mod/revenge-bundle/issues?q=label%3A%22Bug+report%22).
14 | - **Review the contribution guidelines**: Make sure your bug report adheres to it. You can find the guidelines [here](https://github.com/revenge-mod/revenge-bundle/blob/main/CONTRIBUTING.md).
15 | - **Do not use the issue page for support**: If you need help or have questions, join us on [Discord](https://discord.gg/ddcQf3s2Uq).
16 | - type: textarea
17 | attributes:
18 | label: Bug description
19 | description: |
20 | - Describe your bug in detail
21 | - Add steps to reproduce the bug if possible (Step 1. ... Step 2. ...)
22 | - Add images and videos if possible
23 | validations:
24 | required: true
25 | - type: textarea
26 | attributes:
27 | label: Stack trace
28 | description: If this bug causes a JS crash, please paste the stack trace here.
29 | render: shell
30 | - type: textarea
31 | attributes:
32 | label: Component stack trace
33 | description: If this bug causes a JS crash, please paste the component stack trace here.
34 | render: shell
35 | - type: textarea
36 | attributes:
37 | label: Native crash trace
38 | description: If this bug causes a native crash, please paste the crash trace here. On Android, this can be accessed by doing `logcat | grep AndroidRuntime`.
39 | render: shell
40 | - type: textarea
41 | attributes:
42 | label: Solution
43 | description: If applicable, add a possible solution to the bug.
44 | - type: textarea
45 | attributes:
46 | label: Additional context
47 | description: Add additional context here.
48 | - type: checkboxes
49 | id: acknowledgements
50 | attributes:
51 | label: Acknowledgements
52 | description: Your bug report will be closed if you don't follow the checklist below.
53 | options:
54 | - label: I have checked all open and closed bug reports and this is not a duplicate.
55 | required: true
56 | - label: I have chosen an appropriate title.
57 | required: true
58 | - label: All requested information has been provided properly.
59 | required: true
60 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: 🗨 Discussions
4 | url: https://discord.gg/ddcQf3s2Uq
5 | about: Have something unspecific to Revenge in mind? Join us on Discord!
6 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature-request.yml:
--------------------------------------------------------------------------------
1 | name: ⭐ Feature request
2 | description: Create a detailed request for a new feature.
3 | title: 'feat: '
4 | labels: ['Feature request']
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | # Revenge feature request
10 |
11 | Before creating a new feature request, please keep the following in mind:
12 |
13 | - **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).
14 | - **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).
15 | - **Do not use the issue page for support**: If you need help or have questions, join us on [Discord](https://discord.gg/ddcQf3s2Uq).
16 | - type: textarea
17 | attributes:
18 | label: Feature description
19 | description: |
20 | - Describe your feature in detail
21 | - Add images, videos, links, examples, references, etc. if possible
22 | - type: textarea
23 | attributes:
24 | label: Motivation
25 | description: |
26 | A strong motivation is necessary for a feature request to be considered.
27 |
28 | - Why should this feature be implemented?
29 | - What is the explicit use case?
30 | - What are the benefits?
31 | - What makes this feature important?
32 | validations:
33 | required: true
34 | - type: checkboxes
35 | id: acknowledgements
36 | attributes:
37 | label: Acknowledgements
38 | description: Your feature request will be closed if you don't follow the checklist below.
39 | options:
40 | - label: I have checked all open and closed feature requests and this is not a duplicate
41 | required: true
42 | - label: I have chosen an appropriate title.
43 | required: true
44 | - label: All requested information has been provided properly.
45 | required: true
46 |
--------------------------------------------------------------------------------
/.github/config.yml:
--------------------------------------------------------------------------------
1 | firstPRMergeComment: >
2 | Thank you for contributing to Revenge. Join us on [Discord](https://discord.gg/ddcQf3s2Uq) to receive a role for your contribution.
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 | on:
3 | push:
4 | branches: [main, dev]
5 |
6 | jobs:
7 | release:
8 | name: Release
9 | runs-on: ubuntu-latest
10 | permissions:
11 | contents: write
12 |
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v4
16 |
17 | - name: Install pnpm
18 | uses: pnpm/action-setup@v4
19 | with:
20 | version: latest
21 |
22 | - name: Install Node.js
23 | uses: actions/setup-node@v4
24 | with:
25 | node-version: 22
26 | cache: pnpm
27 |
28 | - name: Install dependencies
29 | run: pnpm install --frozen-lockfile
30 |
31 | - name: Release
32 | run: pnpx semantic-release
33 | env:
34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
35 | RELEASE_BRANCH: ${{ github.ref_name }}
36 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist/
3 | package-lock.json
4 | yarn.lock
5 | .DS_Store
6 | lib-dist/
7 | /packages/types
--------------------------------------------------------------------------------
/.releaserc.js:
--------------------------------------------------------------------------------
1 | export default {
2 | branches: [
3 | "main",
4 | {
5 | name: "dev",
6 | prerelease: true,
7 | },
8 | ],
9 | plugins: [
10 | [
11 | "@semantic-release/commit-analyzer",
12 | {
13 | releaseRules: [
14 | { type: "build", scope: "Needs bump", release: "patch" },
15 | ],
16 | },
17 | ],
18 | "@semantic-release/release-notes-generator",
19 | "@semantic-release/changelog",
20 | [
21 | "@semantic-release/git",
22 | {
23 | assets: ["README.md", "CHANGELOG.md"],
24 | message:
25 | "chore: Release ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}",
26 | },
27 | ],
28 | [
29 | "@semantic-release/exec",
30 | {
31 | prepareCmd: `pnpm build --release-branch ${process.env.RELEASE_BRANCH} --build-minify`,
32 | },
33 | ],
34 | [
35 | "@semantic-release/github",
36 | {
37 | assets: [
38 | {
39 | path: "dist/*.js",
40 | },
41 | ],
42 | successComment: false,
43 | },
44 | ],
45 | [
46 | "@saithodev/semantic-release-backmerge",
47 | {
48 | backmergeBranches: [{ from: "main", to: "dev" }],
49 | clearWorkspace: true,
50 | },
51 | ],
52 | ],
53 | };
54 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.codeActionsOnSave": {
3 | "source.fixAll.eslint": "explicit"
4 | },
5 | "[typescript]": {
6 | "editor.defaultFormatter": "vscode.typescript-language-features"
7 | },
8 | "[typescriptreact]": {
9 | "editor.defaultFormatter": "vscode.typescript-language-features"
10 | },
11 | "[json]": {
12 | "editor.formatOnSave": false
13 | },
14 | "javascript.format.semicolons": "insert",
15 | "typescript.format.semicolons": "insert",
16 | "typescript.preferences.quoteStyle": "double",
17 | "javascript.preferences.quoteStyle": "double"
18 | }
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # 👋 Contribution guidelines
2 |
3 | This document describes how to contribute to Revenge bundle.
4 |
5 | ## 📖 Resources to help you get started
6 |
7 | * [Issues](https://github.com/revenge-mod/revenge-bundle/issues) are where we keep track of bugs and feature requests
8 |
9 | ## 🙏 Submitting a feature request
10 |
11 | Features can be requested by opening an issue using the
12 | [Feature request issue template](https://github.com/revenge-mod/revenge-bundle/issues/new?assignees=&labels=Feature+request&projects=&template=feature_request.yml&title=feat%3A+).
13 |
14 | > **Note**
15 | > Requests can be accepted or rejected at the discretion of maintainers of Revenge bundle.
16 | > Good motivation has to be provided for a request to be accepted.
17 |
18 | ## 🐞 Submitting a bug report
19 |
20 | If you encounter a bug while using Revenge bundle, open an issue using the
21 | [Bug report issue template](https://github.com/revenge-mod/revenge-bundle/issues/new?assignees=&labels=Bug+report&projects=&template=bug_report.yml&title=bug%3A+).
22 |
23 | ## 📝 How to contribute
24 |
25 | 1. Before contributing, it is recommended to open an issue to discuss your change
26 | with the maintainers of Revenge bundle. This will help you determine whether your change is acceptable
27 | and whether it is worth your time to implement it
28 | 2. Development happens on the `dev` branch. Fork the repository and create your branch from `dev`
29 | 3. Commit your changes
30 | 4. Submit a pull request to the `dev` branch of the repository and reference issues
31 | that your pull request closes in the description of your pull request
32 | 5. Our team will review your pull request and provide feedback. Once your pull request is approved,
33 | it will be merged into the `dev` branch and will be included in the next release of Revenge bundle
34 |
35 | ❤️ Thank you for considering contributing to Revenge bundle
36 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2023, Team Vendetta
4 | Copyright (c) 2024, pylixonly
5 | Copyright (c) 2024, Team Revenge
6 |
7 | Redistribution and use in source and binary forms, with or without
8 | modification, are permitted provided that the following conditions are met:
9 |
10 | 1. Redistributions of source code must retain the above copyright notice, this
11 | list of conditions and the following disclaimer.
12 |
13 | 2. Redistributions in binary form must reproduce the above copyright notice,
14 | this list of conditions and the following disclaimer in the documentation
15 | and/or other materials provided with the distribution.
16 |
17 | 3. Neither the name of the copyright holder nor the names of its
18 | contributors may be used to endorse or promote products derived from
19 | this software without specific prior written permission.
20 |
21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
22 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
23 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
24 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
25 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
26 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
27 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
29 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | # 👊 Revenge
37 |
38 | 
39 | [](https://opensource.org/licenses/BSD-3-Clause)
40 |
41 | A client modification for Discord mobile, a continuation of [Bunny](https://github.com/pyoncord/Bunny).
42 |
43 | ## 💪 Features
44 |
45 | - **🔌 Plugins**: Extend Discord with custom features
46 | - **🎨 Themes & Fonts**: Customize Discord's appearance to your liking
47 | - **🧪 Experiments**: Try out Discord's new features before they're rolled out
48 |
49 | ## ⬇️ Download
50 |
51 | This repository releases JavaScript bundles for loaders to execute. These are the official Revenge loaders:
52 |
53 | ### Android
54 |
55 | - **🩹 Root with Xposed**: [RevengeXposed](https://github.com/revenge-mod/revenge-xposed/releases/latest)
56 | - **📵 Non-root**: [Revenge Manager](https://github.com/revenge-mod/revenge-manager/releases/latest)
57 |
58 | ### iOS
59 |
60 | - [**RevengeTweak**](https://github.com/revenge-mod/revenge-tweak): Prebuilt rootful and rootless `.deb` files or the prepatched `.ipa`
61 |
62 | ## 📚 Everything else
63 |
64 | ### 📙 Contributing
65 |
66 | Thank you for considering contributing to Revenge.
67 | You can find the contribution guidelines [here](CONTRIBUTING.md).
68 |
69 | ### 🛠️ Building
70 |
71 | To build Revenge bundle, you can follow the [documentation](/docs).
72 |
73 | ### 📃 Documentation
74 |
75 | You can find the documentation of Revenge bundle [here](/docs).
76 |
77 | ## 📜 Licence
78 |
79 | Revenge bundle is licensed under the 3-Clause BSD license. Please see the [license file](LICENSE) for more information.
80 |
--------------------------------------------------------------------------------
/assets/logo/logo-dark.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/assets/logo/logo-light.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/docs/2_building.md:
--------------------------------------------------------------------------------
1 | ## Building
2 | 1. Install a Revenge loader with config support (any mentioned in the [Installing](#installing) section).
3 | 2. Go to **Settings** > **General** and enable **Developer Settings**.
4 | 3. Clone the repository
5 |
6 | ```sh
7 | git clone https://github.com/revenge-mod/revenge-bundle.git
8 | ```
9 |
10 | 4. Install dependencies
11 |
12 | ```
13 | pnpm i
14 | ```
15 |
16 | 5. Build Revenge's code
17 |
18 | ```
19 | pnpm build
20 | ```
21 |
22 | 6. In the newly created `dist` directory, run a HTTP server. I recommend [http-server](https://www.npmjs.com/package/http-server).
23 | 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`.
24 | 8. Restart Discord. Upon reload, you should notice that your device will download Revenge's bundled code from your server, rather than GitHub.
25 | 9. Make your changes, rebuild, reload, go wild!
26 |
27 | Alternatively, you can directly *serve* the bundled code by running `pnpm 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.
28 |
29 | 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.
30 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # 👊 Revenge
2 |
3 | This documentation explains how to use [Revenge](https://github.com/revenge-mod/revenge-bundle).
4 |
5 | ## 📖 Table of contents
6 |
7 | TODO.
8 |
9 | ## ⏭️ Start here
10 |
11 | The next page will tell you about the prerequisites for using Revenge.
12 |
13 | Continue: [💼 Prerequisites](0_prerequisites.md)
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import typescriptEslint from "@typescript-eslint/eslint-plugin";
2 | import simpleImportSort from "eslint-plugin-simple-import-sort";
3 | import unusedImports from "eslint-plugin-unused-imports";
4 | import importAlias from "eslint-plugin-import-alias";
5 | import tsParser from "@typescript-eslint/parser";
6 |
7 | export default [{
8 | ignores: ["**/dist", "**/browser"],
9 | }, {
10 | plugins: {
11 | "@typescript-eslint": typescriptEslint,
12 | "simple-import-sort": simpleImportSort,
13 | "unused-imports": unusedImports,
14 | "import-alias": importAlias,
15 | },
16 |
17 | languageOptions: {
18 | parser: tsParser,
19 | },
20 |
21 | rules: {
22 | "no-restricted-syntax": ["error", {
23 | selector: "AwaitExpression:not(:function *)",
24 | message: "Hermes does not support top-level await, and SWC cannot transform it.",
25 | }],
26 |
27 | quotes: ["error", "double", {
28 | avoidEscape: true,
29 | }],
30 |
31 | "jsx-quotes": ["error", "prefer-double"],
32 | "no-mixed-spaces-and-tabs": "error",
33 |
34 | indent: ["error", 4, {
35 | SwitchCase: 1,
36 | }],
37 |
38 | "arrow-parens": ["error", "as-needed"],
39 | "eol-last": ["error", "always"],
40 | "func-call-spacing": ["error", "never"],
41 | "no-multi-spaces": "error",
42 | "no-trailing-spaces": "error",
43 | "no-whitespace-before-property": "error",
44 | semi: ["error", "always"],
45 | "semi-style": ["error", "last"],
46 | "space-in-parens": ["error", "never"],
47 | "block-spacing": ["error", "always"],
48 | "object-curly-spacing": ["error", "always"],
49 |
50 | eqeqeq: ["error", "always", {
51 | null: "ignore",
52 | }],
53 |
54 | "spaced-comment": ["error", "always", {
55 | markers: ["!"],
56 | }],
57 |
58 | yoda: "error",
59 |
60 | "prefer-destructuring": ["error", {
61 | object: true,
62 | array: false,
63 | }],
64 |
65 | "operator-assignment": ["error", "always"],
66 | "no-useless-computed-key": "error",
67 |
68 | "no-unneeded-ternary": ["error", {
69 | defaultAssignment: false,
70 | }],
71 |
72 | "no-invalid-regexp": "error",
73 |
74 | "no-constant-condition": ["error", {
75 | checkLoops: false,
76 | }],
77 |
78 | "no-duplicate-imports": "error",
79 | "no-extra-semi": "error",
80 | "dot-notation": "error",
81 | "no-useless-escape": ["error"],
82 | "no-fallthrough": "error",
83 | "for-direction": "error",
84 | "no-async-promise-executor": "error",
85 | "no-cond-assign": "error",
86 | "no-dupe-else-if": "error",
87 | "no-duplicate-case": "error",
88 | "no-irregular-whitespace": "error",
89 | "no-loss-of-precision": "error",
90 | "no-misleading-character-class": "error",
91 | "no-prototype-builtins": "error",
92 | "no-regex-spaces": "error",
93 | "no-shadow-restricted-names": "error",
94 | "no-unexpected-multiline": "error",
95 | "no-unsafe-optional-chaining": "error",
96 | "no-useless-backreference": "error",
97 | "use-isnan": "error",
98 | "prefer-const": "error",
99 | "prefer-spread": "error",
100 | "simple-import-sort/imports": "error",
101 | "simple-import-sort/exports": "error",
102 | "unused-imports/no-unused-imports": "error",
103 |
104 | "import-alias/import-alias": ["error", {
105 | relativeDepth: 0,
106 |
107 | aliases: [{
108 | alias: "@metro",
109 | matcher: "^src/lib/metro",
110 | }, {
111 | alias: "@core",
112 | matcher: "^src/core",
113 | }, {
114 | alias: "@ui",
115 | matcher: "^src/lib/ui",
116 | }, {
117 | alias: "@types",
118 | matcher: "^src/lib/utils/types.ts",
119 | }, {
120 | alias: "@lib",
121 | matcher: "^src/lib",
122 | }],
123 | }],
124 | },
125 | }];
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@revenge-mod/revenge",
3 | "exports": {
4 | "types": "./dist/types/index.d.ts"
5 | },
6 | "author": {
7 | "name": "Revenge Team",
8 | "url": "https://github.com/revenge-mod"
9 | },
10 | "scripts": {
11 | "build": "node scripts/build.mjs",
12 | "serve": "node scripts/serve.mjs",
13 | "serve:adb": "pnpm run serve --adb",
14 | "lint": "eslint ./src --ext .js,.jsx,.ts,.tsx"
15 | },
16 | "license": "BSD-3-Clause",
17 | "devDependencies": {
18 | "@saithodev/semantic-release-backmerge": "^4.0.1",
19 | "@semantic-release/changelog": "^6.0.3",
20 | "@semantic-release/exec": "^6.0.3",
21 | "@semantic-release/git": "^10.0.1",
22 | "semantic-release": "^24.1.3",
23 | "@swc/core": "^1.7.0",
24 | "@swc/helpers": "^0.5.12",
25 | "@types/node": "^20.14.11",
26 | "@types/yargs-parser": "^21.0.3",
27 | "@typescript-eslint/eslint-plugin": "^7.16.1",
28 | "@typescript-eslint/parser": "^7.16.1",
29 | "@typescript-eslint/typescript-estree": "^7.16.1",
30 | "chalk": "^5.3.0",
31 | "esbuild": "^0.20.2",
32 | "esbuild-plugin-globals": "^0.2.0",
33 | "eslint": "^8.57.0",
34 | "eslint-plugin-import-alias": "^1.2.0",
35 | "eslint-plugin-react": "^7.35.0",
36 | "eslint-plugin-simple-import-sort": "^12.1.1",
37 | "eslint-plugin-unused-imports": "^3.2.0",
38 | "typescript": "^5.5.3",
39 | "yargs-parser": "^21.1.1"
40 | },
41 | "dependencies": {
42 | "@gullerya/object-observer": "^6.1.3",
43 | "@shopify/react-native-skia": "^1.3.8",
44 | "@tanstack/react-query": "^5.51.16",
45 | "@types/chroma-js": "~2.4.4",
46 | "@types/lodash": "~4.17.7",
47 | "@types/react": "18.2.60",
48 | "@types/react-native": "0.72.3",
49 | "es-toolkit": "^1.13.1",
50 | "fuzzysort": "^3.0.2",
51 | "intl-messageformat": "^10.5.14",
52 | "moment": "2.22.2",
53 | "react-native-reanimated": "^3.6.2",
54 | "spitroast": "^1.4.4",
55 | "type-fest": "^4.22.1"
56 | },
57 | "pnpm": {
58 | "peerDependencyRules": {
59 | "ignoreMissing": [
60 | "react",
61 | "react-native"
62 | ]
63 | }
64 | }
65 | }
--------------------------------------------------------------------------------
/scripts/adb.mjs:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | import { execSync } from "child_process";
3 |
4 | const packageName = process.env.DISCORD_PACKAGE_NAME ?? "com.discord";
5 |
6 | export function getPackageName() {
7 | return packageName;
8 | }
9 |
10 | export function isADBAvailableAndAppInstalled() {
11 | try {
12 | const out = execSync(`adb shell pm list packages ${packageName}`);
13 | return out.toString().trimEnd() === `package:${packageName}`;
14 | } catch {
15 | return false;
16 | }
17 | }
18 | export async function restartAppFromADB(reversePort) {
19 | if (typeof reversePort === "number") {
20 | await execSync(`adb reverse tcp:${reversePort} tcp:${reversePort}`);
21 | }
22 |
23 | await forceStopAppFromADB();
24 | await execSync(`adb shell am start ${packageName}/com.discord.main.MainActivity`);
25 | }
26 |
27 | export async function forceStopAppFromADB() {
28 | await execSync(`adb shell am force-stop ${packageName}`);
29 | }
30 |
--------------------------------------------------------------------------------
/scripts/serve.mjs:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | import chalk from "chalk";
3 | import { readFile } from "fs/promises";
4 | import http from "http";
5 | import os from "os";
6 | import readline from "readline";
7 | import url from "url";
8 | import yargs from "yargs-parser";
9 |
10 | import { forceStopAppFromADB, getPackageName, isADBAvailableAndAppInstalled, restartAppFromADB } from "./adb.mjs";
11 | import { buildBundle } from "./build.mjs";
12 | import { printBuildSuccess } from "./util.mjs";
13 |
14 | const args = yargs(process.argv.slice(2));
15 |
16 | export function serve(options) {
17 | // @ts-ignore
18 | const server = http.createServer(async (req, res) => {
19 | const { pathname } = url.parse(req.url || "", true);
20 | if (pathname?.endsWith(".js")) {
21 | try {
22 | const { config, context, timeTook } = await buildBundle();
23 |
24 | printBuildSuccess(
25 | context.hash,
26 | args.production,
27 | timeTook
28 | );
29 |
30 | res.writeHead(200, { "Content-Type": "application/javascript" });
31 | res.end(await readFile(config.outfile, "utf-8"));
32 | } catch {
33 | res.writeHead(500);
34 | res.end();
35 | }
36 | } else {
37 | res.writeHead(404);
38 | res.end();
39 | }
40 | }, options);
41 |
42 | server.listen(args.port ?? 4040);
43 |
44 | console.info(chalk.bold.yellowBright("Serving Revenge bundle, available on:"));
45 |
46 | const netInterfaces = os.networkInterfaces();
47 | for (const netinterfaces of Object.values(netInterfaces)) {
48 | for (const details of netinterfaces || []) {
49 | if (details.family !== "IPv4") continue;
50 | const port = chalk.green(server.address()?.port.toString());
51 | console.info(` http://${details.address}:${port}/bundle.js`);
52 | }
53 | }
54 |
55 | return server;
56 | }
57 |
58 | const server = serve();
59 |
60 | console.log("\nPress Q key or Ctrl+C to exit.");
61 |
62 | if (args.adb && isADBAvailableAndAppInstalled()) {
63 | const packageName = getPackageName();
64 |
65 | console.log(`Press R key to reload Discord ${chalk.bold.blue(`(${packageName})`)}.`);
66 | console.log(`Press S key to force stop Discord ${chalk.bold.blue(`(${packageName})`)}.`);
67 |
68 | readline.emitKeypressEvents(process.stdin);
69 |
70 | if (process.stdin.isTTY) {
71 | process.stdin.setRawMode(true);
72 | }
73 |
74 | process.stdin.on("keypress", (ch, key) => {
75 | if (!key) return;
76 |
77 | if (key.name === "q" || key.ctrl && key.name === "c") {
78 | process.exit(0);
79 | }
80 |
81 | if (key.name === "r") {
82 | console.info(chalk.yellow(`${chalk.bold("↻ Reloading")} ${packageName}`));
83 | restartAppFromADB(server.address().port)
84 | .then(() => console.info(chalk.greenBright(`${chalk.bold("✔ Executed")} reload command`)))
85 | .catch(e => console.error(e));
86 | }
87 |
88 | if (key.name === "s") {
89 | console.info(chalk.yellow(`${chalk.bold("⎊ Force stopping")} ${packageName}`));
90 | forceStopAppFromADB()
91 | .then(() => console.info(chalk.greenBright(`${chalk.bold("✔ Executed")} force stop command`)))
92 | .catch(e => console.error(e));
93 | }
94 | });
95 | } else if (args.adb) {
96 | console.warn("ADB option enabled but failed to connect to device!");
97 | }
98 |
--------------------------------------------------------------------------------
/scripts/util.mjs:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | import chalk from "chalk";
3 |
4 | export function printBuildSuccess(
5 | hash,
6 | branch,
7 | timeTook,
8 | minified
9 | ) {
10 | console.info([
11 | chalk.bold.greenBright("✔ Built bundle" + (minified ? " (minified)" : "")),
12 | hash && chalk.bold.blueBright(`(${hash})`),
13 | !branch && chalk.bold.cyanBright("(local)"),
14 | timeTook && chalk.gray(`in ${timeTook.toFixed(3)}ms`)
15 | ].filter(Boolean).join(" "));
16 | }
17 |
--------------------------------------------------------------------------------
/shims/asyncIteratorSymbol.js:
--------------------------------------------------------------------------------
1 | // babel/swc async generator implementation for some reason does not include assigning Symbol.asyncIterator (and won't work out of the box???)
2 | const asyncIteratorSymbol = Symbol("Symbol.asyncIterator");
3 |
4 | // your editor may yell at you for invalid syntax here, but this is a valid JS syntax!
5 | export { asyncIteratorSymbol as "Symbol.asyncIterator" };
--------------------------------------------------------------------------------
/shims/depsModule.ts:
--------------------------------------------------------------------------------
1 | import { findByPropsLazy } from "@metro/wrappers";
2 |
3 | module.exports = {
4 | "react": findByPropsLazy("createElement"),
5 | "react-native": findByPropsLazy("AppRegistry"),
6 | "util": findByPropsLazy("inspect", "isNullOrUndefined"),
7 | "moment": findByPropsLazy("isMoment"),
8 | "chroma-js": findByPropsLazy("brewer"),
9 | "lodash": findByPropsLazy("forEachRight"),
10 | "@shopify/react-native-skia": findByPropsLazy("useFont")
11 | };
12 |
--------------------------------------------------------------------------------
/shims/emptyModule.ts:
--------------------------------------------------------------------------------
1 | export {};
2 |
--------------------------------------------------------------------------------
/shims/jsxRuntime.ts:
--------------------------------------------------------------------------------
1 | import { getProxyFactory } from "@lib/utils/lazy";
2 | import { findByPropsLazy } from "@metro/wrappers";
3 |
4 | const jsxRuntime = findByPropsLazy("jsx", "jsxs", "Fragment");
5 |
6 | function unproxyFirstArg(args: T[]) {
7 | if (!args[0]) {
8 | throw new Error("The first argument (Component) is falsy. Ensure that you are passing a valid component.");
9 | }
10 |
11 | const factory = getProxyFactory(args[0]);
12 | if (factory) args[0] = factory();
13 | return args;
14 | }
15 |
16 | export const Fragment = Symbol.for("react.fragment");
17 | export const jsx = (...args: any[]) => jsxRuntime.jsx(...unproxyFirstArg(args));
18 | export const jsxs = (...args: any[]) => jsxRuntime.jsxs(...unproxyFirstArg(args));
19 |
--------------------------------------------------------------------------------
/shims/promiseAllSettled.js:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 |
3 | const allSettledFulfill = value => ({ status: "fulfilled", value });
4 | const allSettledReject = reason => ({ status: "rejected", reason });
5 | const mapAllSettled = item => Promise.resolve(item).then(allSettledFulfill, allSettledReject);
6 |
7 | const allSettled = Promise.allSettled ??= iterator => {
8 | return Promise.all(Array.from(iterator).map(mapAllSettled));
9 | };
10 |
11 | // Your editor may yell at you for this, but this is alright! It's a valid JS syntax
12 | export { allSettled as "Promise.allSettled" };
13 |
--------------------------------------------------------------------------------
/src/assets/icons/revenge.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/revenge-mod/revenge-bundle/c263de39d0f8786d2a9225621e2db667f0141edb/src/assets/icons/revenge.png
--------------------------------------------------------------------------------
/src/core/commands/debug.ts:
--------------------------------------------------------------------------------
1 | import { Strings } from "@core/i18n";
2 | import { ApplicationCommand, ApplicationCommandOptionType } from "@lib/api/commands/types";
3 | import { getDebugInfo } from "@lib/api/debug";
4 | import { messageUtil } from "@metro/common";
5 |
6 | export default () => {
7 | name: "debug",
8 | description: Strings.COMMAND_DEBUG_DESC,
9 | options: [
10 | {
11 | name: "ephemeral",
12 | type: ApplicationCommandOptionType.BOOLEAN,
13 | description: Strings.COMMAND_DEBUG_OPT_EPHEMERALLY,
14 | }
15 | ],
16 | execute([ephemeral], ctx) {
17 | const info = getDebugInfo();
18 | const content = [
19 | "**Revenge Debug Info**",
20 | `> Revenge: ${info.bunny.version} (${info.bunny.loader.name} ${info.bunny.loader.version})`,
21 | `> Discord: ${info.discord.version} (${info.discord.build})`,
22 | `> React: ${info.react.version} (RN ${info.react.nativeVersion})`,
23 | `> Hermes: ${info.hermes.version} (bcv${info.hermes.bytecodeVersion})`,
24 | `> System: ${info.os.name} ${info.os.version} ${info.os.sdk ? `(SDK ${info.os.sdk})` : ""}`.trimEnd(),
25 | `> Device: ${info.device.model} (${info.device.codename})`,
26 | ].join("\n");
27 |
28 | if (ephemeral?.value) {
29 | messageUtil.sendBotMessage(ctx.channel.id, content);
30 | } else {
31 | messageUtil.sendMessage(ctx.channel.id, { content });
32 | }
33 | }
34 | };
35 |
--------------------------------------------------------------------------------
/src/core/commands/eval.ts:
--------------------------------------------------------------------------------
1 | import { Strings } from "@core/i18n";
2 | import { ApplicationCommand, ApplicationCommandOptionType } from "@lib/api/commands/types";
3 | import { settings } from "@lib/api/settings";
4 | import { messageUtil } from "@metro/common";
5 | import { findByPropsLazy } from "@metro/wrappers";
6 |
7 | const util = findByPropsLazy("inspect");
8 | const AsyncFunction = (async () => void 0).constructor;
9 |
10 | const ZERO_WIDTH_SPACE_CHARACTER = "\u200B";
11 |
12 | function wrapInJSCodeblock(resString: string) {
13 | return "```js\n" + resString.replaceAll("`", "`" + ZERO_WIDTH_SPACE_CHARACTER) + "\n```";
14 | }
15 |
16 | export default () => {
17 | name: "eval",
18 | description: Strings.COMMAND_EVAL_DESC,
19 | shouldHide: () => settings.enableEvalCommand === true,
20 | options: [
21 | {
22 | name: "code",
23 | type: ApplicationCommandOptionType.STRING,
24 | description: Strings.COMMAND_EVAL_OPT_CODE,
25 | required: true
26 | },
27 | {
28 | name: "async",
29 | type: ApplicationCommandOptionType.BOOLEAN,
30 | description: Strings.COMMAND_EVAL_OPT_ASYNC,
31 | }
32 | ],
33 | async execute([code, async], ctx) {
34 | try {
35 | const res = util.inspect(async?.value ? await AsyncFunction(code.value)() : eval?.(code.value));
36 | const trimmedRes = res.length > 2000 ? res.slice(0, 2000) + "..." : res;
37 |
38 | messageUtil.sendBotMessage(ctx.channel.id, wrapInJSCodeblock(trimmedRes));
39 | } catch (err: any) {
40 | messageUtil.sendBotMessage(ctx.channel.id, wrapInJSCodeblock(err?.stack ?? err));
41 | }
42 | }
43 | };
44 |
--------------------------------------------------------------------------------
/src/core/commands/plugins.ts:
--------------------------------------------------------------------------------
1 | import { Strings } from "@core/i18n";
2 | import { VdPluginManager, VendettaPlugin } from "@core/vendetta/plugins";
3 | import { ApplicationCommand, ApplicationCommandOptionType } from "@lib/api/commands/types";
4 | import { messageUtil } from "@metro/common";
5 |
6 | export default () => {
7 | name: "plugins",
8 | description: Strings.COMMAND_PLUGINS_DESC,
9 | options: [
10 | {
11 | name: "ephemeral",
12 | displayName: "ephemeral",
13 | type: ApplicationCommandOptionType.BOOLEAN,
14 | description: Strings.COMMAND_DEBUG_OPT_EPHEMERALLY,
15 | }
16 | ],
17 | execute([ephemeral], ctx) {
18 | const plugins = Object.values(VdPluginManager.plugins).filter(Boolean) as unknown as VendettaPlugin[];
19 | plugins.sort((a, b) => a.manifest.name.localeCompare(b.manifest.name));
20 |
21 | const enabled = plugins.filter(p => p.enabled).map(p => p.manifest.name);
22 | const disabled = plugins.filter(p => !p.enabled).map(p => p.manifest.name);
23 |
24 | const content = [
25 | `**Installed Plugins (${plugins.length}):**`,
26 | ...(enabled.length > 0 ? [
27 | `Enabled (${enabled.length}):`,
28 | "> " + enabled.join(", "),
29 | ] : []),
30 | ...(disabled.length > 0 ? [
31 | `Disabled (${disabled.length}):`,
32 | "> " + disabled.join(", "),
33 | ] : []),
34 | ].join("\n");
35 |
36 | if (ephemeral?.value) {
37 | messageUtil.sendBotMessage(ctx.channel.id, content);
38 | } else {
39 | messageUtil.sendMessage(ctx.channel.id, { content });
40 | }
41 | }
42 | };
43 |
--------------------------------------------------------------------------------
/src/core/debug/patches/patchErrorBoundary.tsx:
--------------------------------------------------------------------------------
1 | import ErrorBoundaryScreen from "@core/ui/reporter/components/ErrorBoundaryScreen";
2 | import { after } from "@lib/api/patcher";
3 | import { _lazyContextSymbol } from "@metro/lazy";
4 | import { LazyModuleContext } from "@metro/types";
5 | import { findByNameLazy } from "@metro/wrappers";
6 |
7 | function getErrorBoundaryContext() {
8 | const ctxt: LazyModuleContext = findByNameLazy("ErrorBoundary")[_lazyContextSymbol];
9 | return new Promise(resolve => ctxt.getExports(exp => resolve(exp.prototype)));
10 | }
11 |
12 | export default function patchErrorBoundary() {
13 | return after.await("render", getErrorBoundaryContext(), function (this: any) {
14 | if (!this.state.error) return;
15 |
16 | return this.setState({ info: null, error: null })}
19 | />;
20 | });
21 | }
22 |
--------------------------------------------------------------------------------
/src/core/debug/safeMode.ts:
--------------------------------------------------------------------------------
1 | import { getCurrentTheme, writeThemeToNative } from "@lib/addons/themes";
2 | import { BundleUpdaterManager } from "@lib/api/native/modules";
3 | import { settings } from "@lib/api/settings";
4 |
5 | export function isSafeMode() {
6 | return settings.safeMode?.enabled === true;
7 | }
8 |
9 | export async function toggleSafeMode({
10 | to = !isSafeMode(),
11 | reload = true
12 | } = {}) {
13 | const enabled = (settings.safeMode ??= { enabled: to }).enabled = to;
14 | const currentColor = getCurrentTheme();
15 | await writeThemeToNative(enabled ? {} : currentColor?.data ?? {});
16 | if (reload) setTimeout(() => BundleUpdaterManager.reload(), 500);
17 | }
18 |
--------------------------------------------------------------------------------
/src/core/fixes.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Why do we need this exactly? It's simple, to fight the side effects caused by when you
3 | * initialize modules not in the right time or when you're not supposed to.
4 | * Back then, we had major bugs like the mixing of light and dark themes and AMOLED option getting ignored.
5 | * Nowadays, there are only three which I can spot: Hindi timestamps, crashing on some native components and slower startup time.
6 | * - @pylixonly
7 | */
8 |
9 | import { logger } from "@lib/utils/logger";
10 | import { FluxDispatcher } from "@metro/common";
11 | import moment from "moment";
12 |
13 | function onDispatch({ locale }: { locale: string; }) {
14 | // Timestamps
15 | try {
16 | moment.locale(locale.toLowerCase());
17 | } catch (e) {
18 | logger.error("Failed to fix timestamps...", e);
19 | }
20 |
21 | // We're done here!
22 | FluxDispatcher.unsubscribe("I18N_LOAD_SUCCESS", onDispatch);
23 | }
24 |
25 | export default () => {
26 | FluxDispatcher.subscribe("I18N_LOAD_SUCCESS", onDispatch);
27 | };
28 |
--------------------------------------------------------------------------------
/src/core/i18n/index.ts:
--------------------------------------------------------------------------------
1 | import { FluxDispatcher } from "@metro/common";
2 | import { findByNameLazy } from "@metro/wrappers";
3 | import { PrimitiveType } from "intl-messageformat";
4 |
5 | import langDefault from "./default.json";
6 |
7 | const IntlMessageFormat = findByNameLazy("MessageFormat") as typeof import("intl-messageformat").default;
8 |
9 | type I18nKey = keyof typeof langDefault;
10 |
11 | let _currentLocale: string | null = null;
12 | let _lastSetLocale: string | null = null;
13 |
14 | const _loadedLocale = new Set();
15 | const _loadedStrings = {} as Record;
16 |
17 | export const Strings = new Proxy({}, {
18 | get: (_t, prop: keyof typeof langDefault) => {
19 | if (_currentLocale && _loadedStrings[_currentLocale]?.[prop]) {
20 | return _loadedStrings[_currentLocale]?.[prop];
21 | }
22 | return langDefault[prop];
23 | }
24 | }) as Record;
25 |
26 | export function initFetchI18nStrings() {
27 | const cb = ({ locale }: { locale: string; }) => {
28 | const languageMap = {
29 | "es-ES": "es",
30 | "es-419": "es_419",
31 | "zh-TW": "zh-Hant",
32 | "zh-CN": "zh-Hans",
33 | "pt-PT": "pt",
34 | "pt-BR": "pt_BR",
35 | "sv-SE": "sv"
36 | } as Record;
37 |
38 | const resolvedLocale = _lastSetLocale = languageMap[locale] ?? locale;
39 |
40 | if (resolvedLocale.startsWith("en-")) {
41 | _currentLocale = null;
42 | return;
43 | }
44 |
45 | if (!_loadedLocale.has(resolvedLocale)) {
46 | _loadedLocale.add(resolvedLocale);
47 |
48 | fetch(`https://raw.githubusercontent.com/pyoncord/i18n/main/resources/${resolvedLocale}/bunny.json`)
49 | .then(r => r.json())
50 | .then(strings => _loadedStrings[resolvedLocale] = strings)
51 | .then(() => resolvedLocale === _lastSetLocale && (_currentLocale = resolvedLocale))
52 | .catch(e => console.error(`An error occured while fetching strings for ${resolvedLocale}: ${e}`));
53 | } else {
54 | _currentLocale = resolvedLocale;
55 | }
56 | };
57 |
58 | FluxDispatcher.subscribe("I18N_LOAD_SUCCESS", cb);
59 | return () => FluxDispatcher.unsubscribe("I18N_LOAD_SUCCESS", cb);
60 | }
61 |
62 | type FormatStringRet = T extends PrimitiveType ? string : string | T | (string | T)[];
63 |
64 | export function formatString(key: I18nKey, val: Record): FormatStringRet {
65 | const str = Strings[key];
66 | // @ts-ignore
67 | return new IntlMessageFormat(str).format(val);
68 | }
69 |
--------------------------------------------------------------------------------
/src/core/plugins/badges/index.tsx:
--------------------------------------------------------------------------------
1 | import { after } from "@lib/api/patcher";
2 | import { onJsxCreate } from "@lib/api/react/jsx";
3 | import { findByName } from "@metro";
4 | import { useEffect, useState } from "react";
5 |
6 | import { defineCorePlugin } from "..";
7 |
8 | interface BunnyBadge {
9 | label: string;
10 | url: string;
11 | }
12 |
13 | const useBadgesModule = findByName("useBadges", false);
14 |
15 | export default defineCorePlugin({
16 | manifest: {
17 | id: "bunny.badges",
18 | name: "Badges",
19 | version: "1.0.0",
20 | description: "Adds badges to user's profile",
21 | authors: [{ name: "pylixonly" }]
22 | },
23 | start() {
24 | const propHolder = {} as Record;
25 | const badgeCache = {} as Record;
26 |
27 | onJsxCreate("RenderedBadge", (_, ret) => {
28 | if (ret.props.id.match(/bunny-\d+-\d+/)) {
29 | Object.assign(ret.props, propHolder[ret.props.id]);
30 | }
31 | });
32 |
33 | after("default", useBadgesModule, ([user], r) => {
34 | const [badges, setBadges] = useState(user ? badgeCache[user.userId] ??= [] : []);
35 |
36 | useEffect(() => {
37 | if (user) {
38 | fetch(`https://raw.githubusercontent.com/pyoncord/badges/refs/heads/main/${user.userId}.json`)
39 | .then(r => r.json())
40 | .then(badges => setBadges(badgeCache[user.userId] = badges));
41 | }
42 | }, [user]);
43 |
44 | if (user) {
45 | badges.forEach((badges, i) => {
46 | propHolder[`bunny-${user.userId}-${i}`] = {
47 | source: { uri: badges.url },
48 | id: `bunny-${i}`,
49 | label: badges.label
50 | };
51 |
52 | r.push({
53 | id: `bunny-${user.userId}-${i}`,
54 | description: badges.label,
55 | icon: "_",
56 | });
57 | });
58 | }
59 | });
60 | }
61 | });
62 |
--------------------------------------------------------------------------------
/src/core/plugins/index.ts:
--------------------------------------------------------------------------------
1 | import { PluginInstanceInternal } from "@lib/addons/plugins/types";
2 |
3 | interface CorePlugin {
4 | default: PluginInstanceInternal;
5 | preenabled: boolean;
6 | }
7 |
8 | // Called from @lib/plugins
9 | export const getCorePlugins = (): Record => ({
10 | "bunny.quickinstall": require("./quickinstall"),
11 | "bunny.badges": require("./badges")
12 | });
13 |
14 | /**
15 | * @internal
16 | */
17 | export function defineCorePlugin(instance: PluginInstanceInternal): PluginInstanceInternal {
18 | // @ts-expect-error
19 | instance[Symbol.for("bunny.core.plugin")] = true;
20 | return instance;
21 | }
22 |
--------------------------------------------------------------------------------
/src/core/plugins/quickinstall/index.ts:
--------------------------------------------------------------------------------
1 | import { defineCorePlugin } from "..";
2 | import patchForumPost from "./forumPost";
3 | import patchUrl from "./url";
4 |
5 | let patches = [] as (() => unknown)[];
6 |
7 | export default defineCorePlugin({
8 | manifest: {
9 | id: "bunny.quickinstall",
10 | name: "QuickInstall",
11 | version: "1.0.0",
12 | description: "Quickly install Vendetta plugins and themes",
13 | authors: [{ name: "Vendetta Team" }]
14 | },
15 | start() {
16 | patches = [patchForumPost(), patchUrl()];
17 | },
18 | stop() {
19 | patches.forEach(p => p());
20 | }
21 | });
22 |
--------------------------------------------------------------------------------
/src/core/plugins/quickinstall/url.tsx:
--------------------------------------------------------------------------------
1 | import { formatString, Strings } from "@core/i18n";
2 | import { showConfirmationAlert } from "@core/vendetta/alerts";
3 | import { VdPluginManager } from "@core/vendetta/plugins";
4 | import { installTheme } from "@lib/addons/themes";
5 | import { findAssetId } from "@lib/api/assets";
6 | import { isThemeSupported } from "@lib/api/native/loader";
7 | import { after, instead } from "@lib/api/patcher";
8 | import { VD_PROXY_PREFIX, VD_THEMES_CHANNEL_ID } from "@lib/utils/constants";
9 | import { lazyDestructure } from "@lib/utils/lazy";
10 | import { channels } from "@metro/common";
11 | import { byMutableProp } from "@metro/filters";
12 | import { findExports } from "@metro/finders";
13 | import { findByProps, findByPropsLazy } from "@metro/wrappers";
14 | import { showToast } from "@ui/toasts";
15 | import { Linking } from "react-native";
16 |
17 | const showSimpleActionSheet = findExports(byMutableProp("showSimpleActionSheet"));
18 | const handleClick = findByPropsLazy("handleClick");
19 | const { getChannelId } = lazyDestructure(() => channels);
20 | const { getChannel } = lazyDestructure(() => findByProps("getChannel"));
21 |
22 | function typeFromUrl(url: string) {
23 | if (url.startsWith(VD_PROXY_PREFIX)) {
24 | return "plugin";
25 | } else if (url.endsWith(".json") && isThemeSupported()) {
26 | return "theme";
27 | }
28 | }
29 |
30 | function installWithToast(type: "plugin" | "theme", url: string) {
31 | (type === "plugin" ? VdPluginManager.installPlugin.bind(VdPluginManager) : installTheme)(url)
32 | .then(() => {
33 | showToast(Strings.SUCCESSFULLY_INSTALLED, findAssetId("Check"));
34 | })
35 | .catch((e: Error) => {
36 | showToast(e.message, findAssetId("Small"));
37 | });
38 | }
39 |
40 | export default () => {
41 | const patches = new Array();
42 |
43 | patches.push(
44 | after("showSimpleActionSheet", showSimpleActionSheet, args => {
45 | if (args[0].key !== "LongPressUrl") return;
46 | const {
47 | header: { title: url },
48 | options,
49 | } = args[0];
50 |
51 | const urlType = typeFromUrl(url);
52 | if (!urlType) return;
53 |
54 | options.push({
55 | label: Strings.INSTALL_ADDON,
56 | onPress: () => installWithToast(urlType, url),
57 | });
58 | })
59 | );
60 |
61 | patches.push(
62 | instead("handleClick", handleClick, async function (this: any, args, orig) {
63 | const { href: url } = args[0];
64 |
65 | const urlType = typeFromUrl(url);
66 | if (!urlType) return orig.apply(this, args);
67 |
68 | // Make clicking on theme links only work in #themes, should there be a theme proxy in the future, this can be removed.
69 | if (urlType === "theme" && getChannel(getChannelId())?.parent_id !== VD_THEMES_CHANNEL_ID) return orig.apply(this, args);
70 |
71 | showConfirmationAlert({
72 | title: Strings.HOLD_UP,
73 | content: formatString("CONFIRMATION_LINK_IS_A_TYPE", { urlType }),
74 | onConfirm: () => installWithToast(urlType, url),
75 | confirmText: Strings.INSTALL,
76 | cancelText: Strings.CANCEL,
77 | secondaryConfirmText: Strings.OPEN_IN_BROWSER,
78 | onConfirmSecondary: () => Linking.openURL(url),
79 | });
80 | })
81 | );
82 |
83 | return () => patches.forEach(p => p());
84 | };
85 |
--------------------------------------------------------------------------------
/src/core/ui/hooks/useFS.ts:
--------------------------------------------------------------------------------
1 | import * as fs from "@lib/api/native/fs";
2 | import { useEffect, useMemo, useState } from "react";
3 |
4 | export enum CheckState {
5 | FALSE,
6 | TRUE,
7 | LOADING,
8 | ERROR
9 | }
10 |
11 | export function useFileExists(path: string, prefix?: string): [CheckState, typeof fs] {
12 | const [state, setState] = useState(CheckState.LOADING);
13 |
14 | const check = () => fs.fileExists(path, { prefix })
15 | .then(exists => setState(exists ? CheckState.TRUE : CheckState.FALSE))
16 | .catch(() => setState(CheckState.ERROR));
17 |
18 | const customFS = useMemo(() => new Proxy(fs, {
19 | get(target, p, receiver) {
20 | const val = Reflect.get(target, p, receiver);
21 | if (typeof val !== "function") return;
22 |
23 | return (...args: any[]) => {
24 | const promise = (check(), val(...args));
25 | if (promise?.constructor?.name === "Promise") {
26 | setState(CheckState.LOADING);
27 | promise.finally(check);
28 | }
29 | return promise;
30 | };
31 | },
32 | }), []);
33 |
34 | useEffect(() => void check(), []);
35 | return [state, customFS];
36 | }
37 |
--------------------------------------------------------------------------------
/src/core/ui/reporter/components/ErrorBoundaryScreen.tsx:
--------------------------------------------------------------------------------
1 | import { hasStack, isComponentStack } from "@core/ui/reporter/utils/isStack";
2 | import { getDebugInfo, toggleSafeMode } from "@lib/api/debug";
3 | import { BundleUpdaterManager } from "@lib/api/native/modules";
4 | import { settings } from "@lib/api/settings";
5 | import { Codeblock, ErrorBoundary } from "@lib/ui/components";
6 | import { createStyles } from "@lib/ui/styles";
7 | import { tokens } from "@metro/common";
8 | import { Button, Card, SafeAreaProvider, SafeAreaView, Text } from "@metro/common/components";
9 | import { ScrollView, View } from "react-native";
10 |
11 | import ErrorComponentStackCard from "./ErrorComponentStackCard";
12 | import ErrorStackCard from "./ErrorStackCard";
13 |
14 | const useStyles = createStyles({
15 | container: {
16 | flex: 1,
17 | backgroundColor: tokens.colors.BG_BASE_SECONDARY,
18 | paddingHorizontal: 16,
19 | height: "100%",
20 | gap: 12
21 | }
22 | });
23 |
24 | export default function ErrorBoundaryScreen(props: {
25 | error: Error;
26 | rerender: () => void;
27 | }) {
28 | const styles = useStyles();
29 | const debugInfo = getDebugInfo();
30 |
31 | return
32 |
33 |
34 |
35 | Uh oh.
36 | A crash occurred while rendering a component. This could be caused by a plugin, Revenge, or Discord itself.
37 | {debugInfo.os.name}; {debugInfo.discord.build} ({debugInfo.discord.version}); {debugInfo.bunny.version}
38 |
39 |
40 | {props.error.message}
41 | {hasStack(props.error) && }
42 | {isComponentStack(props.error) ? : null}
43 |
44 |
45 |
49 |
50 |
51 | ;
52 | }
53 |
--------------------------------------------------------------------------------
/src/core/ui/reporter/components/ErrorCard.tsx:
--------------------------------------------------------------------------------
1 | import { Strings } from "@core/i18n";
2 | import { Codeblock } from "@lib/ui/components";
3 | import { showSheet } from "@lib/ui/sheets";
4 | import { Button, Card, Stack, Text, TwinButtons } from "@metro/common/components";
5 | import { ReactNode } from "react";
6 |
7 | import ErrorDetailsActionSheet from "./ErrorDetailsActionSheet";
8 |
9 | export const INDEX_BUNDLE_FILE: string = window.HermesInternal.getFunctionLocation(window.__r).fileName;
10 |
11 | interface ErrorCardProps {
12 | error: unknown;
13 | header?: string | ReactNode;
14 | onRetryRender?: () => void;
15 | }
16 |
17 | export default function ErrorCard(props: ErrorCardProps) {
18 | return
19 |
20 | {props.header && typeof props.header !== "string"
21 | ? props.header
22 | : {props.header ?? Strings.UH_OH}
23 | }
24 | {String(props.error)}
25 |
26 | {props.onRetryRender && }
32 | {props.error instanceof Error ? showSheet(
36 | "BunnyErrorDetailsActionSheet",
37 | ErrorDetailsActionSheet,
38 | { error: props.error as Error }
39 | )}
40 | /> : null}
41 |
42 |
43 | ;
44 | }
45 |
--------------------------------------------------------------------------------
/src/core/ui/reporter/components/ErrorComponentStackCard.tsx:
--------------------------------------------------------------------------------
1 | import { parseComponentStack } from "@core/ui/reporter/utils/parseComponentStack";
2 | import { findAssetId } from "@lib/api/assets";
3 | import { clipboard } from "@metro/common";
4 | import { Button, Card, Text } from "@metro/common/components";
5 | import { useState } from "react";
6 | import { Image, View } from "react-native";
7 |
8 | export default function ErrorComponentStackCard(props: {
9 | componentStack: string;
10 | }) {
11 | const [collapsed, setCollapsed] = useState(true);
12 |
13 | let stack: string[];
14 | try {
15 | stack = parseComponentStack(props.componentStack);
16 | stack = collapsed ? stack.slice(0, 4) : stack;
17 | } catch {
18 | return;
19 | }
20 |
21 | return
22 |
23 |
24 | Component Stack
25 |
26 |
27 | {stack.map(component => (
28 |
29 | {"<"}
30 | {component}
31 | {"/>"}
32 |
33 | ))}
34 |
35 | {collapsed && ...}
36 |
37 | }
44 | onPress={() => setCollapsed(v => !v)} />
45 | clipboard.setString(props.componentStack)} />
50 |
51 |
52 | ;
53 | }
54 |
--------------------------------------------------------------------------------
/src/core/ui/reporter/components/ErrorDetailsActionSheet.tsx:
--------------------------------------------------------------------------------
1 | import { hasStack, isComponentStack } from "@core/ui/reporter/utils/isStack";
2 | import { Codeblock } from "@lib/ui/components";
3 | import { ActionSheet, Text } from "@metro/common/components";
4 | import { View } from "react-native";
5 |
6 | import ErrorComponentStackCard from "./ErrorComponentStackCard";
7 | import ErrorStackCard from "./ErrorStackCard";
8 |
9 | export default function ErrorDetailsActionSheet(props: {
10 | error: Error;
11 | }) {
12 | return
13 |
14 | Error
15 | {props.error.message}
16 | {hasStack(props.error) && }
17 | {isComponentStack(props.error) ? : null}
18 |
19 | ;
20 | }
21 |
--------------------------------------------------------------------------------
/src/core/ui/reporter/components/ErrorStackCard.tsx:
--------------------------------------------------------------------------------
1 | import parseErrorStack, { StackFrame } from "@core/ui/reporter/utils/parseErrorStack";
2 | import { findAssetId } from "@lib/api/assets";
3 | import { clipboard, constants } from "@metro/common";
4 | import { Button, Card, Text } from "@metro/common/components";
5 | import { useState } from "react";
6 | import { Image, Pressable, View } from "react-native";
7 |
8 | import { INDEX_BUNDLE_FILE } from "./ErrorCard";
9 |
10 | export default function ErrorStackCard(props: {
11 | error: Error & { stack: string };
12 | }) {
13 | const [collapsed, setCollapsed] = useState(true);
14 |
15 | let stack: StackFrame[];
16 |
17 | try {
18 | const parsedErrorStack = parseErrorStack(props.error.stack);
19 | stack = collapsed ? parsedErrorStack.slice(0, 4) : parsedErrorStack;
20 | } catch {
21 | return null;
22 | }
23 |
24 | return
25 |
26 |
27 | Call Stack
28 |
29 |
30 | {stack.map((f, id) => )}
31 |
32 | {collapsed && ...}
33 |
34 | }
41 | onPress={() => setCollapsed(v => !v)} />
42 | clipboard.setString(props.error.stack)} />
47 |
48 |
49 | ;
50 | }
51 | function Line(props: { id: number, frame: StackFrame }) {
52 | const [collapsed, setCollapsed] = useState(true);
53 |
54 | return setCollapsed(v => !v)} key={props.id}>
55 |
56 | {props.frame.methodName}
57 |
58 |
59 | {props.frame.file === INDEX_BUNDLE_FILE ? "jsbundle" : props.frame.file}:{props.frame.lineNumber}:{props.frame.column}
60 |
61 | ;
62 | }
63 |
64 |
--------------------------------------------------------------------------------
/src/core/ui/reporter/utils/isStack.tsx:
--------------------------------------------------------------------------------
1 | export function isComponentStack(error: Error): error is Error & { componentStack: string; } {
2 | return "componentStack" in error && typeof error.componentStack === "string";
3 | }
4 | export function hasStack(error: Error): error is Error & { stack: string; } {
5 | return !!error.stack;
6 | }
7 |
--------------------------------------------------------------------------------
/src/core/ui/reporter/utils/parseComponentStack.tsx:
--------------------------------------------------------------------------------
1 | export function parseComponentStack(componentStack: string) {
2 | return componentStack.split(/[\s|\n]+?in /).filter(Boolean);
3 | }
4 |
--------------------------------------------------------------------------------
/src/core/ui/settings/index.ts:
--------------------------------------------------------------------------------
1 | import PyoncordIcon from "@assets/icons/revenge.png";
2 | import { Strings } from "@core/i18n";
3 | import { useProxy } from "@core/vendetta/storage";
4 | import { findAssetId } from "@lib/api/assets";
5 | import { isFontSupported, isThemeSupported } from "@lib/api/native/loader";
6 | import { settings } from "@lib/api/settings";
7 | import { registerSection } from "@ui/settings";
8 | import { version } from "bunny-build-info";
9 |
10 | export { PyoncordIcon };
11 |
12 | export default function initSettings() {
13 |
14 | registerSection({
15 | name: Strings.BUNNY,
16 | items: [
17 | {
18 | key: "BUNNY",
19 | title: () => Strings.BUNNY,
20 | icon: { uri: PyoncordIcon },
21 | render: () => import("@core/ui/settings/pages/General"),
22 | useTrailing: () => `(${version})`
23 | },
24 | {
25 | key: "BUNNY_PLUGINS",
26 | title: () => Strings.PLUGINS,
27 | icon: findAssetId("ActivitiesIcon"),
28 | render: () => import("@core/ui/settings/pages/Plugins")
29 | },
30 | {
31 | key: "BUNNY_THEMES",
32 | title: () => Strings.THEMES,
33 | icon: findAssetId("PaintPaletteIcon"),
34 | render: () => import("@core/ui/settings/pages/Themes"),
35 | usePredicate: () => isThemeSupported()
36 | },
37 | {
38 | key: "BUNNY_FONTS",
39 | title: () => Strings.FONTS,
40 | icon: findAssetId("ic_add_text"),
41 | render: () => import("@core/ui/settings/pages/Fonts"),
42 | usePredicate: () => isFontSupported()
43 | },
44 | {
45 | key: "BUNNY_DEVELOPER",
46 | title: () => Strings.DEVELOPER,
47 | icon: findAssetId("WrenchIcon"),
48 | render: () => import("@core/ui/settings/pages/Developer"),
49 | usePredicate: () => useProxy(settings).developerSettings ?? false
50 | }
51 | ]
52 | });
53 |
54 | // Retain compatibility with plugins which inject into this section
55 | registerSection({
56 | name: "Bunny",
57 | items: []
58 | })
59 |
60 | // Compat for plugins which injects into the settings
61 | // Flaw: in the old UI, this will be displayed anyway with no items
62 | registerSection({
63 | name: "Vendetta",
64 | items: []
65 | });
66 | }
67 |
--------------------------------------------------------------------------------
/src/core/ui/settings/pages/Developer/AssetBrowser.tsx:
--------------------------------------------------------------------------------
1 | import AssetDisplay from "@core/ui/settings/pages/Developer/AssetDisplay";
2 | import { iterateAssets } from "@lib/api/assets";
3 | import { Text } from "@metro/common/components";
4 | import { ErrorBoundary, Search } from "@ui/components";
5 | import { useMemo } from "react";
6 | import { FlatList, View } from "react-native";
7 |
8 | export default function AssetBrowser() {
9 | const [search, setSearch] = React.useState("");
10 | const all = useMemo(() => Array.from(iterateAssets()), []);
11 |
12 | return (
13 |
14 |
15 | setSearch(v)}
18 | />
19 |
20 | Some assets types cannot be displayed and will be marked in red.
21 | a.name.includes(search) || a.id.toString() === search)}
23 | renderItem={({ item }: any) => }
24 | contentContainerStyle={{ overflow: 'hidden', backgroundColor: 'transparent', borderRadius: 16 }}
25 | keyExtractor={a => a.name}
26 | />
27 |
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/src/core/ui/settings/pages/Developer/AssetDisplay.tsx:
--------------------------------------------------------------------------------
1 | import { Asset, findAssetId } from "@lib/api/assets";
2 | import { lazyDestructure } from "@lib/utils/lazy";
3 | import { findByProps } from "@metro";
4 | import { clipboard } from "@metro/common";
5 | import { Stack, TableRow, Text } from "@metro/common/components";
6 | import { showToast } from "@ui/toasts";
7 | import { Image } from "react-native";
8 |
9 | const { openAlert } = lazyDestructure(() => findByProps("openAlert", "dismissAlert"));
10 | const { AlertModal, AlertActionButton } = lazyDestructure(() => findByProps("AlertModal", "AlertActions"));
11 |
12 | const displayable = new Set(['png', 'jpg', 'svg']);
13 |
14 | const iconMap = {
15 | jsona: 'ic_file_text',
16 | lottie: 'ic_image',
17 | webm: 'CirclePlayIcon-primary',
18 | ttf: 'ic_add_text',
19 | default: 'UnknownGameIcon'
20 | };
21 |
22 | interface AssetDisplayProps { asset: Asset; }
23 |
24 | export default function AssetDisplay({ asset }: AssetDisplayProps) {
25 |
26 | return (
27 |
34 | :
38 | }
39 | onPress={() =>
40 | openAlert("revenge-asset-display-details",
46 | : (
47 | Asset type {asset.type.toUpperCase()} is not supported for preview.
48 | )
49 | }
50 | actions={
51 |
52 | copyToClipboard(asset.name)} />
53 | copyToClipboard(asset.id.toString())} />
54 |
55 | }
56 | />)
57 | }
58 | />
59 | );
60 | }
61 |
62 | const copyToClipboard = (text: string) => {
63 | clipboard.setString(text);
64 | showToast.showCopyToClipboard();
65 | };
--------------------------------------------------------------------------------
/src/core/ui/settings/pages/Fonts/FontCard.tsx:
--------------------------------------------------------------------------------
1 | import { Strings } from "@core/i18n";
2 | import { CardWrapper } from "@core/ui/components/AddonCard";
3 | import { showConfirmationAlert } from "@core/vendetta/alerts";
4 | import { useProxy } from "@core/vendetta/storage";
5 | import { FontDefinition, fonts, selectFont } from "@lib/addons/fonts";
6 | import { findAssetId } from "@lib/api/assets";
7 | import { BundleUpdaterManager } from "@lib/api/native/modules";
8 | import { lazyDestructure } from "@lib/utils/lazy";
9 | import { findByProps } from "@metro";
10 | import { NavigationNative, tokens } from "@metro/common";
11 | import { Button, Card, IconButton, Stack, Text } from "@metro/common/components";
12 | import * as Skia from "@shopify/react-native-skia";
13 | import { TextStyleSheet } from "@ui/styles";
14 | import { useMemo } from "react";
15 | import { View } from "react-native";
16 |
17 | import FontEditor from "./FontEditor";
18 |
19 | const { useToken } = lazyDestructure(() => findByProps("useToken"));
20 |
21 | function FontPreview({ font }: { font: FontDefinition; }) {
22 | const TEXT_NORMAL = useToken(tokens.colors.TEXT_NORMAL);
23 | const { fontFamily: fontFamilyList, fontSize } = TextStyleSheet["text-md/medium"];
24 | const fontFamily = fontFamilyList!.split(/,/g)[0];
25 |
26 | const typeface = Skia.useFont(font.main[fontFamily])?.getTypeface();
27 |
28 | const paragraph = useMemo(() => {
29 | if (!typeface) return null;
30 |
31 | const fMgr = SkiaApi.TypefaceFontProvider.Make();
32 | fMgr.registerFont(typeface, fontFamily);
33 |
34 |
35 | return SkiaApi.ParagraphBuilder.Make({}, fMgr)
36 | .pushStyle({
37 | color: SkiaApi.Color(TEXT_NORMAL),
38 | fontFamilies: [fontFamily],
39 | fontSize,
40 | })
41 | .addText("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.")
42 | .pop()
43 | .build();
44 | }, [typeface]);
45 |
46 | return (
47 | // This does not work, actually :woeis:
48 |
49 | {typeface
50 | ?
51 |
52 |
53 | :
54 |
55 | Loading...
56 |
57 | }
58 |
59 | );
60 | }
61 |
62 | export default function FontCard({ item: font }: CardWrapper) {
63 | useProxy(fonts);
64 |
65 | const navigation = NavigationNative.useNavigation();
66 | const selected = fonts.__selected === font.name;
67 |
68 | return (
69 |
70 |
71 |
72 |
73 |
74 | {font.name}
75 |
76 | {/* TODO: Text wrapping doesn't work well */}
77 | {/*
78 | {font.description}
79 | */}
80 |
81 |
82 |
83 | {
85 | navigation.push("BUNNY_CUSTOM_PAGE", {
86 | title: "Edit Font",
87 | render: () =>
88 | });
89 | }}
90 | size="sm"
91 | variant="secondary"
92 | disabled={selected}
93 | icon={findAssetId("WrenchIcon")}
94 | />
95 | {
100 | await selectFont(selected ? null : font.name);
101 | showConfirmationAlert({
102 | title: Strings.HOLD_UP,
103 | content: "Reload Discord to apply changes?",
104 | confirmText: Strings.RELOAD,
105 | cancelText: Strings.CANCEL,
106 | confirmColor: "red",
107 | onConfirm: BundleUpdaterManager.reload
108 | });
109 | }}
110 | />
111 |
112 |
113 |
114 |
115 |
116 |
117 | );
118 | }
119 |
--------------------------------------------------------------------------------
/src/core/ui/settings/pages/Fonts/index.tsx:
--------------------------------------------------------------------------------
1 | import { Strings } from "@core/i18n";
2 | import AddonPage from "@core/ui/components/AddonPage";
3 | import FontEditor from "@core/ui/settings/pages/Fonts/FontEditor";
4 | import { useProxy } from "@core/vendetta/storage";
5 | import { FontDefinition, fonts } from "@lib/addons/fonts";
6 | import { settings } from "@lib/api/settings";
7 | import { NavigationNative } from "@metro/common";
8 |
9 | import FontCard from "./FontCard";
10 |
11 | export default function Fonts() {
12 | useProxy(settings);
13 | useProxy(fonts);
14 |
15 | const navigation = NavigationNative.useNavigation();
16 |
17 | return (
18 |
19 | title={Strings.FONTS}
20 | searchKeywords={["name", "description"]}
21 | sortOptions={{
22 | "Name (A-Z)": (a, b) => a.name.localeCompare(b.name),
23 | "Name (Z-A)": (a, b) => b.name.localeCompare(a.name)
24 | }}
25 | items={Object.values(fonts)}
26 | safeModeHint={{ message: Strings.SAFE_MODE_NOTICE_FONTS }}
27 | CardComponent={FontCard}
28 | installAction={{
29 | label: "Install a font",
30 | onPress: () => {
31 | navigation.push("BUNNY_CUSTOM_PAGE", {
32 | title: "Import Font",
33 | render: () =>
34 | });
35 | }
36 | }}
37 | />
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/core/ui/settings/pages/General/About.tsx:
--------------------------------------------------------------------------------
1 | import { Strings } from "@core/i18n";
2 | import { PyoncordIcon } from "@core/ui/settings";
3 | import Version from "@core/ui/settings/pages/General/Version";
4 | import { useProxy } from "@core/vendetta/storage";
5 | import { getDebugInfo } from "@lib/api/debug";
6 | import { settings } from "@lib/api/settings";
7 | import { Stack, TableRowGroup } from "@metro/common/components";
8 | import { Platform, ScrollView } from "react-native";
9 |
10 | export default function About() {
11 | const debugInfo = getDebugInfo();
12 | useProxy(settings);
13 |
14 | const versions = [
15 | {
16 | label: Strings.BUNNY,
17 | version: debugInfo.bunny.version,
18 | icon: { uri: PyoncordIcon },
19 | },
20 | {
21 | label: "Discord",
22 | version: `${debugInfo.discord.version} (${debugInfo.discord.build})`,
23 | icon: "Discord",
24 | },
25 | {
26 | label: "React",
27 | version: debugInfo.react.version,
28 | icon: "ScienceIcon",
29 | },
30 | {
31 | label: "React Native",
32 | version: debugInfo.react.nativeVersion,
33 | icon: "MobilePhoneIcon",
34 | },
35 | {
36 | label: Strings.BYTECODE,
37 | version: debugInfo.hermes.bytecodeVersion,
38 | icon: "TopicsIcon",
39 | },
40 | ];
41 |
42 | const platformInfo = [
43 | {
44 | label: Strings.LOADER,
45 | version: `${debugInfo.bunny.loader.name} (${debugInfo.bunny.loader.version})`,
46 | icon: "DownloadIcon",
47 | },
48 | {
49 | label: Strings.OPERATING_SYSTEM,
50 | version: `${debugInfo.os.name} ${debugInfo.os.version}`,
51 | icon: "ScreenIcon"
52 | },
53 | ...(debugInfo.os.sdk ? [{
54 | label: "SDK",
55 | version: debugInfo.os.sdk,
56 | icon: "StaffBadgeIcon"
57 | }] : []),
58 | {
59 | label: Strings.MANUFACTURER,
60 | version: debugInfo.device.manufacturer,
61 | icon: "WrenchIcon"
62 | },
63 | {
64 | label: Strings.BRAND,
65 | version: debugInfo.device.brand,
66 | icon: "SparklesIcon"
67 | },
68 | {
69 | label: Strings.MODEL,
70 | version: debugInfo.device.model,
71 | icon: "MobilePhoneIcon"
72 | },
73 | {
74 | label: Platform.select({ android: Strings.CODENAME, ios: Strings.MACHINE_ID })!,
75 | version: debugInfo.device.codename,
76 | icon: "TagIcon"
77 | }
78 | ];
79 |
80 | return (
81 |
82 |
83 |
84 | {versions.map(v => )}
85 |
86 |
87 | {platformInfo.map(p => )}
88 |
89 |
90 |
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/src/core/ui/settings/pages/General/Version.tsx:
--------------------------------------------------------------------------------
1 | import { findAssetId } from "@lib/api/assets";
2 | import { clipboard } from "@metro/common";
3 | import { TableRow, TableRowTrailingText } from "@metro/common/components";
4 | import { showToast } from "@ui/toasts";
5 | import { ImageURISource } from "react-native";
6 |
7 | interface VersionProps {
8 | label: string;
9 | version: string;
10 | icon: string | ImageURISource;
11 | }
12 |
13 | export default function Version({ label, version, icon }: VersionProps) {
14 | return (
15 | }
18 | icon={}
19 | onPress={() => {
20 | clipboard.setString(`${label} - ${version}`);
21 | showToast.showCopyToClipboard();
22 | }}
23 | />
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/core/ui/settings/pages/Plugins/models/bunny.ts:
--------------------------------------------------------------------------------
1 | import { PyoncordIcon } from "@core/ui/settings";
2 | import { disablePlugin, enablePlugin, getPluginSettingsComponent, isPluginEnabled, pluginSettings } from "@lib/addons/plugins";
3 | import { BunnyPluginManifest } from "@lib/addons/plugins/types";
4 | import { useObservable } from "@lib/api/storage";
5 |
6 | import { UnifiedPluginModel } from ".";
7 |
8 | export default function unifyBunnyPlugin(manifest: BunnyPluginManifest): UnifiedPluginModel {
9 | return {
10 | id: manifest.id,
11 | name: manifest.display.name,
12 | description: manifest.display.description,
13 | authors: manifest.display.authors,
14 |
15 | getBadges() {
16 | return [
17 | { source: { uri: PyoncordIcon } },
18 | // { source: findAssetId("CheckmarkLargeBoldIcon")! }
19 | ];
20 | },
21 | isEnabled: () => isPluginEnabled(manifest.id),
22 | isInstalled: () => manifest.id in pluginSettings,
23 | usePluginState() {
24 | useObservable([pluginSettings]);
25 | },
26 | toggle(start: boolean) {
27 | try {
28 | start
29 | ? enablePlugin(manifest.id, true)
30 | : disablePlugin(manifest.id);
31 | } catch (e) {
32 | console.error(e);
33 | // showToast("Failed to toggle plugin " + e, findAssetId("Small"));
34 | }
35 | },
36 | resolveSheetComponent() {
37 | return import("../sheets/PluginInfoActionSheet");
38 | },
39 | getPluginSettingsComponent() {
40 | return getPluginSettingsComponent(manifest.id);
41 | },
42 | };
43 | }
44 |
--------------------------------------------------------------------------------
/src/core/ui/settings/pages/Plugins/models/index.ts:
--------------------------------------------------------------------------------
1 | import { Author } from "@lib/addons/types";
2 | import { ImageSourcePropType } from "react-native";
3 |
4 | interface Badge {
5 | source: ImageSourcePropType;
6 | color?: string;
7 | onPress?: () => void;
8 | }
9 |
10 | export interface UnifiedPluginModel {
11 | id: string;
12 | name: string;
13 | description?: string;
14 | authors?: Author[];
15 | icon?: string;
16 | getBadges(): Badge[];
17 | isEnabled(): boolean;
18 | usePluginState(): void;
19 | isInstalled(): boolean;
20 | toggle(start: boolean): void;
21 | resolveSheetComponent(): Promise<{ default: React.ComponentType; }>;
22 | getPluginSettingsComponent(): React.ComponentType | null | undefined;
23 | }
24 |
--------------------------------------------------------------------------------
/src/core/ui/settings/pages/Plugins/models/vendetta.ts:
--------------------------------------------------------------------------------
1 | import { VdPluginManager, VendettaPlugin } from "@core/vendetta/plugins";
2 | import { useProxy } from "@core/vendetta/storage";
3 |
4 | import { UnifiedPluginModel } from ".";
5 |
6 | export default function unifyVdPlugin(vdPlugin: VendettaPlugin): UnifiedPluginModel {
7 | return {
8 | id: vdPlugin.id,
9 | name: vdPlugin.manifest.name,
10 | description: vdPlugin.manifest.description,
11 | authors: vdPlugin.manifest.authors,
12 | icon: vdPlugin.manifest.vendetta?.icon,
13 |
14 | getBadges() {
15 | return [];
16 | },
17 | isEnabled: () => vdPlugin.enabled,
18 | isInstalled: () => Boolean(vdPlugin && VdPluginManager.plugins[vdPlugin.id]),
19 | usePluginState() {
20 | useProxy(VdPluginManager.plugins[vdPlugin.id]);
21 | },
22 | toggle(start: boolean) {
23 | start
24 | ? VdPluginManager.startPlugin(vdPlugin.id)
25 | : VdPluginManager.stopPlugin(vdPlugin.id);
26 | },
27 | resolveSheetComponent() {
28 | return import("../sheets/VdPluginInfoActionSheet");
29 | },
30 | getPluginSettingsComponent() {
31 | return VdPluginManager.getSettings(vdPlugin.id);
32 | },
33 | };
34 | }
35 |
--------------------------------------------------------------------------------
/src/core/ui/settings/pages/Plugins/sheets/PluginInfoActionSheet.tsx:
--------------------------------------------------------------------------------
1 | import { startPlugin } from "@lib/addons/plugins";
2 | import { findAssetId } from "@lib/api/assets";
3 | import { hideSheet } from "@lib/ui/sheets";
4 | import { ActionSheet, Card, ContextMenu, IconButton, Text } from "@metro/common/components";
5 | import { ComponentProps } from "react";
6 | import { ScrollView, View } from "react-native";
7 |
8 | import { PluginInfoActionSheetProps } from "./common";
9 | import TitleComponent from "./TitleComponent";
10 |
11 | function PluginInfoIconButton(props: ComponentProps) {
12 | const { onPress } = props;
13 | props.onPress &&= () => {
14 | hideSheet("PluginInfoActionSheet");
15 | onPress?.();
16 | };
17 |
18 | return ;
19 | }
20 |
21 | export default function PluginInfoActionSheet({ plugin, navigation }: PluginInfoActionSheetProps) {
22 | plugin.usePluginState();
23 |
24 | return
25 |
26 |
27 |
28 | {
34 | }
35 | },
36 | // {
37 | // label: true ? "Disable Updates" : "Enable Updates",
38 | // iconSource: true ? findAssetId("ClockXIcon") : findAssetId("ClockIcon"),
39 | // action: () => {
40 |
41 | // }
42 | // },
43 | {
44 | label: "Clear Data",
45 | iconSource: findAssetId("FileIcon"),
46 | variant: "destructive",
47 | action: () => {
48 | }
49 | },
50 | {
51 | label: "Uninstall",
52 | iconSource: findAssetId("TrashIcon"),
53 | variant: "destructive",
54 | action: () => {
55 | }
56 | }
57 | ]}
58 | >
59 | {props => }
65 |
66 |
67 |
68 | {
74 | navigation.push("BUNNY_CUSTOM_PAGE", {
75 | title: plugin.name,
76 | render: plugin.getPluginSettingsComponent(),
77 | });
78 | }}
79 | />
80 | {
85 | startPlugin(plugin.id);
86 | }}
87 | />
88 | {
93 | }}
94 | />
95 |
96 |
97 |
98 | Description
99 |
100 |
101 | {plugin.description}
102 |
103 |
104 |
105 | ;
106 | }
107 |
--------------------------------------------------------------------------------
/src/core/ui/settings/pages/Plugins/sheets/TitleComponent.tsx:
--------------------------------------------------------------------------------
1 | import { UnifiedPluginModel } from "@core/ui/settings/pages/Plugins/models";
2 | import { lazyDestructure } from "@lib/utils/lazy";
3 | import { findByNameLazy, findByProps } from "@metro";
4 | import { FluxUtils } from "@metro/common";
5 | import { Avatar, AvatarPile, Text } from "@metro/common/components";
6 | import { UserStore } from "@metro/common/stores";
7 | import { View } from "react-native";
8 |
9 | const showUserProfileActionSheet = findByNameLazy("showUserProfileActionSheet");
10 | const { getUser: maybeFetchUser } = lazyDestructure(() => findByProps("getUser", "fetchProfile"));
11 |
12 | export default function TitleComponent({ plugin }: { plugin: UnifiedPluginModel; }) {
13 | const users: any[] = FluxUtils.useStateFromStoresArray([UserStore], () => {
14 | plugin.authors?.forEach(a => a.id && maybeFetchUser(a.id));
15 | return plugin.authors?.map(a => UserStore.getUser(a.id));
16 | });
17 |
18 | const { authors } = plugin;
19 | const authorTextNode = [];
20 |
21 | if (authors) {
22 | for (const author of authors) {
23 | authorTextNode.push( showUserProfileActionSheet({ userId: author.id })}
25 | variant="text-md/medium"
26 | >
27 | {author.name}
28 | );
29 |
30 | authorTextNode.push(", ");
31 | }
32 |
33 | authorTextNode.pop();
34 | }
35 |
36 | return
37 |
38 |
39 | {plugin.name}
40 |
41 |
42 |
43 | {authors?.length &&
44 | {users.length && a.name)}
47 | totalCount={plugin.authors?.length}
48 | >
49 | {users.map(a => )}
50 | }
51 |
52 | {authorTextNode}
53 |
54 | }
55 |
56 | ;
57 | }
58 |
--------------------------------------------------------------------------------
/src/core/ui/settings/pages/Plugins/sheets/common.ts:
--------------------------------------------------------------------------------
1 | import { UnifiedPluginModel } from "../models";
2 |
3 | export interface PluginInfoActionSheetProps {
4 | plugin: UnifiedPluginModel;
5 | navigation: any;
6 | }
7 |
--------------------------------------------------------------------------------
/src/core/ui/settings/pages/Plugins/usePluginCardStyles.ts:
--------------------------------------------------------------------------------
1 | import { tokens } from "@metro/common";
2 | import { createStyles } from "@ui/styles";
3 |
4 | export const usePluginCardStyles = createStyles({
5 | smallIcon: {
6 | tintColor: tokens.colors.LOGO_PRIMARY,
7 | height: 18,
8 | width: 18,
9 | },
10 | badgeIcon: {
11 | tintColor: tokens.colors.LOGO_PRIMARY,
12 | height: 12,
13 | width: 12,
14 | },
15 | badgesContainer: {
16 | flexWrap: "wrap",
17 | flexDirection: "row",
18 | gap: 6,
19 | borderRadius: 6,
20 | padding: 4
21 | }
22 | });
23 |
--------------------------------------------------------------------------------
/src/core/ui/settings/pages/Themes/ThemeCard.tsx:
--------------------------------------------------------------------------------
1 | import { formatString, Strings } from "@core/i18n";
2 | import AddonCard, { CardWrapper } from "@core/ui/components/AddonCard";
3 | import { showConfirmationAlert } from "@core/vendetta/alerts";
4 | import { useProxy } from "@core/vendetta/storage";
5 | import { fetchTheme, removeTheme, selectTheme, themes, VdThemeInfo } from "@lib/addons/themes";
6 | import { findAssetId } from "@lib/api/assets";
7 | import { settings } from "@lib/api/settings";
8 | import { clipboard } from "@metro/common";
9 | import { showToast } from "@ui/toasts";
10 |
11 | function selectAndApply(value: boolean, theme: VdThemeInfo) {
12 | try {
13 | selectTheme(value ? theme : null);
14 | } catch (e: any) {
15 | console.error("Error while selectAndApply,", e);
16 | }
17 | }
18 |
19 | export default function ThemeCard({ item: theme }: CardWrapper) {
20 | useProxy(theme);
21 |
22 | const [removed, setRemoved] = React.useState(false);
23 |
24 | // This is needed because of React™
25 | if (removed) return null;
26 |
27 | const { authors } = theme.data;
28 |
29 | return (
30 | i.name).join(", ")}` : ""}
33 | descriptionLabel={theme.data.description ?? "No description."}
34 | toggleType={!settings.safeMode?.enabled ? "radio" : undefined}
35 | toggleValue={() => themes[theme.id].selected}
36 | onToggleChange={(v: boolean) => {
37 | selectAndApply(v, theme);
38 | }}
39 | overflowTitle={theme.data.name}
40 | overflowActions={[
41 | {
42 | icon: "ic_sync_24px",
43 | label: Strings.REFETCH,
44 | onPress: () => {
45 | fetchTheme(theme.id, theme.selected).then(() => {
46 | showToast(Strings.THEME_REFETCH_SUCCESSFUL, findAssetId("toast_image_saved"));
47 | }).catch(() => {
48 | showToast(Strings.THEME_REFETCH_FAILED, findAssetId("Small"));
49 | });
50 | },
51 | },
52 | {
53 | icon: "copy",
54 | label: Strings.COPY_URL,
55 | onPress: () => {
56 | clipboard.setString(theme.id);
57 | showToast.showCopyToClipboard();
58 | }
59 | },
60 | {
61 | icon: "ic_message_delete",
62 | label: Strings.DELETE,
63 | isDestructive: true,
64 | onPress: () => showConfirmationAlert({
65 | title: Strings.HOLD_UP,
66 | content: formatString("ARE_YOU_SURE_TO_DELETE_THEME", { name: theme.data.name }),
67 | confirmText: Strings.DELETE,
68 | cancelText: Strings.CANCEL,
69 | confirmColor: "red",
70 | onConfirm: () => {
71 | removeTheme(theme.id).then(wasSelected => {
72 | setRemoved(true);
73 | if (wasSelected) selectAndApply(false, theme);
74 | }).catch((e: Error) => {
75 | showToast(e.message, findAssetId("Small"));
76 | });
77 | }
78 | })
79 | },
80 | ]}
81 | />
82 | );
83 | }
84 |
--------------------------------------------------------------------------------
/src/core/ui/settings/pages/Themes/index.tsx:
--------------------------------------------------------------------------------
1 | import { formatString, Strings } from "@core/i18n";
2 | import AddonPage from "@core/ui/components/AddonPage";
3 | import ThemeCard from "@core/ui/settings/pages/Themes/ThemeCard";
4 | import { useProxy } from "@core/vendetta/storage";
5 | import { getCurrentTheme, installTheme, themes, VdThemeInfo } from "@lib/addons/themes";
6 | import { colorsPref } from "@lib/addons/themes/colors/preferences";
7 | import { updateBunnyColor } from "@lib/addons/themes/colors/updater";
8 | import { Author } from "@lib/addons/types";
9 | import { findAssetId } from "@lib/api/assets";
10 | import { settings } from "@lib/api/settings";
11 | import { useObservable } from "@lib/api/storage";
12 | import { ActionSheet, BottomSheetTitleHeader, Button, TableRadioGroup, TableRadioRow, TableRowIcon } from "@metro/common/components";
13 | import { View } from "react-native";
14 |
15 | export default function Themes() {
16 | useProxy(settings);
17 | useProxy(themes);
18 |
19 | return (
20 |
21 | title={Strings.THEMES}
22 | searchKeywords={[
23 | "data.name",
24 | "data.description",
25 | p => p.data.authors?.map((a: Author) => a.name).join(", ") ?? ""
26 | ]}
27 | sortOptions={{
28 | "Name (A-Z)": (a, b) => a.data.name.localeCompare(b.data.name),
29 | "Name (Z-A)": (a, b) => b.data.name.localeCompare(a.data.name)
30 | }}
31 | installAction={{
32 | label: "Install a theme",
33 | fetchFn: installTheme
34 | }}
35 | items={Object.values(themes)}
36 | safeModeHint={{
37 | message: formatString("SAFE_MODE_NOTICE_THEMES", { enabled: Boolean(settings.safeMode?.currentThemeId) }),
38 | footer: settings.safeMode?.currentThemeId && delete settings.safeMode?.currentThemeId}
42 | style={{ marginTop: 8 }}
43 | />
44 | }}
45 | CardComponent={ThemeCard}
46 | OptionsActionSheetComponent={() => {
47 | useObservable([colorsPref]);
48 |
49 | return
50 |
51 |
52 | {
57 | colorsPref.type = type !== "auto" ? type as "dark" | "light" : undefined;
58 | getCurrentTheme()?.data && updateBunnyColor(getCurrentTheme()!.data!, { update: true });
59 | }}
60 | >
61 | } label="Auto" value="auto" />
62 | } label="Dark" value="dark" />
63 | } label="Light" value="light" />
64 |
65 | {
70 | colorsPref.customBackground = type !== "shown" ? type as "hidden" : null;
71 | }}
72 | >
73 | } label="Show" value={"shown"} />
74 | } label="Hide" value={"hidden"} />
75 |
76 |
77 | ;
78 | }}
79 | />
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/src/core/vendetta/Emitter.ts:
--------------------------------------------------------------------------------
1 | export enum Events {
2 | GET = "GET",
3 | SET = "SET",
4 | DEL = "DEL",
5 | }
6 |
7 | export type EmitterEvent = "SET" | "GET" | "DEL";
8 |
9 | export interface EmitterListenerData {
10 | path: string[];
11 | value?: any;
12 | }
13 |
14 | export type EmitterListener = (
15 | event: EmitterEvent,
16 | data: EmitterListenerData | any
17 | ) => any;
18 |
19 | export type EmitterListeners = Record>;
20 |
21 | export class Emitter {
22 | listeners = Object.values(Events).reduce(
23 | (acc, val: string) => ((acc[val] = new Set()), acc),
24 | {}
25 | ) as EmitterListeners;
26 |
27 | on(event: EmitterEvent, listener: EmitterListener) {
28 | if (!this.listeners[event].has(listener)) this.listeners[event].add(listener);
29 | }
30 |
31 | off(event: EmitterEvent, listener: EmitterListener) {
32 | this.listeners[event].delete(listener);
33 | }
34 |
35 | once(event: EmitterEvent, listener: EmitterListener) {
36 | const once = (event: EmitterEvent, data: EmitterListenerData) => {
37 | this.off(event, once);
38 | listener(event, data);
39 | };
40 | this.on(event, once);
41 | }
42 |
43 | emit(event: EmitterEvent, data: EmitterListenerData) {
44 | for (const listener of this.listeners[event]) listener(event, data);
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/core/vendetta/alerts.ts:
--------------------------------------------------------------------------------
1 | import { findByPropsLazy } from "@metro/wrappers";
2 | import InputAlert, { InputAlertProps } from "@ui/components/InputAlert";
3 |
4 | const Alerts = findByPropsLazy("openLazy", "close");
5 |
6 | interface InternalConfirmationAlertOptions extends Omit {
7 | content?: ConfirmationAlertOptions["content"];
8 | body?: ConfirmationAlertOptions["content"];
9 | }
10 |
11 | export interface ConfirmationAlertOptions {
12 | title?: string;
13 | content: string | JSX.Element | (string | JSX.Element)[];
14 | confirmText?: string;
15 | confirmColor?: string;
16 | onConfirm: () => void;
17 | secondaryConfirmText?: string;
18 | onConfirmSecondary?: () => void;
19 | cancelText?: string;
20 | onCancel?: () => void;
21 | isDismissable?: boolean;
22 | }
23 |
24 | export function showConfirmationAlert(options: ConfirmationAlertOptions) {
25 | const internalOptions = options as InternalConfirmationAlertOptions;
26 |
27 | internalOptions.body = options.content;
28 | delete internalOptions.content;
29 |
30 | internalOptions.isDismissable ??= true;
31 |
32 | return Alerts.show(internalOptions);
33 | }
34 |
35 | export const showCustomAlert = (component: React.ComponentType, props: any) => Alerts.openLazy({
36 | importer: async () => () => React.createElement(component, props),
37 | });
38 |
39 | export const showInputAlert = (options: InputAlertProps) => showCustomAlert(InputAlert, options);
40 |
--------------------------------------------------------------------------------
/src/entry.ts:
--------------------------------------------------------------------------------
1 | import { version } from "bunny-build-info";
2 | const { instead } = require("spitroast");
3 |
4 | // @ts-ignore - shut up fr
5 | globalThis.window = globalThis;
6 |
7 | async function initializeBunny() {
8 | try {
9 | // Make 'freeze' and 'seal' do nothing
10 | Object.freeze = Object.seal = Object;
11 |
12 | await require("@metro/internals/caches").initMetroCache();
13 | await require(".").default();
14 | } catch (e) {
15 | const { ClientInfoManager } = require("@lib/api/native/modules");
16 | const stack = e instanceof Error ? e.stack : undefined;
17 |
18 | console.log(stack ?? e?.toString?.() ?? e);
19 | alert([
20 | "Failed to load Revenge!\n",
21 | `Build Number: ${ClientInfoManager.Build}`,
22 | `Revenge: ${version}`,
23 | stack || e?.toString?.(),
24 | ].join("\n"));
25 | }
26 | }
27 |
28 | // @ts-ignore
29 | if (typeof globalThis.__r !== "undefined") {
30 | initializeBunny();
31 | } else {
32 | // We hold calls from the native side
33 | function onceIndexRequired(originalRequire: any) {
34 | const batchedBridge = window.__fbBatchedBridge;
35 |
36 | const callQueue = new Array;
37 | const unpatchHook = instead("callFunctionReturnFlushedQueue", batchedBridge, (args: any, orig: any) => {
38 | if (args[0] === "AppRegistry" || !batchedBridge.getCallableModule(args[0])) {
39 | callQueue.push(args);
40 | return batchedBridge.flushedQueue();
41 | }
42 |
43 | return orig.apply(batchedBridge, args);
44 | });
45 |
46 | const startDiscord = async () => {
47 | await initializeBunny();
48 | unpatchHook();
49 | originalRequire(0);
50 |
51 | callQueue.forEach(arg =>
52 | batchedBridge.getCallableModule(arg[0])
53 | && batchedBridge.__callFunction(...arg));
54 | };
55 |
56 | startDiscord();
57 | }
58 |
59 | var _requireFunc: any; // We can't set properties to 'this' during __r set for some reason
60 |
61 | Object.defineProperties(globalThis, {
62 | __r: {
63 | configurable: true,
64 | get: () => _requireFunc,
65 | set(v) {
66 | _requireFunc = function patchedRequire(a: number) {
67 | // Initializing index.ts(x)
68 | if (a === 0) {
69 | if (window.modules instanceof Map) window.modules = Object.fromEntries(window.modules);
70 | onceIndexRequired(v);
71 | _requireFunc = v;
72 | } else return v(a);
73 | };
74 | }
75 | },
76 | __d: {
77 | configurable: true,
78 | get() {
79 | // @ts-ignore - I got an error where 'Object' is undefined *sometimes*, which is literally never supposed to happen
80 | if (window.Object && !window.modules) {
81 | window.modules = window.__c?.();
82 | }
83 | return this.value;
84 | },
85 | set(v) { this.value = v; }
86 | }
87 | });
88 | }
89 |
--------------------------------------------------------------------------------
/src/global.d.ts:
--------------------------------------------------------------------------------
1 | declare global {
2 | type React = typeof import("react");
3 | var SkiaApi: typeof import("@shopify/react-native-skia").Skia;
4 |
5 | // ReactNative/Hermes globals
6 | var globalEvalWithSourceUrl: (script: string, sourceURL: string) => any;
7 | var nativePerformanceNow: typeof performance.now;
8 | var nativeModuleProxy: Record;
9 |
10 | interface Window {
11 | [key: string]: any;
12 | vendetta: any;
13 | bunny: typeof import("@lib");
14 | }
15 | }
16 |
17 | export { };
18 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import patchErrorBoundary from "@core/debug/patches/patchErrorBoundary";
2 | import initFixes from "@core/fixes";
3 | import { initFetchI18nStrings } from "@core/i18n";
4 | import initSettings from "@core/ui/settings";
5 | import { initVendettaObject } from "@core/vendetta/api";
6 | import { VdPluginManager } from "@core/vendetta/plugins";
7 | import { updateFonts } from "@lib/addons/fonts";
8 | import { initPlugins, updatePlugins } from "@lib/addons/plugins";
9 | import { initThemes } from "@lib/addons/themes";
10 | import { patchCommands } from "@lib/api/commands";
11 | import { patchLogHook } from "@lib/api/debug";
12 | import { injectFluxInterceptor } from "@lib/api/flux";
13 | import { patchJsx } from "@lib/api/react/jsx";
14 | import { logger } from "@lib/utils/logger";
15 | import { patchSettings } from "@ui/settings";
16 |
17 | import * as lib from "./lib";
18 |
19 | export default async () => {
20 | // Load everything in parallel
21 | await Promise.all([
22 | initThemes(),
23 | injectFluxInterceptor(),
24 | patchSettings(),
25 | patchLogHook(),
26 | patchCommands(),
27 | patchJsx(),
28 | initVendettaObject(),
29 | initFetchI18nStrings(),
30 | initSettings(),
31 | initFixes(),
32 | patchErrorBoundary(),
33 | updatePlugins()
34 | ]).then(
35 | // Push them all to unloader
36 | u => u.forEach(f => f && lib.unload.push(f))
37 | );
38 |
39 | // Assign window object
40 | window.bunny = lib;
41 |
42 | // Once done, load Vendetta plugins
43 | VdPluginManager.initPlugins()
44 | .then(u => lib.unload.push(u))
45 | .catch(() => alert("Failed to initialize Vendetta plugins"));
46 |
47 | // And then, load Bunny plugins
48 | initPlugins();
49 |
50 | // Update the fonts
51 | updateFonts();
52 |
53 | // We good :)
54 | logger.log("Revenge is ready!");
55 | };
56 |
--------------------------------------------------------------------------------
/src/lib/addons/fonts/index.ts:
--------------------------------------------------------------------------------
1 | import { awaitStorage, createMMKVBackend, createStorage, wrapSync } from "@core/vendetta/storage";
2 | import { clearFolder, downloadFile, fileExists, removeFile, writeFile } from "@lib/api/native/fs";
3 | import { safeFetch } from "@lib/utils";
4 |
5 | type FontMap = Record;
6 |
7 | export type FontDefinition = {
8 | spec: 1;
9 | name: string;
10 | description?: string;
11 | main: FontMap;
12 | source?: string
13 | }
14 |
15 | type FontStorage = Record & { __selected?: string; };
16 | export const fonts = wrapSync(createStorage(createMMKVBackend("BUNNY_FONTS")));
17 |
18 | async function writeFont(font: FontDefinition | null) {
19 | if (!font && font !== null) throw new Error("Arg font must be a valid object or null");
20 | if (font) {
21 | await writeFile("fonts.json", JSON.stringify(font));
22 | } else {
23 | await removeFile("fonts.json");
24 | }
25 | }
26 |
27 | export function validateFont(font: FontDefinition) {
28 | if (!font || typeof font !== "object") throw new Error("URL returned a null/non-object JSON");
29 | if (typeof font.spec !== "number") throw new Error("Invalid font 'spec' number");
30 | if (font.spec !== 1) throw new Error("Only fonts which follows spec:1 are supported");
31 |
32 | const requiredFields = ["name", "main"] as const;
33 |
34 | if (requiredFields.some(f => !font[f])) throw new Error(`Font is missing one of the fields: ${requiredFields}`);
35 | if (font.name.startsWith("__")) throw new Error("Font names cannot start with __");
36 | if (font.name in fonts) throw new Error(`There is already a font named '${font.name}' installed`);
37 | }
38 |
39 | export async function saveFont(data: string | FontDefinition, selected = false) {
40 | let fontDefJson: FontDefinition;
41 |
42 | if (typeof data === "string") {
43 | try {
44 | fontDefJson = await (await safeFetch(data)).json();
45 | } catch (e) {
46 | throw new Error(`Failed to fetch fonts at ${data}`, { cause: e });
47 | }
48 | } else {
49 | fontDefJson = data;
50 | }
51 |
52 | validateFont(fontDefJson);
53 |
54 | const errors = await Promise.allSettled(Object.entries(fontDefJson.main).map(async ([font, url]) => {
55 | let ext = url.split(".").pop();
56 | if (ext !== "ttf" && ext !== "otf") ext = "ttf";
57 | const path = `downloads/fonts/${fontDefJson.name}/${font}.${ext}`;
58 | if (!await fileExists(path)) await downloadFile(url, path);
59 | })).then(it => it.map(it => it.status === 'fulfilled' ? undefined : it.reason));
60 |
61 | if (errors.some(it => it)) throw errors
62 |
63 | fonts[fontDefJson.name] = fontDefJson;
64 |
65 | if (selected) writeFont(fonts[fontDefJson.name]);
66 | return fontDefJson;
67 | }
68 |
69 | export async function updateFont(fontDef: FontDefinition) {
70 | let fontDefCopy = { ...fontDef }
71 | if (fontDefCopy.source) fontDefCopy = {
72 | ...await fetch(fontDefCopy.source).then(it => it.json()),
73 | // Can't change these properties
74 | name: fontDef.name,
75 | source: fontDef.source
76 | }
77 |
78 | const selected = fonts.__selected === fontDef.name
79 | await removeFont(fontDef.name)
80 | await saveFont(fontDefCopy, selected)
81 | }
82 |
83 | export async function installFont(url: string, selected = false) {
84 | const font = await saveFont(url);
85 | if (selected) await selectFont(font.name);
86 | }
87 |
88 | export async function selectFont(name: string | null) {
89 | if (name && !(name in fonts)) throw new Error("Selected font does not exist!");
90 |
91 | if (name) {
92 | fonts.__selected = name;
93 | } else {
94 | delete fonts.__selected;
95 | }
96 | await writeFont(name == null ? null : fonts[name]);
97 | }
98 |
99 | export async function removeFont(name: string) {
100 | const selected = fonts.__selected === name;
101 | if (selected) await selectFont(null);
102 | delete fonts[name];
103 | try {
104 | await clearFolder(`downloads/fonts/${name}`);
105 | } catch {
106 | // ignore
107 | }
108 | }
109 |
110 | export async function updateFonts() {
111 | await awaitStorage(fonts);
112 | await Promise.allSettled(
113 | Object.keys(fonts).map(
114 | name => saveFont(fonts[name], fonts.__selected === name)
115 | )
116 | );
117 | }
118 |
--------------------------------------------------------------------------------
/src/lib/addons/plugins/api.ts:
--------------------------------------------------------------------------------
1 | import { patcher } from "@lib/api";
2 | import { registerCommand } from "@lib/api/commands";
3 | import { createStorage } from "@lib/api/storage";
4 | import { logger } from "@lib/utils/logger";
5 |
6 | import { registeredPlugins } from ".";
7 | import { BunnyPluginObject } from "./types";
8 |
9 | type DisposableFn = (...props: any[]) => () => unknown;
10 | function shimDisposableFn(unpatches: (() => void)[], f: F): F {
11 | const dummy = ((...props: Parameters) => {
12 | const up = f(...props);
13 | unpatches.push(up);
14 | return up;
15 | }) as F;
16 |
17 | for (const key in f) if (typeof f[key] === "function") {
18 | // @ts-ignore
19 | dummy[key] = shimDisposableFn(unpatches, f[key] as DisposableFn);
20 | }
21 |
22 | return dummy;
23 | }
24 |
25 | export function createBunnyPluginApi(id: string) {
26 | const disposers = new Array;
27 |
28 | // proxying this would be a good idea
29 | const object = {
30 | ...window.bunny,
31 | api: {
32 | ...window.bunny.api,
33 | patcher: {
34 | before: shimDisposableFn(disposers, patcher.before),
35 | after: shimDisposableFn(disposers, patcher.after),
36 | instead: shimDisposableFn(disposers, patcher.instead)
37 | },
38 | commands: {
39 | ...window.bunny.api.commands,
40 | registerCommand: shimDisposableFn(disposers, registerCommand)
41 | },
42 | flux: {
43 | ...window.bunny.api.flux,
44 | intercept: shimDisposableFn(disposers, window.bunny.api.flux.intercept)
45 | }
46 | },
47 | // Added something in here? Make sure to also update BunnyPluginProperty in ./types
48 | plugin: {
49 | createStorage: () => createStorage(`plugins/storage/${id}.json`),
50 | manifest: registeredPlugins.get(id),
51 | logger
52 | }
53 | } as unknown as BunnyPluginObject;
54 |
55 | return {
56 | object,
57 | disposers,
58 | };
59 | }
60 |
--------------------------------------------------------------------------------
/src/lib/addons/plugins/types.ts:
--------------------------------------------------------------------------------
1 | import { BunnyManifest } from "@lib/addons/types";
2 | import { createStorage } from "@lib/api/storage";
3 | import { Logger } from "@lib/utils/logger";
4 |
5 | export type PluginRepo = Record & {
10 | $meta: {
11 | name: string;
12 | description: string;
13 | };
14 | };
15 |
16 | export interface PluginRepoStorage {
17 | [repoUrl: string]: PluginRepo;
18 | }
19 |
20 | export interface PluginSettingsStorage {
21 | [pluginId: string]: {
22 | enabled: boolean;
23 | };
24 | }
25 |
26 | export interface BunnyPluginManifest extends BunnyManifest {
27 | readonly type: "plugin";
28 | readonly spec: 3;
29 | readonly main: string;
30 | }
31 |
32 | export interface BunnyPluginManifestInternal extends BunnyPluginManifest {
33 | readonly parentRepository: string;
34 | readonly jsPath?: string;
35 | }
36 |
37 | export interface PluginInstance {
38 | start?(): void;
39 | stop?(): void;
40 | SettingsComponent?(): JSX.Element;
41 | }
42 |
43 | export interface PluginInstanceInternal extends PluginInstance {
44 | readonly manifest: BunnyPluginManifest;
45 | }
46 |
47 | export interface BunnyPluginProperty {
48 | readonly manifest: BunnyPluginManifestInternal;
49 | readonly logger: Logger;
50 | createStorage(): ReturnType>;
51 | }
52 |
53 | export type BunnyPluginObject = typeof window.bunny & {
54 | plugin: BunnyPluginProperty;
55 | };
56 |
--------------------------------------------------------------------------------
/src/lib/addons/themes/colors/index.ts:
--------------------------------------------------------------------------------
1 |
2 | import patchChatBackground from "./patches/background";
3 | import patchDefinitionAndResolver from "./patches/resolver";
4 | import patchStorage from "./patches/storage";
5 | import { ColorManifest } from "./types";
6 | import { updateBunnyColor } from "./updater";
7 |
8 | /** @internal */
9 | export default function initColors(manifest: ColorManifest | null) {
10 | const patches = [
11 | patchStorage(),
12 | patchDefinitionAndResolver(),
13 | patchChatBackground()
14 | ];
15 |
16 | if (manifest) updateBunnyColor(manifest, { update: false });
17 |
18 | return () => patches.forEach(p => p());
19 | }
20 |
--------------------------------------------------------------------------------
/src/lib/addons/themes/colors/patches/background.tsx:
--------------------------------------------------------------------------------
1 | import { colorsPref } from "@lib/addons/themes/colors/preferences";
2 | import { _colorRef } from "@lib/addons/themes/colors/updater";
3 | import { after } from "@lib/api/patcher";
4 | import { useObservable } from "@lib/api/storage";
5 | import { findInReactTree } from "@lib/utils";
6 | import { findByFilePathLazy } from "@metro";
7 | import chroma from "chroma-js";
8 | import { ImageBackground, StyleSheet } from "react-native";
9 |
10 | const Messages = findByFilePathLazy("components_native/chat/Messages.tsx", true);
11 |
12 | function ThemeBackground({ children }: { children: React.ReactNode }) {
13 | useObservable([colorsPref]);
14 |
15 | if (!_colorRef.current
16 | || colorsPref.customBackground === "hidden"
17 | || !_colorRef.current.background?.url
18 | || _colorRef.current.background?.blur && (typeof _colorRef.current.background?.blur !== "number")
19 | ){
20 | return children;
21 | }
22 |
23 | return
28 | {children}
29 | ;
30 | }
31 |
32 | export default function patchChatBackground() {
33 | const patches = [
34 | after("render", Messages, (_, ret) => {
35 | if (!_colorRef.current || !_colorRef.current.background?.url) return;
36 | const messagesComponent = findInReactTree(
37 | ret,
38 | x => x && "HACK_fixModalInteraction" in x.props && x?.props?.style
39 | );
40 |
41 | if (messagesComponent) {
42 | const flattened = StyleSheet.flatten(messagesComponent.props.style);
43 | const backgroundColor = chroma(
44 | flattened.backgroundColor || "black"
45 | ).alpha(
46 | 1 - (_colorRef.current.background?.opacity ?? 1)
47 | ).hex();
48 |
49 | messagesComponent.props.style = StyleSheet.flatten([
50 | messagesComponent.props.style,
51 | { backgroundColor }
52 | ]);
53 | }
54 |
55 | return {ret};
56 | })
57 | ];
58 |
59 | return () => patches.forEach(x => x());
60 | }
61 |
--------------------------------------------------------------------------------
/src/lib/addons/themes/colors/patches/resolver.ts:
--------------------------------------------------------------------------------
1 | import { _colorRef } from "@lib/addons/themes/colors/updater";
2 | import { NativeThemeModule } from "@lib/api/native/modules";
3 | import { before, instead } from "@lib/api/patcher";
4 | import { findByProps } from "@metro";
5 | import { byMutableProp } from "@metro/filters";
6 | import { createLazyModule } from "@metro/lazy";
7 | import chroma from "chroma-js";
8 |
9 | const tokenReference = findByProps("SemanticColor");
10 | const isThemeModule = createLazyModule(byMutableProp("isThemeDark"));
11 |
12 | const SEMANTIC_FALLBACK_MAP: Record = {
13 | "BG_BACKDROP": "BACKGROUND_FLOATING",
14 | "BG_BASE_PRIMARY": "BACKGROUND_PRIMARY",
15 | "BG_BASE_SECONDARY": "BACKGROUND_SECONDARY",
16 | "BG_BASE_TERTIARY": "BACKGROUND_SECONDARY_ALT",
17 | "BG_MOD_FAINT": "BACKGROUND_MODIFIER_ACCENT",
18 | "BG_MOD_STRONG": "BACKGROUND_MODIFIER_ACCENT",
19 | "BG_MOD_SUBTLE": "BACKGROUND_MODIFIER_ACCENT",
20 | "BG_SURFACE_OVERLAY": "BACKGROUND_FLOATING",
21 | "BG_SURFACE_OVERLAY_TMP": "BACKGROUND_FLOATING",
22 | "BG_SURFACE_RAISED": "BACKGROUND_MOBILE_PRIMARY"
23 | };
24 |
25 | export default function patchDefinitionAndResolver() {
26 | const callback = ([theme]: any[]) => theme === _colorRef.key ? [_colorRef.current!.reference] : void 0;
27 |
28 | Object.keys(tokenReference.RawColor).forEach(key => {
29 | Object.defineProperty(tokenReference.RawColor, key, {
30 | configurable: true,
31 | enumerable: true,
32 | get: () => {
33 | const ret = _colorRef.current?.raw[key];
34 | return ret || _colorRef.origRaw[key];
35 | }
36 | });
37 | });
38 |
39 | const unpatches = [
40 | before("isThemeDark", isThemeModule, callback),
41 | before("isThemeLight", isThemeModule, callback),
42 | before("updateTheme", NativeThemeModule, callback),
43 | instead("resolveSemanticColor", tokenReference.default.meta ?? tokenReference.default.internal, (args: any[], orig: any) => {
44 | if (!_colorRef.current) return orig(...args);
45 | if (args[0] !== _colorRef.key) return orig(...args);
46 |
47 | args[0] = _colorRef.current.reference;
48 |
49 | const [name, colorDef] = extractInfo(_colorRef.current!.reference, args[1]);
50 |
51 | let semanticDef = _colorRef.current.semantic[name];
52 | if (!semanticDef && _colorRef.current.spec === 2 && name in SEMANTIC_FALLBACK_MAP) {
53 | semanticDef = _colorRef.current.semantic[SEMANTIC_FALLBACK_MAP[name]];
54 | }
55 |
56 | if (semanticDef?.value) {
57 | if (semanticDef.opacity === 1) return semanticDef.value;
58 | return chroma(semanticDef.value).alpha(semanticDef.opacity).hex();
59 | }
60 |
61 | const rawValue = _colorRef.current.raw[colorDef.raw];
62 | if (rawValue) {
63 | // Set opacity if needed
64 | return colorDef.opacity === 1 ? rawValue : chroma(rawValue).alpha(colorDef.opacity).hex();
65 | }
66 |
67 | // Fallback to default
68 | return orig(...args);
69 | }),
70 | () => {
71 | // Not the actual module but.. yeah.
72 | Object.defineProperty(tokenReference, "RawColor", {
73 | configurable: true,
74 | writable: true,
75 | value: _colorRef.origRaw
76 | });
77 | }
78 | ];
79 |
80 | return () => unpatches.forEach(p => p());
81 | }
82 |
83 | function extractInfo(themeName: string, colorObj: any): [name: string, colorDef: any] {
84 | // @ts-ignore - assigning to extractInfo._sym
85 | const propName = colorObj[extractInfo._sym ??= Object.getOwnPropertySymbols(colorObj)[0]];
86 | const colorDef = tokenReference.SemanticColor[propName];
87 |
88 | return [propName, colorDef[themeName]];
89 | }
90 |
--------------------------------------------------------------------------------
/src/lib/addons/themes/colors/patches/storage.ts:
--------------------------------------------------------------------------------
1 | import { _colorRef } from "@lib/addons/themes/colors/updater";
2 | import { after, before } from "@lib/api/patcher";
3 | import { findInTree } from "@lib/utils";
4 | import { proxyLazy } from "@lib/utils/lazy";
5 | import { findByProps } from "@metro";
6 |
7 | const mmkvStorage = proxyLazy(() => {
8 | const newModule = findByProps("impl");
9 | if (typeof newModule?.impl === "object") return newModule.impl;
10 | return findByProps("storage");
11 | });
12 |
13 | export default function patchStorage() {
14 | const patchedKeys = new Set(["ThemeStore", "SelectivelySyncedUserSettingsStore"]);
15 |
16 | const patches = [
17 | after("get", mmkvStorage, ([key], ret) => {
18 | if (!_colorRef.current || !patchedKeys.has(key)) return;
19 |
20 | const state = findInTree(ret._state, s => typeof s.theme === "string");
21 | if (state) state.theme = _colorRef.key;
22 | }),
23 | before("set", mmkvStorage, ([key, value]) => {
24 | if (!patchedKeys.has(key)) return;
25 |
26 | const json = JSON.stringify(value);
27 | const lastSetDiscordTheme = _colorRef.lastSetDiscordTheme ?? "darker";
28 | const replaced = json.replace(
29 | /"theme":"bn-theme-\d+"/,
30 | `"theme":${JSON.stringify(lastSetDiscordTheme)}`
31 | );
32 |
33 | return [key, JSON.parse(replaced)];
34 | })
35 | ];
36 |
37 | return () => patches.forEach(p => p());
38 | }
39 |
--------------------------------------------------------------------------------
/src/lib/addons/themes/colors/preferences.ts:
--------------------------------------------------------------------------------
1 | import { createStorage } from "@lib/api/storage";
2 |
3 | interface BunnyColorPreferencesStorage {
4 | selected: string | null;
5 | type?: "dark" | "light" | null;
6 | customBackground: "hidden" | null;
7 | per?: Record;
8 | }
9 |
10 | export const colorsPref = createStorage(
11 | "themes/colors/preferences.json",
12 | {
13 | dflt: {
14 | selected: null,
15 | customBackground: null
16 | }
17 | }
18 | );
19 |
--------------------------------------------------------------------------------
/src/lib/addons/themes/colors/types.ts:
--------------------------------------------------------------------------------
1 | import { Author, BunnyManifest } from "@lib/addons/types";
2 |
3 | interface SemanticReference {
4 | type: "color" | "raw";
5 | value: string;
6 | opacity?: number;
7 | }
8 |
9 | interface BackgroundDefinition {
10 | url: string;
11 | blur?: number;
12 | opacity?: number;
13 | }
14 |
15 | export interface BunnyColorManifest extends BunnyManifest {
16 | type: "color";
17 | spec: 3;
18 | main: {
19 | type: "dark" | "light";
20 | semantic?: Record;
21 | raw?: Record;
22 | background?: BackgroundDefinition;
23 | }
24 | }
25 |
26 | export interface VendettaThemeManifest {
27 | spec: 2;
28 | name: string;
29 | description?: string;
30 | authors?: Author[];
31 | semanticColors?: Record;
32 | rawColors?: Record;
33 | background?: {
34 | url: string;
35 | blur?: number;
36 | alpha?: number;
37 | };
38 | }
39 |
40 | /** @internal */
41 | export interface InternalColorDefinition {
42 | spec: 2 | 3;
43 | reference: "darker" | "light";
44 | semantic: Record;
48 | raw: Record;
49 | background?: BackgroundDefinition;
50 | }
51 |
52 | export type ColorManifest = BunnyColorManifest | VendettaThemeManifest;
53 |
--------------------------------------------------------------------------------
/src/lib/addons/themes/colors/updater.ts:
--------------------------------------------------------------------------------
1 | import { settings } from "@lib/api/settings";
2 | import { findByProps, findByPropsLazy, findByStoreNameLazy } from "@metro";
3 |
4 | import { parseColorManifest } from "./parser";
5 | import { ColorManifest, InternalColorDefinition } from "./types";
6 |
7 | const tokenRef = findByProps("SemanticColor");
8 | const origRawColor = { ...tokenRef.RawColor };
9 | const AppearanceManager = findByPropsLazy("updateTheme");
10 | const ThemeStore = findByStoreNameLazy("ThemeStore");
11 | const FormDivider = findByPropsLazy("DIVIDER_COLORS");
12 |
13 | let _inc = 1;
14 |
15 | interface InternalColorRef {
16 | key: `bn-theme-${string}`;
17 | current: InternalColorDefinition | null;
18 | readonly origRaw: Record;
19 | lastSetDiscordTheme: string;
20 | }
21 |
22 | /** @internal */
23 | export const _colorRef: InternalColorRef = {
24 | current: null,
25 | key: `bn-theme-${_inc}`,
26 | origRaw: origRawColor,
27 | lastSetDiscordTheme: "darker"
28 | };
29 |
30 | export function updateBunnyColor(colorManifest: ColorManifest | null, { update = true }) {
31 | if (settings.safeMode?.enabled) return;
32 |
33 | const internalDef = colorManifest ? parseColorManifest(colorManifest) : null;
34 | const ref = Object.assign(_colorRef, {
35 | current: internalDef,
36 | key: `bn-theme-${++_inc}`,
37 | lastSetDiscordTheme: !ThemeStore.theme.startsWith("bn-theme-")
38 | ? ThemeStore.theme
39 | : _colorRef.lastSetDiscordTheme
40 | });
41 |
42 | if (internalDef != null) {
43 | tokenRef.Theme[ref.key.toUpperCase()] = ref.key;
44 | FormDivider.DIVIDER_COLORS[ref.key] = FormDivider.DIVIDER_COLORS[ref.current!.reference];
45 |
46 | Object.keys(tokenRef.Shadow).forEach(k => tokenRef.Shadow[k][ref.key] = tokenRef.Shadow[k][ref.current!.reference]);
47 | Object.keys(tokenRef.SemanticColor).forEach(k => {
48 | tokenRef.SemanticColor[k][ref.key] = {
49 | ...tokenRef.SemanticColor[k][ref.current!.reference]
50 | };
51 | });
52 | }
53 |
54 | if (update) {
55 | AppearanceManager.setShouldSyncAppearanceSettings(false);
56 | AppearanceManager.updateTheme(internalDef != null ? ref.key : ref.lastSetDiscordTheme);
57 | }
58 | }
59 |
60 |
--------------------------------------------------------------------------------
/src/lib/addons/types.ts:
--------------------------------------------------------------------------------
1 | export type Author = { name: string, id?: `${bigint}`; };
2 |
3 | export interface BunnyManifest {
4 | readonly id: string;
5 | readonly spec: number;
6 | readonly version: string;
7 | readonly type: string;
8 | readonly display: {
9 | readonly name: string;
10 | readonly description?: string;
11 | readonly authors?: Author[];
12 | };
13 | readonly main: unknown;
14 | readonly extras?: {
15 | readonly [key: string]: any;
16 | };
17 | }
18 |
--------------------------------------------------------------------------------
/src/lib/api/assets/index.ts:
--------------------------------------------------------------------------------
1 | import { getMetroCache } from "@metro/internals/caches";
2 | import { ModuleFlags } from "@metro/internals/enums";
3 | import { requireModule } from "@metro/internals/modules";
4 |
5 | import { assetsModule } from "./patches";
6 |
7 | export interface Asset {
8 | id: number;
9 | name: string;
10 | moduleId: number;
11 | type: string;
12 | }
13 |
14 | // Cache common usage
15 | const _nameToAssetCache = {} as Record;
16 |
17 | export function* iterateAssets() {
18 | const { flagsIndex } = getMetroCache();
19 | const yielded = new Set();
20 |
21 | for (const id in flagsIndex) {
22 | if (flagsIndex[id] & ModuleFlags.ASSET) {
23 | const assetId = requireModule(Number(id));
24 | if (typeof assetId !== "number" || yielded.has(assetId)) continue;
25 | yield getAssetById(assetId);
26 | yielded.add(assetId);
27 | }
28 | }
29 | }
30 |
31 | // Apply additional properties for convenience
32 | function getAssetById(id: number): Asset {
33 | const asset = assetsModule.getAssetByID(id);
34 | if (!asset) return asset;
35 | return Object.assign(asset, { id });
36 | }
37 |
38 | /**
39 | * Returns the first asset registry by its registry id (number), name (string) or given filter (function)
40 | */
41 | export function findAsset(id: number): Asset | undefined;
42 | export function findAsset(name: string): Asset | undefined;
43 | export function findAsset(filter: (a: Asset) => boolean): Asset | undefined;
44 |
45 | export function findAsset(param: number | string | ((a: Asset) => boolean)) {
46 | if (typeof param === "number") return getAssetById(param);
47 |
48 | if (typeof param === "string" && _nameToAssetCache[param]) {
49 | return _nameToAssetCache[param];
50 | }
51 |
52 | for (const asset of iterateAssets()) {
53 | if (typeof param === "string" && asset.name === param) {
54 | _nameToAssetCache[param] = asset;
55 | return asset;
56 | } else if (typeof param === "function" && param(asset)) {
57 | return asset;
58 | }
59 | }
60 | }
61 |
62 | export function filterAssets(param: string | ((a: Asset) => boolean)) {
63 | const filteredAssets = [] as Array;
64 |
65 | for (const asset of iterateAssets()) {
66 | if (typeof param === "string" ? asset.name === param : param(asset)) {
67 | filteredAssets.push(asset);
68 | }
69 | }
70 |
71 | return filteredAssets;
72 | }
73 |
74 | /**
75 | * Returns the first asset ID in the registry with the given name
76 | */
77 | export function findAssetId(name: string) {
78 | return findAsset(name)?.id;
79 | }
80 |
--------------------------------------------------------------------------------
/src/lib/api/assets/patches.ts:
--------------------------------------------------------------------------------
1 | import { after } from "@lib/api/patcher";
2 | import { indexAssetModuleFlag } from "@metro/internals/caches";
3 | import { getImportingModuleId } from "@metro/internals/modules";
4 |
5 | interface AssetModule {
6 | registerAsset(assetDefinition: any): number;
7 | getAssetByID(id: number): any;
8 | }
9 |
10 | export let assetsModule: AssetModule;
11 |
12 | /**
13 | * @internal
14 | */
15 | export function patchAssets(module: AssetModule) {
16 | if (assetsModule) return;
17 | assetsModule = module;
18 |
19 | const unpatch = after("registerAsset", assetsModule, () => {
20 | const moduleId = getImportingModuleId();
21 | if (moduleId !== -1) indexAssetModuleFlag(moduleId);
22 | });
23 |
24 | return unpatch;
25 | }
26 |
--------------------------------------------------------------------------------
/src/lib/api/commands/index.ts:
--------------------------------------------------------------------------------
1 | import { ApplicationCommand, ApplicationCommandInputType, ApplicationCommandType, BunnyApplicationCommand } from "@lib/api/commands/types";
2 | import { after, instead } from "@lib/api/patcher";
3 | import { logger } from "@lib/utils/logger";
4 | import { commands as commandsModule, messageUtil } from "@metro/common";
5 |
6 | let commands: ApplicationCommand[] = [];
7 |
8 | /**
9 | * @internal
10 | */
11 | export function patchCommands() {
12 | const unpatch = after("getBuiltInCommands", commandsModule, ([type], res: ApplicationCommand[]) => {
13 | return [...res, ...commands.filter(c =>
14 | (type instanceof Array ? type.includes(c.type) : type === c.type)
15 | && c.__bunny?.shouldHide?.() !== false)
16 | ];
17 | });
18 |
19 | // Register core commands
20 | [
21 | require("@core/commands/eval"),
22 | require("@core/commands/debug"),
23 | require("@core/commands/plugins")
24 | ].forEach(r => registerCommand(r.default()));
25 |
26 | return () => {
27 | commands = [];
28 | unpatch();
29 | };
30 | }
31 |
32 | export function registerCommand(command: BunnyApplicationCommand): () => void {
33 | // Get built in commands
34 | let builtInCommands: ApplicationCommand[];
35 | try {
36 | builtInCommands = commandsModule.getBuiltInCommands(ApplicationCommandType.CHAT, true, false);
37 | } catch {
38 | builtInCommands = commandsModule.getBuiltInCommands(Object.values(ApplicationCommandType), true, false);
39 | }
40 |
41 | builtInCommands.sort((a: ApplicationCommand, b: ApplicationCommand) => parseInt(b.id!) - parseInt(a.id!));
42 |
43 | const lastCommand = builtInCommands[builtInCommands.length - 1];
44 |
45 | // Override the new command's id to the last command id - 1
46 | command.id = (parseInt(lastCommand.id!, 10) - 1).toString();
47 |
48 | // Fill optional args
49 | command.__bunny = {
50 | shouldHide: command.shouldHide
51 | };
52 |
53 | command.applicationId ??= "-1";
54 | command.type ??= ApplicationCommandType.CHAT;
55 | command.inputType = ApplicationCommandInputType.BUILT_IN;
56 | command.displayName ??= command.name;
57 | command.untranslatedName ??= command.name;
58 | command.displayDescription ??= command.description;
59 | command.untranslatedDescription ??= command.description;
60 |
61 | if (command.options) for (const opt of command.options) {
62 | opt.displayName ??= opt.name;
63 | opt.displayDescription ??= opt.description;
64 | }
65 |
66 | instead("execute", command, (args, orig) => {
67 | Promise.resolve(
68 | orig.apply(command, args)
69 | ).then(ret => {
70 | if (ret && typeof ret === "object") {
71 | messageUtil.sendMessage(args[1].channel.id, ret);
72 | }
73 | }).catch(err => {
74 | logger.error("Failed to execute command", err);
75 | });
76 | });
77 |
78 | // Add it to the commands array
79 | commands.push(command);
80 |
81 | // Return command id so it can be unregistered
82 | return () => (commands = commands.filter(({ id }) => id !== command.id));
83 | }
84 |
--------------------------------------------------------------------------------
/src/lib/api/commands/types.ts:
--------------------------------------------------------------------------------
1 | export interface Argument {
2 | type: ApplicationCommandOptionType,
3 | name: string,
4 | value: string,
5 | focused: undefined;
6 | options: Argument[];
7 | }
8 |
9 | export interface ApplicationCommand {
10 | name: string;
11 | description: string;
12 | execute: (args: Argument[], ctx: CommandContext) => CommandResult | void | Promise | Promise;
13 | options: ApplicationCommandOption[];
14 | id?: string;
15 | applicationId?: string;
16 | displayName?: string;
17 | displayDescription?: string;
18 | untranslatedDescription?: string;
19 | untranslatedName?: string;
20 | inputType?: ApplicationCommandInputType;
21 | type?: ApplicationCommandType;
22 | __bunny?: {
23 | shouldHide: () => boolean;
24 | };
25 | }
26 |
27 | export interface BunnyApplicationCommand extends ApplicationCommand {
28 | shouldHide: () => boolean;
29 | }
30 |
31 | export enum ApplicationCommandInputType {
32 | BUILT_IN,
33 | BUILT_IN_TEXT,
34 | BUILT_IN_INTEGRATION,
35 | BOT,
36 | PLACEHOLDER,
37 | }
38 |
39 | export interface ApplicationCommandOption {
40 | name: string;
41 | description: string;
42 | required?: boolean;
43 | type: ApplicationCommandOptionType;
44 | displayName?: string;
45 | displayDescription?: string;
46 | }
47 |
48 | export enum ApplicationCommandOptionType {
49 | SUB_COMMAND = 1,
50 | SUB_COMMAND_GROUP,
51 | STRING,
52 | INTEGER,
53 | BOOLEAN,
54 | USER,
55 | CHANNEL,
56 | ROLE,
57 | MENTIONABLE,
58 | NUMBER,
59 | ATTACHMENT,
60 | }
61 |
62 | export enum ApplicationCommandType {
63 | CHAT = 1,
64 | USER,
65 | MESSAGE,
66 | }
67 |
68 | export interface CommandContext {
69 | channel: any;
70 | guild: any;
71 | }
72 |
73 | export interface CommandResult {
74 | content: string;
75 | tts?: boolean;
76 | }
77 |
--------------------------------------------------------------------------------
/src/lib/api/flux/index.ts:
--------------------------------------------------------------------------------
1 | // shelter-mod inspired
2 | import { FluxDispatcher } from "@metro/common";
3 |
4 | const blockedSym = Symbol.for("bunny.flux.blocked");
5 | const modifiedSym = Symbol.for("bunny.flux.modified");
6 |
7 | export const dispatcher = FluxDispatcher;
8 |
9 | type Intercept = (payload: Record & { type: string; }) => any;
10 | let intercepts: Intercept[] = [];
11 |
12 | /**
13 | * @internal
14 | */
15 | export function injectFluxInterceptor() {
16 | const cb = (payload: any) => {
17 | for (const intercept of intercepts) {
18 | const res = intercept(payload);
19 |
20 | // nullish -> nothing, falsy -> block, object -> modify
21 | if (res == null) {
22 | continue;
23 | } else if (!res) {
24 | payload[blockedSym] = true;
25 | } else if (typeof res === "object") {
26 | Object.assign(payload, res);
27 | payload[modifiedSym] = true;
28 | }
29 | }
30 |
31 | return blockedSym in payload;
32 | };
33 |
34 | (dispatcher._interceptors ??= []).unshift(cb);
35 |
36 | return () => dispatcher._interceptors &&= dispatcher._interceptors.filter(v => v !== cb);
37 | }
38 |
39 | /**
40 | * Intercept Flux dispatches. Return type affects the end result, where
41 | * nullish -> nothing, falsy -> block, object -> modify
42 | */
43 | export function intercept(cb: Intercept) {
44 | intercepts.push(cb);
45 |
46 | return () => {
47 | intercepts = intercepts.filter(i => i !== cb);
48 | };
49 | }
50 |
--------------------------------------------------------------------------------
/src/lib/api/index.ts:
--------------------------------------------------------------------------------
1 | export * as assets from "./assets";
2 | export * as commands from "./commands";
3 | export * as debug from "./debug";
4 | export * as flux from "./flux";
5 | export * as native from "./native";
6 | export * as patcher from "./patcher";
7 | export * as react from "./react";
8 | export * as settings from "./settings";
9 | export * as storage from "./storage";
10 |
--------------------------------------------------------------------------------
/src/lib/api/native/fs.ts:
--------------------------------------------------------------------------------
1 | import { NativeFileModule } from "./modules";
2 |
3 | /**
4 | * Removes all files in a directory from the path given
5 | * @param path Path to the targeted directory
6 | */
7 | export async function clearFolder(path: string, { prefix = "pyoncord/" } = {}) {
8 | if (typeof NativeFileModule.clearFolder !== "function") throw new Error("'fs.clearFolder' is not supported");
9 | return void await NativeFileModule.clearFolder("documents", `${prefix}${path}`);
10 | }
11 |
12 | /**
13 | * Remove file from given path, currently no check for any failure
14 | * @param path Path to the file
15 | */
16 | export async function removeFile(path: string, { prefix = "pyoncord/" } = {}) {
17 | if (typeof NativeFileModule.removeFile !== "function") throw new Error("'fs.removeFile' is not supported");
18 | return void await NativeFileModule.removeFile("documents", `${prefix}${path}`);
19 | }
20 |
21 | /**
22 | * Remove file from given path, currently no check for any failure
23 | * @param path Path to the file
24 | */
25 | export async function removeCacheFile(path: string, prefix = "pyoncord/") {
26 | if (typeof NativeFileModule.removeFile !== "function") throw new Error("'fs.removeFile' is not supported");
27 | return void await NativeFileModule.removeFile("cache", `${prefix}${path}`);
28 | }
29 |
30 | /**
31 | * Check if the file or directory given by the path exists
32 | * @param path Path to the file
33 | */
34 | export async function fileExists(path: string, { prefix = "pyoncord/" } = {}) {
35 | return await NativeFileModule.fileExists(`${NativeFileModule.getConstants().DocumentsDirPath}/${prefix}${path}`);
36 | }
37 |
38 | /**
39 | * A wrapper to write to a file to the documents directory
40 | * @param path Path to the file
41 | * @param data String data to write to the file
42 | */
43 | export async function writeFile(path: string, data: string, { prefix = "pyoncord/" } = {}): Promise {
44 | if (typeof data !== "string") throw new Error("Argument 'data' must be a string");
45 | return void await NativeFileModule.writeFile("documents", `${prefix}${path}`, data, "utf8");
46 | }
47 |
48 | /**
49 | * A wrapper to read a file from the documents directory
50 | * @param path Path to the file
51 | * @param fallback Fallback data to return if the file doesn't exist, and will be written to the file
52 | */
53 | export async function readFile(path: string, { prefix = "pyoncord/" } = {}): Promise {
54 | try {
55 | return await NativeFileModule.readFile(`${NativeFileModule.getConstants().DocumentsDirPath}/${prefix}${path}`, "utf8");
56 | } catch (err) {
57 | throw new Error(`An error occured while writing to '${path}'`, { cause: err });
58 | }
59 | }
60 |
61 | /**
62 | * Download a file from the given URL and save it to the path given
63 | * @param url URL to download the file from
64 | * @param path Path to save the file to
65 | */
66 | export async function downloadFile(url: string, path: string, { prefix = "pyoncord/" } = {}) {
67 | const response = await fetch(url);
68 | if (!response.ok) {
69 | throw new Error(`Failed to download file from ${url}: ${response.status}`);
70 | }
71 |
72 | const arrayBuffer = await response.arrayBuffer();
73 | const data = Buffer.from(arrayBuffer).toString("base64");
74 |
75 | await NativeFileModule.writeFile("documents", `${prefix}${path}`, data, "base64");
76 | }
77 |
--------------------------------------------------------------------------------
/src/lib/api/native/index.ts:
--------------------------------------------------------------------------------
1 | export * as fs from "./fs";
2 |
--------------------------------------------------------------------------------
/src/lib/api/native/modules/index.ts:
--------------------------------------------------------------------------------
1 | import { RNModules } from "./types";
2 |
3 | const nmp = window.nativeModuleProxy;
4 |
5 | export const NativeCacheModule = (nmp.NativeCacheModule ?? nmp.MMKVManager) as RNModules.MMKVManager;
6 | export const NativeFileModule = (nmp.NativeFileModule ?? nmp.RTNFileManager ?? nmp.DCDFileManager) as RNModules.FileManager;
7 | export const NativeClientInfoModule = nmp.NativeClientInfoModule ?? nmp.RTNClientInfoManager ?? nmp.InfoDictionaryManager;
8 | export const NativeDeviceModule = nmp.NativeDeviceModule ?? nmp.RTNDeviceManager ?? nmp.DCDDeviceManager;
9 | export const NativeThemeModule = nmp.NativeThemeModule ?? nmp.RTNThemeManager ?? nmp.DCDTheme;
10 |
11 | export const { BundleUpdaterManager } = nmp;
12 |
--------------------------------------------------------------------------------
/src/lib/api/native/modules/types.ts:
--------------------------------------------------------------------------------
1 |
2 |
3 | export namespace RNModules {
4 | /**
5 | * A key-value storage based upon `SharedPreferences` on Android.
6 | *
7 | * These types are based on Android though everything should be the same between
8 | * platforms.
9 | */
10 | export interface MMKVManager {
11 | /**
12 | * Get the value for the given `key`, or null
13 | * @param key The key to fetch
14 | */
15 | getItem: (key: string) => Promise;
16 | /**
17 | * Deletes the value for the given `key`
18 | * @param key The key to delete
19 | */
20 | removeItem: (key: string) => void;
21 | /**
22 | * Sets the value of `key` to `value`
23 | */
24 | setItem: (key: string, value: string) => void;
25 | /**
26 | * Goes through every item in storage and returns it, excluding the
27 | * keys specified in `exclude`.
28 | * @param exclude A list of items to exclude from result
29 | */
30 | refresh: (exclude: string[]) => Promise>;
31 | /**
32 | * You will be murdered if you use this function.
33 | * Clears ALL of Discord's settings.
34 | */
35 | clear: () => void;
36 | }
37 |
38 | export interface FileManager {
39 | /**
40 | * @param path **Full** path to file
41 | */
42 | fileExists: (path: string) => Promise;
43 | /**
44 | * Allowed URI schemes on Android: `file://`, `content://` ([See here](https://developer.android.com/reference/android/content/ContentResolver#accepts-the-following-uri-schemes:_3))
45 | */
46 | getSize: (uri: string) => Promise;
47 | /**
48 | * @param path **Full** path to file
49 | * @param encoding Set to `base64` in order to encode response
50 | */
51 | readFile(path: string, encoding: "base64" | "utf8"): Promise;
52 | saveFileToGallery?(uri: string, fileName: string, fileType: "PNG" | "JPEG"): Promise;
53 | /**
54 | * @param storageDir Either `cache` or `documents`.
55 | * @param path Path in `storageDir`, parents are recursively created.
56 | * @param data The data to write to the file
57 | * @param encoding Set to `base64` if `data` is base64 encoded.
58 | * @returns Promise that resolves to path of the file once it got written
59 | */
60 | writeFile(storageDir: "cache" | "documents", path: string, data: string, encoding: "base64" | "utf8"): Promise;
61 | /**
62 | * Removes a file from the path given.
63 | * (!) On Android, this always returns false, regardless if it fails or not!
64 | * @param storageDir Either `cache` or `documents`
65 | * @param path Path to the file to be removed
66 | */
67 | removeFile(storageDir: "cache" | "documents", path: string): Promise;
68 | /**
69 | * Clear the folder from the path given
70 | * (!) On Android, this only clears all *files* and not subdirectories!
71 | * @param storageDir Either `cache` or `documents`
72 | * @param path Path to the folder to be cleared
73 | * @returns Whether the clearance succeeded
74 | */
75 | clearFolder(storageDir: "cache" | "documents", path: string): Promise;
76 | getConstants: () => {
77 | /**
78 | * The path the `documents` storage dir (see {@link writeFile}) represents.
79 | */
80 | DocumentsDirPath: string;
81 | CacheDirPath: string;
82 | };
83 | /**
84 | * Will apparently cease to exist some time in the future so please use {@link getConstants} instead.
85 | * @deprecated
86 | */
87 | DocumentsDirPath: string;
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/lib/api/patcher.ts:
--------------------------------------------------------------------------------
1 | const {
2 | after: _after,
3 | before: _before,
4 | instead: _instead
5 | } = require("spitroast");
6 |
7 | /** @internal */
8 | export const _patcherDelaySymbol = Symbol.for("bunny.api.patcher.delay");
9 |
10 | type Unpatcher = () => boolean;
11 | type DelayCallback = (callback: (target: any) => void) => unknown;
12 | type Thenable = { then: typeof Promise.prototype.then };
13 |
14 | interface PatchFn {
15 | (func: string, parent: any, callback: Callback, once?: boolean): Unpatcher;
16 | await(func: string, parent: Promise, callback: Callback, once?: boolean): Unpatcher;
17 | }
18 |
19 | type BeforeFn = PatchFn<(args: any[]) => unknown | unknown[]>;
20 | type InsteadFn = PatchFn<(args: any[], origFunc: Function) => unknown>;
21 | type AfterFn = PatchFn<(args: any[], ret: any) => unknown>;
22 |
23 | function create(fn: Function) {
24 | function patchFn(this: any, ...args: any[]) {
25 | if (_patcherDelaySymbol in args[1]) {
26 | const delayCallback: DelayCallback = args[1][_patcherDelaySymbol];
27 |
28 | let cancel = false;
29 | let unpatch = () => cancel = true;
30 |
31 | delayCallback(target => {
32 | if (cancel) return;
33 | args[1] = target;
34 | unpatch = fn.apply(this, args);
35 | });
36 |
37 | return () => unpatch();
38 | }
39 |
40 | return fn.apply(this, args);
41 | }
42 |
43 | function promisePatchFn(this: any, ...args: [any, Thenable, ...any]) {
44 | const thenable = args[1];
45 | if (!thenable || !("then" in thenable)) throw new Error("target is not a then-able object");
46 |
47 | let cancel = false;
48 | let unpatch = () => cancel = true;
49 |
50 | thenable.then(target => {
51 | if (cancel) return;
52 | args[1] = target;
53 | unpatch = patchFn.apply(this, args);
54 | });
55 |
56 | return () => unpatch();
57 | }
58 |
59 | return Object.assign(patchFn, { await: promisePatchFn });
60 | }
61 |
62 | export const after = create(_after) as AfterFn;
63 | export const before = create(_before) as BeforeFn;
64 | export const instead = create(_instead) as InsteadFn;
65 |
66 | /** @internal */
67 | export default { after, before, instead };
68 |
--------------------------------------------------------------------------------
/src/lib/api/react/index.ts:
--------------------------------------------------------------------------------
1 | export * as jsx from "./jsx";
2 |
--------------------------------------------------------------------------------
/src/lib/api/react/jsx.ts:
--------------------------------------------------------------------------------
1 | import { after } from "@lib/api/patcher";
2 | import { findByPropsLazy } from "@metro";
3 |
4 | type Callback = (Component: any, ret: JSX.Element) => JSX.Element;
5 | const callbacks = new Map();
6 |
7 | const jsxRuntime = findByPropsLazy("jsx", "jsxs");
8 |
9 | export function onJsxCreate(Component: string, callback: Callback) {
10 | if (!callbacks.has(Component)) callbacks.set(Component, []);
11 | callbacks.get(Component)!.push(callback);
12 | }
13 |
14 | export function deleteJsxCreate(Component: string, callback: Callback) {
15 | if (!callbacks.has(Component)) return;
16 | const cbs = callbacks.get(Component)!;
17 | cbs.splice(cbs.indexOf(callback), 1);
18 | if (cbs.length === 0) callbacks.delete(Component);
19 | }
20 |
21 | /**
22 | * @internal
23 | */
24 | export function patchJsx() {
25 | const callback = ([Component]: unknown[], ret: JSX.Element) => {
26 | // Band-aid fix for iOS invalid element type crashes
27 | if (typeof ret.type === 'undefined') {
28 | ret.type = 'RCTView'
29 | return ret
30 | }
31 |
32 | // The check could be more complex, but this is fine for now to avoid overhead
33 | if (typeof Component === "function" && callbacks.has(Component.name)) {
34 | const cbs = callbacks.get(Component.name)!;
35 | for (const cb of cbs) {
36 | const _ret = cb(Component, ret);
37 | if (_ret !== undefined) ret = _ret;
38 | }
39 | return ret;
40 | }
41 | };
42 |
43 | const patches = [
44 | after("jsx", jsxRuntime, callback),
45 | after("jsxs", jsxRuntime, callback)
46 | ];
47 |
48 | return () => patches.forEach(unpatch => unpatch());
49 | }
50 |
--------------------------------------------------------------------------------
/src/lib/api/settings.ts:
--------------------------------------------------------------------------------
1 | import { createFileBackend, createMMKVBackend, createStorage, wrapSync } from "@core/vendetta/storage";
2 | import { getLoaderConfigPath } from "@lib/api/native/loader";
3 |
4 | export interface Settings {
5 | debuggerUrl: string;
6 | developerSettings: boolean;
7 | enableDiscordDeveloperSettings: boolean;
8 | safeMode?: {
9 | enabled: boolean;
10 | currentThemeId?: string;
11 | };
12 | enableEvalCommand?: boolean;
13 | }
14 |
15 | export interface LoaderConfig {
16 | customLoadUrl: {
17 | enabled: boolean;
18 | url: string;
19 | };
20 | loadReactDevTools: boolean;
21 | }
22 |
23 | export const settings = wrapSync(createStorage(createMMKVBackend("VENDETTA_SETTINGS")));
24 |
25 | export const loaderConfig = wrapSync(createStorage(
26 | createFileBackend(getLoaderConfigPath(), {
27 | customLoadUrl: {
28 | enabled: false,
29 | url: "http://localhost:4040/bunny.js"
30 | }
31 | })
32 | ));
33 |
--------------------------------------------------------------------------------
/src/lib/index.ts:
--------------------------------------------------------------------------------
1 | import "../global.d.ts"; // eslint-disable-line import-alias/import-alias
2 | import "../modules.d.ts"; // eslint-disable-line import-alias/import-alias
3 |
4 | export * as fonts from "./addons/fonts/index.js";
5 | export * as plugins from "./addons/plugins/index.js";
6 | export * as themes from "./addons/themes/index.js";
7 | export * as api from "./api";
8 | export * as ui from "./ui";
9 | export * as utils from "./utils";
10 | export * as metro from "@metro";
11 |
12 | import * as fonts from "./addons/fonts/index.js";
13 | import * as plugins from "./addons/plugins/index.js";
14 | import * as themes from "./addons/themes/index.js";
15 |
16 | /** @internal */
17 | export * as _jsx from "react/jsx-runtime";
18 |
19 | import { proxyLazy } from "./utils/lazy";
20 |
21 | /**
22 | * @internal
23 | * @deprecated Moved to top level (bunny.*)
24 | */
25 | export const managers = proxyLazy(() => {
26 | console.warn("bunny.managers.* is deprecated, and moved the top level (bunny.*). bunny.managers will be eventually removed soon");
27 |
28 | return {
29 | get fonts() { return fonts; },
30 | get plugins() { return plugins; },
31 | get themes() { return themes; }
32 | };
33 | }, { hint: "object" });
34 |
35 | const _disposer = [] as Array<() => unknown>;
36 |
37 | export function unload() {
38 | for (const d of _disposer) if (typeof d === "function") d();
39 | // @ts-expect-error
40 | delete window.bunny;
41 | }
42 |
43 | /**
44 | * For internal use only, do not use!
45 | * @internal
46 | */
47 | unload.push = (fn: typeof _disposer[number]) => {
48 | _disposer.push(fn);
49 | };
50 |
--------------------------------------------------------------------------------
/src/lib/ui/alerts.ts:
--------------------------------------------------------------------------------
1 | import { lazyDestructure } from "@lib/utils/lazy";
2 | import { findByProps } from "@metro";
3 |
4 | export const { openAlert, dismissAlert } = lazyDestructure(() => findByProps("openAlert", "dismissAlert"));
5 |
--------------------------------------------------------------------------------
/src/lib/ui/color.ts:
--------------------------------------------------------------------------------
1 | import { constants } from "@metro/common";
2 | import { findByProps, findByStoreNameLazy } from "@metro/wrappers";
3 |
4 | //! This module is only found on 165.0+, under the assumption that iOS 165.0 is the same as Android 165.0.
5 | //* In 167.1, most if not all traces of the old color modules were removed.
6 | //* In 168.6, Discord restructured EVERYTHING again. SemanticColor on this module no longer works when passed to a stylesheet. We must now use what you see below.
7 | //* In 173.10, Discord restructured a lot of the app. These changes included making the color module impossible to early-find.
8 | // ? To stop duplication, it is now exported in our theming code.
9 | // ? These comments are preserved for historical purposes.
10 | // const colorModule = findByPropsProxy("colors", "meta");
11 |
12 | const color = findByProps("SemanticColor");
13 |
14 | // ? SemanticColor and default.colors are effectively ThemeColorMap
15 | export const semanticColors = (color?.default?.colors ?? constants?.ThemeColorMap) as Record;
16 |
17 | // ? RawColor and default.unsafe_rawColors are effectively Colors
18 | //* Note that constants.Colors does still appear to exist on newer versions despite Discord not internally using it - what the fuck?
19 | export const rawColors = (color?.default?.unsafe_rawColors ?? constants?.Colors) as Record;
20 |
21 | const ThemeStore = findByStoreNameLazy("ThemeStore");
22 | const colorResolver = color.default.meta ??= color.default.internal;
23 |
24 | export function isSemanticColor(sym: any): boolean {
25 | return colorResolver.isSemanticColor(sym);
26 | }
27 |
28 | export function resolveSemanticColor(sym: any, theme = ThemeStore.theme): string {
29 | return colorResolver.resolveSemanticColor(theme, sym);
30 | }
31 |
--------------------------------------------------------------------------------
/src/lib/ui/components/Codeblock.tsx:
--------------------------------------------------------------------------------
1 | import { constants } from "@metro/common";
2 | import { semanticColors } from "@ui/color";
3 | import { createStyles } from "@ui/styles";
4 | import { Platform, Text, TextInput } from "react-native";
5 |
6 | export interface CodeblockProps {
7 | selectable?: boolean;
8 | style?: import("react-native").TextStyle;
9 | children?: string;
10 | }
11 |
12 | const useStyles = createStyles({
13 | codeBlock: {
14 | fontFamily: constants.Fonts.CODE_NORMAL,
15 | fontSize: 12,
16 | textAlignVertical: "center",
17 | backgroundColor: semanticColors.BACKGROUND_SECONDARY,
18 | color: semanticColors.TEXT_NORMAL,
19 | borderWidth: 1,
20 | borderRadius: 12,
21 | borderColor: semanticColors.BACKGROUND_TERTIARY,
22 | padding: 10,
23 | },
24 | });
25 |
26 | // iOS doesn't support the selectable property on RN.Text...
27 | const InputBasedCodeblock = ({ style, children }: CodeblockProps) => ;
28 | const TextBasedCodeblock = ({ selectable, style, children }: CodeblockProps) => {children};
29 |
30 | export default function Codeblock({ selectable, style, children }: CodeblockProps) {
31 | if (!selectable) return ;
32 |
33 | return Platform.select({
34 | ios: ,
35 | default: ,
36 | });
37 | }
38 |
--------------------------------------------------------------------------------
/src/lib/ui/components/ErrorBoundary.tsx:
--------------------------------------------------------------------------------
1 | import ErrorCard from "@core/ui/reporter/components/ErrorCard";
2 | import { React } from "@metro/common";
3 | import { ThemeContext } from "@ui/styles";
4 | import { Falsy } from "react-native";
5 |
6 | type ErrorBoundaryState = {
7 | hasErr: false;
8 | } | {
9 | hasErr: true;
10 | error: Error;
11 | };
12 |
13 | export interface ErrorBoundaryProps {
14 | children: JSX.Element | Falsy | (JSX.Element | Falsy)[];
15 | }
16 |
17 | export default class ErrorBoundary extends React.Component {
18 | constructor(props: ErrorBoundaryProps) {
19 | super(props);
20 | this.state = { hasErr: false };
21 | }
22 |
23 | static contextType = ThemeContext;
24 | static getDerivedStateFromError = (error: Error) => ({ hasErr: true, error });
25 |
26 | render() {
27 | if (!this.state.hasErr) return this.props.children;
28 |
29 | return (
30 | this.setState({ hasErr: false })}
33 | />
34 | );
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/lib/ui/components/InputAlert.tsx:
--------------------------------------------------------------------------------
1 | import { LegacyAlert, LegacyFormInput } from "@metro/common/components";
2 | import { findByPropsLazy } from "@metro/wrappers";
3 |
4 | const Alerts = findByPropsLazy("openLazy", "close");
5 |
6 | export interface InputAlertProps {
7 | title?: string;
8 | confirmText?: string;
9 | confirmColor?: string;
10 | onConfirm: (input: string) => (void | Promise);
11 | cancelText?: string;
12 | placeholder?: string;
13 | initialValue?: string;
14 | secureTextEntry?: boolean;
15 | }
16 |
17 | export default function InputAlert({ title, confirmText, confirmColor, onConfirm, cancelText, placeholder, initialValue = "", secureTextEntry }: InputAlertProps) {
18 | const [value, setValue] = React.useState(initialValue);
19 | const [error, setError] = React.useState("");
20 |
21 | function onConfirmWrapper() {
22 | const asyncOnConfirm = Promise.resolve(onConfirm(value));
23 |
24 | asyncOnConfirm.then(() => {
25 | Alerts.close();
26 | }).catch((e: Error) => {
27 | setError(e.message);
28 | });
29 | }
30 |
31 | return (
32 | Alerts.close()}
40 | >
41 | {
45 | setValue(typeof v === "string" ? v : v.text);
46 | if (error) setError("");
47 | }}
48 | returnKeyType="done"
49 | onSubmitEditing={onConfirmWrapper}
50 | error={error || undefined}
51 | secureTextEntry={secureTextEntry}
52 | autoFocus={true}
53 | showBorder={true}
54 | style={{ alignSelf: "stretch" }}
55 | />
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/src/lib/ui/components/Search.tsx:
--------------------------------------------------------------------------------
1 | import { Strings } from "@core/i18n";
2 | import { findAssetId } from "@lib/api/assets";
3 | import { TextInput } from "@metro/common/components";
4 | import ErrorBoundary from "@ui/components/ErrorBoundary";
5 | import { Image, View, ViewStyle } from "react-native";
6 |
7 | export interface SearchProps {
8 | onChangeText?: (v: string) => void;
9 | placeholder?: string;
10 | style?: ViewStyle;
11 | isRound?: boolean;
12 | }
13 |
14 | function SearchIcon() {
15 | return ;
16 | }
17 |
18 | export default ({ onChangeText, placeholder, style, isRound }: SearchProps) => {
19 | const [query, setQuery] = React.useState("");
20 |
21 | const onChange = (value: string) => {
22 | setQuery(value);
23 | onChangeText?.(value);
24 | };
25 |
26 | return
27 |
28 |
41 |
42 | ;
43 | };
44 |
--------------------------------------------------------------------------------
/src/lib/ui/components/Summary.tsx:
--------------------------------------------------------------------------------
1 | import { findAssetId } from "@lib/api/assets";
2 | import { LegacyFormRow, TableRow } from "@metro/common/components";
3 | import { LayoutAnimation, View } from "react-native";
4 |
5 | export interface SummaryProps {
6 | label: string;
7 | icon?: string;
8 | noPadding?: boolean;
9 | noAnimation?: boolean;
10 | children: JSX.Element | JSX.Element[];
11 | }
12 |
13 | export default function Summary({ label, icon, noPadding = false, noAnimation = false, children }: SummaryProps) {
14 | const [hidden, setHidden] = React.useState(true);
15 |
16 | return (
17 | <>
18 | }
21 | trailing={}
22 | onPress={() => {
23 | setHidden(!hidden);
24 | if (!noAnimation) LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
25 | }}
26 | />
27 | {!hidden && <>
28 | {children}
29 | >}
30 | >
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/lib/ui/components/index.ts:
--------------------------------------------------------------------------------
1 | export * as wrappers from "./wrappers";
2 | export { default as Codeblock } from "@ui/components/Codeblock";
3 | export { default as ErrorBoundary } from "@ui/components/ErrorBoundary";
4 | export { default as Search } from "@ui/components/Search";
5 | export { default as Summary } from "@ui/components/Summary";
6 |
--------------------------------------------------------------------------------
/src/lib/ui/components/wrappers/AlertModal.tsx:
--------------------------------------------------------------------------------
1 | import { lazyDestructure } from "@lib/utils/lazy";
2 | import { findByFilePath, findByProps } from "@metro";
3 | import { Text } from "@metro/common/components";
4 | import { Button } from "@metro/common/types/components";
5 | import { ComponentProps, ComponentType, ReactNode } from "react";
6 | import { View } from "react-native";
7 |
8 | type ActionButtonProps = Omit, "onPress"> & {
9 | onPress?: () => void | Promise;
10 | };
11 |
12 | const {
13 | AlertModal: _AlertModal,
14 | AlertActionButton: _AlertActionButton
15 | } = lazyDestructure(() => findByProps("AlertModal", "AlertActions"));
16 |
17 | export const AlertActionButton = _AlertActionButton as ComponentType;
18 |
19 | export default function AlertModal(props: Record) {
20 | const forwardFailedModal = findByFilePath("modules/forwarding/native/ForwardFailedAlertModal.tsx");
21 |
22 | // ponyfill for extraContent
23 | if (!forwardFailedModal && "extraContent" in props) {
24 | props.content = (
25 |
26 |
27 | {props.content as string}
28 |
29 |
30 | {props.extraContent as ReactNode}
31 |
32 |
33 | );
34 |
35 | delete props.extraContent;
36 | }
37 |
38 | return <_AlertModal {...props} />;
39 | }
40 |
--------------------------------------------------------------------------------
/src/lib/ui/components/wrappers/index.ts:
--------------------------------------------------------------------------------
1 | import AlertModal, { AlertActionButton } from "./AlertModal";
2 |
3 | export { AlertActionButton, AlertModal };
4 |
--------------------------------------------------------------------------------
/src/lib/ui/index.ts:
--------------------------------------------------------------------------------
1 |
2 | export * as alerts from "./alerts";
3 | export * as components from "./components";
4 | export * as settings from "./settings";
5 | export * as sheets from "./sheets";
6 | export * as styles from "./styles";
7 | export * as toasts from "./toasts";
8 |
--------------------------------------------------------------------------------
/src/lib/ui/settings/index.tsx:
--------------------------------------------------------------------------------
1 | // Good luck reading this!
2 | import { lazy } from "react";
3 | import type { ImageURISource } from "react-native";
4 |
5 | import { patchTabsUI } from "./patches/tabs";
6 |
7 | export interface RowConfig {
8 | key: string;
9 | title: () => string;
10 | onPress?: () => any;
11 | render?: Parameters[0];
12 | icon?: ImageURISource | number;
13 | IconComponent?: React.ReactNode,
14 | usePredicate?: () => boolean,
15 | useTrailing?: () => string | JSX.Element,
16 | rawTabsConfig?: Record;
17 | }
18 |
19 | export const registeredSections = {} as {
20 | [key: string]: RowConfig[];
21 | };
22 |
23 | export function registerSection(section: { name: string; items: RowConfig[]; }) {
24 | registeredSections[section.name] = section.items;
25 | return () => delete registeredSections[section.name];
26 | }
27 |
28 | /**
29 | * @internal
30 | */
31 | export function patchSettings() {
32 | const unpatches = new Array<() => boolean>;
33 |
34 | patchTabsUI(unpatches);
35 |
36 | return () => unpatches.forEach(u => u());
37 | }
38 |
--------------------------------------------------------------------------------
/src/lib/ui/settings/patches/shared.tsx:
--------------------------------------------------------------------------------
1 | import { NavigationNative } from "@metro/common";
2 | import { findByPropsLazy } from "@metro/wrappers";
3 | import { ErrorBoundary } from "@ui/components";
4 | import { RowConfig } from "@ui/settings";
5 |
6 | const tabsNavigationRef = findByPropsLazy("getRootNavigationRef");
7 |
8 | export const CustomPageRenderer = React.memo(() => {
9 | const navigation = NavigationNative.useNavigation();
10 | const route = NavigationNative.useRoute();
11 |
12 | const { render: PageComponent, ...args } = route.params;
13 |
14 | React.useEffect(() => void navigation.setOptions({ ...args }), []);
15 |
16 | return ;
17 | });
18 |
19 | export function wrapOnPress(
20 | onPress: (() => unknown) | undefined,
21 | navigation?: any,
22 | renderPromise?: RowConfig["render"],
23 | screenOptions?: string | Record,
24 | props?: any,
25 | ) {
26 | return async () => {
27 | if (onPress) return void onPress();
28 |
29 | const Component = await renderPromise!!().then(m => m.default);
30 |
31 | if (typeof screenOptions === "string") {
32 | screenOptions = { title: screenOptions };
33 | }
34 |
35 | navigation ??= tabsNavigationRef.getRootNavigationRef();
36 | navigation.navigate("BUNNY_CUSTOM_PAGE", {
37 | ...screenOptions,
38 | render: () =>
39 | });
40 | };
41 | }
42 |
43 |
--------------------------------------------------------------------------------
/src/lib/ui/settings/patches/tabs.tsx:
--------------------------------------------------------------------------------
1 | import { after } from "@lib/api/patcher";
2 | import { findInReactTree } from "@lib/utils";
3 | import { TableRow } from "@metro/common/components";
4 | import { findByNameLazy, findByPropsLazy } from "@metro/wrappers";
5 | import { registeredSections } from "@ui/settings";
6 |
7 | import { CustomPageRenderer, wrapOnPress } from "./shared";
8 | import { Strings } from "@core/i18n";
9 | import { TableRowIcon } from "@metro/common/components";
10 |
11 | const settingConstants = findByPropsLazy("SETTING_RENDERER_CONFIG");
12 | const SettingsOverviewScreen = findByNameLazy("SettingsOverviewScreen", false);
13 |
14 | function useIsFirstRender() {
15 | let firstRender = false;
16 | React.useEffect(() => void (firstRender = true), []);
17 | return firstRender;
18 | }
19 |
20 | export function patchTabsUI(unpatches: (() => void | boolean)[]) {
21 | const getRows = () => Object.values(registeredSections)
22 | .flatMap(sect => sect.map(row => ({
23 | [row.key]: {
24 | type: "pressable",
25 | title: row.title,
26 | icon: row.icon,
27 | IconComponent: () => ,
28 | usePredicate: row.usePredicate,
29 | useTrailing: row.useTrailing,
30 | onPress: wrapOnPress(row.onPress, null, row.render, row.title()),
31 | withArrow: true,
32 | ...row.rawTabsConfig
33 | }
34 | })))
35 | .reduce((a, c) => Object.assign(a, c));
36 |
37 | const origRendererConfig = settingConstants.SETTING_RENDERER_CONFIG;
38 | let rendererConfigValue = settingConstants.SETTING_RENDERER_CONFIG;
39 |
40 | Object.defineProperty(settingConstants, "SETTING_RENDERER_CONFIG", {
41 | enumerable: true,
42 | configurable: true,
43 | get: () => ({
44 | ...rendererConfigValue,
45 | VendettaCustomPage: {
46 | type: "route",
47 | title: () => Strings.BUNNY,
48 | screen: {
49 | route: "VendettaCustomPage",
50 | getComponent: () => CustomPageRenderer
51 | }
52 | },
53 | BUNNY_CUSTOM_PAGE: {
54 | type: "route",
55 | title: () => Strings.BUNNY,
56 | screen: {
57 | route: "BUNNY_CUSTOM_PAGE",
58 | getComponent: () => CustomPageRenderer
59 | }
60 | },
61 | ...getRows()
62 | }),
63 | set: v => rendererConfigValue = v,
64 | });
65 |
66 | unpatches.push(() => {
67 | Object.defineProperty(settingConstants, "SETTING_RENDERER_CONFIG", {
68 | value: origRendererConfig,
69 | writable: true,
70 | get: undefined,
71 | set: undefined
72 | });
73 | });
74 |
75 | unpatches.push(after("default", SettingsOverviewScreen, (_, ret) => {
76 | if (useIsFirstRender()) return; // :shrug:
77 |
78 | const { sections } = findInReactTree(ret, i => i.props?.sections).props;
79 | // Credit to @palmdevs - https://discord.com/channels/1196075698301968455/1243605828783571024/1307940348378742816
80 | let index = -~sections.findIndex((i: any) => i.settings.includes("ACCOUNT")) || 1;
81 |
82 | Object.keys(registeredSections).forEach(sect => {
83 | sections.splice(index++, 0, {
84 | label: sect,
85 | title: sect,
86 | settings: registeredSections[sect].map(a => a.key)
87 | });
88 | });
89 | }));
90 | }
91 |
--------------------------------------------------------------------------------
/src/lib/ui/sheets.ts:
--------------------------------------------------------------------------------
1 | import { findByPropsLazy } from "@metro/wrappers";
2 |
3 | const actionSheet = findByPropsLazy("openLazy", "hideActionSheet");
4 |
5 | export function showSheet>(
6 | key: string,
7 | lazyImport: Promise<{ default: T; }> | T,
8 | props?: React.ComponentProps
9 | ) {
10 | if (!("then" in lazyImport)) lazyImport = Promise.resolve({ default: lazyImport });
11 | actionSheet.openLazy(lazyImport, key, props ?? {});
12 | }
13 |
14 | export function hideSheet(key: string) {
15 | actionSheet.hideActionSheet(key);
16 | }
17 |
--------------------------------------------------------------------------------
/src/lib/ui/styles.ts:
--------------------------------------------------------------------------------
1 | import { lazyDestructure, proxyLazy } from "@lib/utils/lazy";
2 | import { findByProps, findByPropsLazy } from "@metro/wrappers";
3 | import { isSemanticColor, resolveSemanticColor } from "@ui/color";
4 | import { TextStyles } from "@ui/types";
5 | import { ImageStyle, StyleSheet, TextStyle, ViewStyle } from "react-native";
6 |
7 | type NamedStyles = { [P in keyof T]: ViewStyle | TextStyle | ImageStyle };
8 |
9 | const Styles = findByPropsLazy("createStyles");
10 |
11 | export const { ThemeContext } = lazyDestructure(() => findByProps("ThemeContext"), { hint: "object" });
12 | export const { TextStyleSheet } = lazyDestructure(() => findByProps("TextStyleSheet")) as unknown as {
13 | TextStyleSheet: Record;
14 | };
15 |
16 | /**
17 | * Get themed styles based on the current theme
18 | * @returns A hook that returns the themed stylesheet
19 | * @example
20 | * const useStyles = createStyles({
21 | * container: {
22 | * flex: 1,
23 | * backgroundColor: tokens.colors.BACKGROUND_PRIMARY,
24 | * },
25 | * });
26 | *
27 | * function MyComponent() {
28 | * const styles = useStyles();
29 | * return ;
30 | * }
31 | */
32 | export function createStyles>(sheet: T | ((props: any) => T)): () => T {
33 | return proxyLazy(() => Styles.createStyles(sheet));
34 | }
35 |
36 | /**
37 | * Get themed styles based on the current theme for class components
38 | * @example
39 | * const getStyles = createStyles({
40 | * container: {
41 | * flex: 1,
42 | * backgroundColor: tokens.colors.BACKGROUND_PRIMARY,
43 | * },
44 | * });
45 | *
46 | * class MyComponent extends React.Component {
47 | * static contextType = ThemeContext;
48 | * render() {
49 | * const styles = getStyles(this.context);
50 | * return ;
51 | * }
52 | * }
53 | */
54 | export function createLegacyClassComponentStyles>(sheet: T | ((props: any) => T)): (ctxt: typeof ThemeContext) => T {
55 | return proxyLazy(() => Styles.createLegacyClassComponentStyles(sheet));
56 | }
57 |
58 | /**
59 | * Reimplementation of Discord's createThemedStyleSheet, which was removed since 204201
60 | * Not exactly a 1:1 reimplementation, but sufficient to keep compatibility with existing plugins
61 | * @deprecated Use createStyles or createLegacyClassComponentStyles instead
62 | */
63 | export function createThemedStyleSheet>(sheet: T) {
64 | for (const key in sheet) {
65 | // @ts-ignore
66 | sheet[key] = new Proxy(StyleSheet.flatten(sheet[key]), {
67 | get(target, prop, receiver) {
68 | const res = Reflect.get(target, prop, receiver);
69 | return isSemanticColor(res) ? resolveSemanticColor(res) : res;
70 | }
71 | });
72 | }
73 |
74 | return sheet;
75 | }
76 |
--------------------------------------------------------------------------------
/src/lib/ui/toasts.ts:
--------------------------------------------------------------------------------
1 | import { Strings } from "@core/i18n";
2 | import { findAssetId } from "@lib/api/assets";
3 | import { lazyDestructure } from "@lib/utils/lazy";
4 | import { toasts } from "@metro/common";
5 | import { findByProps } from "@metro/wrappers";
6 |
7 | const { uuid4 } = lazyDestructure(() => findByProps("uuid4"));
8 |
9 | export const showToast = (content: string, asset?: number) => toasts.open({
10 | // ? In build 182205/44707, Discord changed their toasts, source is no longer used, rather icon, and a key is needed.
11 | // TODO: We could probably have the developer specify a key themselves, but this works to fix toasts
12 | key: `vd-toast-${uuid4()}`,
13 | content: content,
14 | source: asset,
15 | icon: asset,
16 | });
17 |
18 | showToast.showCopyToClipboard = (message = Strings.COPIED_TO_CLIPBOARD) => {
19 | showToast(message, findAssetId("toast_copy_link"));
20 | };
21 |
--------------------------------------------------------------------------------
/src/lib/utils/constants.ts:
--------------------------------------------------------------------------------
1 | export const DISCORD_SERVER = "https://discord.com/invite/ddcQf3s2Uq";
2 | export const GITHUB = "https://github.com/revenge-mod";
3 | export const HTTP_REGEX = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&/=]*)$/;
4 | 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;
5 |
6 | export const BUNNY_PROXY_PREFIX = "https://bn-plugins.github.io/vd-proxy";
7 |
8 | export const OFFICIAL_PLUGINS_REPO_URL = "https://bn-plugins.github.io/dist/repo.json";
9 |
10 | export const VD_PROXY_PREFIX = "https://vd-plugins.github.io/proxy";
11 | export const VD_DISCORD_SERVER_ID = "1015931589865246730";
12 | export const VD_PLUGINS_CHANNEL_ID = "1091880384561684561";
13 | export const VD_THEMES_CHANNEL_ID = "1091880434939482202";
14 |
--------------------------------------------------------------------------------
/src/lib/utils/cyrb64.ts:
--------------------------------------------------------------------------------
1 | // cyrb53 (c) 2018 bryc (github.com/bryc). License: Public domain. Attribution appreciated.
2 | // A fast and simple 64-bit (or 53-bit) string hash function with decent collision resistance.
3 | // Largely inspired by MurmurHash2/3, but with a focus on speed/simplicity.
4 | // See https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript/52171480#52171480
5 | // https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js
6 | export default function cyrb64(str: string, seed = 0) {
7 | let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed;
8 | for (let i = 0, ch; i < str.length; i++) {
9 | ch = str.charCodeAt(i);
10 | h1 = Math.imul(h1 ^ ch, 2654435761);
11 | h2 = Math.imul(h2 ^ ch, 1597334677);
12 | }
13 | h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
14 | h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
15 | h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
16 | h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
17 | // For a single 53-bit numeric return value we could return
18 | // 4294967296 * (2097151 & h2) + (h1 >>> 0);
19 | // but we instead return the full 64-bit value:
20 | return [h2 >>> 0, h1 >>> 0];
21 | }
22 |
23 | // An improved, *insecure* 64-bit hash that's short, fast, and has no dependencies.
24 | // Output is always 14 characters.
25 | export function cyrb64Hash(str: string, seed = 0) {
26 | const [h2, h1] = cyrb64(str, seed);
27 | return h2.toString(36).padStart(7, "0") + h1.toString(36).padStart(7, "0");
28 | }
29 |
30 |
--------------------------------------------------------------------------------
/src/lib/utils/findInReactTree.ts:
--------------------------------------------------------------------------------
1 | import { findInTree } from "@lib/utils";
2 | import { SearchFilter } from "@lib/utils/findInTree";
3 |
4 | export default function findInReactTree(tree: { [key: string]: any; }, filter: SearchFilter): any {
5 | return findInTree(tree, filter, {
6 | walkable: ["props", "children", "child", "sibling"],
7 | });
8 | }
9 |
--------------------------------------------------------------------------------
/src/lib/utils/findInTree.ts:
--------------------------------------------------------------------------------
1 | // This has been completely reimplemented at this point, but the disclaimer at the end of disclaimers still counts.
2 | // https://github.com/Cordwood/Cordwood/blob/91c0b971bbf05e112927df75415df99fa105e1e7/src/lib/utils/findInTree.ts
3 |
4 | export type SearchTree = Record;
5 | export type SearchFilter = (tree: SearchTree) => boolean;
6 | export interface FindInTreeOptions {
7 | walkable?: string[];
8 | ignore?: string[];
9 | maxDepth?: number;
10 | }
11 |
12 | function treeSearch(tree: SearchTree, filter: SearchFilter, opts: Required, depth: number): any {
13 | if (depth > opts.maxDepth) return;
14 | if (!tree) return;
15 |
16 | try {
17 | if (filter(tree)) return tree;
18 | } catch { }
19 |
20 | if (Array.isArray(tree)) {
21 | for (const item of tree) {
22 | if (typeof item !== "object" || item === null) continue;
23 |
24 | try {
25 | const found = treeSearch(item, filter, opts, depth + 1);
26 | if (found) return found;
27 | } catch { }
28 | }
29 | } else if (typeof tree === "object") {
30 | for (const key of Object.keys(tree)) {
31 | if (typeof tree[key] !== "object" || tree[key] === null) continue;
32 | if (opts.walkable.length && !opts.walkable.includes(key)) continue;
33 | if (opts.ignore.includes(key)) continue;
34 |
35 | try {
36 | const found = treeSearch(tree[key], filter, opts, depth + 1);
37 | if (found) return found;
38 | } catch { }
39 | }
40 | }
41 | }
42 |
43 | export default function findInTree(
44 | tree: SearchTree,
45 | filter: SearchFilter,
46 | {
47 | walkable = [],
48 | ignore = [],
49 | maxDepth = 100
50 | }: FindInTreeOptions = {},
51 | ): any | undefined {
52 | return treeSearch(tree, filter, { walkable, ignore, maxDepth }, 0);
53 | }
54 |
--------------------------------------------------------------------------------
/src/lib/utils/hookDefineProperty.ts:
--------------------------------------------------------------------------------
1 | import { LiteralUnion } from "type-fest";
2 |
3 | type KeyOfOrAny = P extends keyof T ? T[P] : any;
4 |
5 | export default function hookDefineProperty<
6 | T extends object,
7 | P extends LiteralUnion
8 | >(target: T, property: LiteralUnion, cb: (val: KeyOfOrAny) => KeyOfOrAny
) {
9 | const targetAsAny = target as any;
10 |
11 | if (property in target) {
12 | return void cb(targetAsAny[property]);
13 | }
14 |
15 | let value: any;
16 |
17 | Object.defineProperty(targetAsAny, property, {
18 | get: () => value,
19 | set(v) {
20 | value = cb(v) ?? v;
21 | },
22 | configurable: true,
23 | enumerable: false
24 | });
25 |
26 | return () => {
27 | delete targetAsAny[property];
28 | targetAsAny[property] = value;
29 | };
30 | }
31 |
--------------------------------------------------------------------------------
/src/lib/utils/index.ts:
--------------------------------------------------------------------------------
1 | import * as constants from "./constants";
2 | import cyrb64 from "./cyrb64";
3 | import findInReactTree from "./findInReactTree";
4 | import findInTree from "./findInTree";
5 | import hookDefineProperty from "./hookDefineProperty";
6 | import invariant from "./invariant";
7 | import * as lazy from "./lazy";
8 | import * as logger from "./logger";
9 | import safeFetch from "./safeFetch";
10 |
11 | export {
12 | constants,
13 | cyrb64,
14 | findInReactTree,
15 | findInTree,
16 | hookDefineProperty,
17 | invariant,
18 | lazy,
19 | logger,
20 | safeFetch };
21 |
--------------------------------------------------------------------------------
/src/lib/utils/invariant.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * `invariant` is used to [assert](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#assertion-functions) that the `condition` is [truthy](https://github.com/getify/You-Dont-Know-JS/blob/bdbe570600d4e1107d0b131787903ca1c9ec8140/up%20%26%20going/ch2.md#truthy--falsy).
3 | * 💥 `invariant` will `throw` an `Error` if the `condition` is [falsey](https://github.com/getify/You-Dont-Know-JS/blob/bdbe570600d4e1107d0b131787903ca1c9ec8140/up%20%26%20going/ch2.md#truthy--falsy)
4 | * 🤏 `message`s are not displayed in production environments to help keep bundles small
5 | *
6 | * ```ts
7 | * const value: Person | null = { name: "Alex" };
8 | * invariant(value, "Expected value to be a person");
9 | * // type of `value`` has been narrowed to `Person`
10 | * ```
11 | */
12 | export default function invariant(
13 | condition: any,
14 | message?: string | (() => string),
15 | ): asserts condition {
16 | if (condition) return;
17 |
18 | const resolvedMessage: string | undefined = typeof message === "function" ? message() : message;
19 | const prefix = "[Invariant Violation]";
20 | const value = resolvedMessage ? `${prefix}: ${resolvedMessage}` : prefix;
21 |
22 | throw new Error(value);
23 | }
24 |
--------------------------------------------------------------------------------
/src/lib/utils/isValidHttpUrl.ts:
--------------------------------------------------------------------------------
1 | // https://stackoverflow.com/a/43467144/15031462
2 | export default function isValidHttpUrl(input: string) {
3 | let url: URL;
4 |
5 | try {
6 | url = new URL(input);
7 | } catch {
8 | return false;
9 | }
10 |
11 | return url.protocol === "http:" || url.protocol === "https:";
12 | }
13 |
--------------------------------------------------------------------------------
/src/lib/utils/logger.ts:
--------------------------------------------------------------------------------
1 | import { findByNameLazy } from "@metro/wrappers";
2 |
3 | type LoggerFunction = (...messages: any[]) => void;
4 | export interface Logger {
5 | log: LoggerFunction;
6 | info: LoggerFunction;
7 | warn: LoggerFunction;
8 | error: LoggerFunction;
9 | time: LoggerFunction;
10 | trace: LoggerFunction;
11 | verbose: LoggerFunction;
12 | }
13 |
14 | export const LoggerClass = findByNameLazy("Logger");
15 | export const logger: Logger = new LoggerClass("Revenge");
16 |
--------------------------------------------------------------------------------
/src/lib/utils/safeFetch.ts:
--------------------------------------------------------------------------------
1 | // A really basic fetch wrapper which throws on non-ok response codes
2 | export default async function safeFetch(input: RequestInfo | URL, options?: RequestInit, timeout = 10000) {
3 | const req = await fetch(input, {
4 | signal: timeoutSignal(timeout),
5 | ...options
6 | });
7 |
8 | if (!req.ok) throw new Error(`Request returned non-ok: ${req.status} ${req.statusText}`);
9 | return req;
10 | }
11 |
12 | function timeoutSignal(ms: number): AbortSignal {
13 | const controller = new AbortController();
14 | setTimeout(() => controller.abort(`Timed out after ${ms}ms`), ms);
15 | return controller.signal;
16 | }
17 |
--------------------------------------------------------------------------------
/src/lib/utils/types.ts:
--------------------------------------------------------------------------------
1 | export type Nullish = null | undefined;
2 |
--------------------------------------------------------------------------------
/src/metro/common/index.ts:
--------------------------------------------------------------------------------
1 | import { lazyDestructure } from "@lib/utils/lazy";
2 | import { findByFilePathLazy, findByProps, findByPropsLazy } from "@metro/wrappers";
3 |
4 | import type { Dispatcher } from "./types/flux";
5 | import { Linking } from "react-native";
6 |
7 | export * as components from "./components";
8 |
9 | // Discord
10 | export const constants = findByPropsLazy("Fonts", "Permissions");
11 | export const channels = findByPropsLazy("getVoiceChannelId");
12 | export const i18n = findByPropsLazy("Messages");
13 |
14 | // Polyfill LinkingUtils
15 | const openURL = (url: string) => Linking.openURL(url);
16 | export const url = nativeModuleProxy.NativeLinkingModule || nativeModuleProxy.DCDLinkingManager ? {
17 | openURL,
18 | openDeeplink: openURL,
19 | handleSupportedURL: openURL,
20 | isDiscordConnectOauth2Deeplink: () => {
21 | console.warn("url.isDiscordConnectOauth2Deeplink is not implemented and will always return false");
22 | return false;
23 | },
24 | showLongPressUrlActionSheet: () => console.warn("url.showLongPressUrlActionSheet is not implemented"),
25 | handleMessageLinking: findByFilePathLazy("modules/links/native/handleContentLinking.tsx", true),
26 | } : findByPropsLazy("openURL", "openDeeplink");
27 |
28 | export const clipboard = findByPropsLazy("setString", "getString", "hasString");
29 | export const assets = findByPropsLazy("registerAsset");
30 | export const invites = findByPropsLazy("acceptInviteAndTransitionToInviteChannel");
31 | export const commands = findByPropsLazy("getBuiltInCommands");
32 | export const navigation = findByPropsLazy("pushLazy");
33 | export const toasts = findByFilePathLazy("modules/toast/native/ToastActionCreators.tsx", true);
34 | export const messageUtil = findByPropsLazy("sendBotMessage");
35 | export const navigationStack = findByPropsLazy("createStackNavigator");
36 | export const NavigationNative = findByPropsLazy("NavigationContainer");
37 | export const semver = findByPropsLazy("parse", "clean");
38 |
39 | export const tokens = findByPropsLazy("unsafe_rawColors", "colors");
40 | export const { useToken } = lazyDestructure(() => findByProps("useToken"));
41 |
42 | // Flux
43 | export const Flux = findByPropsLazy("connectStores");
44 | // TODO: Making this a proxy/lazy fuck things up for some reason
45 | export const FluxDispatcher = findByProps("_interceptors") as Dispatcher;
46 | export const FluxUtils = findByProps("useStateFromStores");
47 |
48 | // React
49 | export const React = window.React = findByPropsLazy("createElement") as typeof import("react");
50 | export const ReactNative = window.ReactNative = findByPropsLazy("AppRegistry") as typeof import("react-native");
51 |
--------------------------------------------------------------------------------
/src/metro/common/stores.ts:
--------------------------------------------------------------------------------
1 | import { findByStoreNameLazy } from "@metro/wrappers";
2 |
3 | export const UserStore = findByStoreNameLazy("UserStore");
4 |
--------------------------------------------------------------------------------
/src/metro/common/types/flux.ts:
--------------------------------------------------------------------------------
1 | export interface Dispatcher {
2 | _actionHandlers: unknown;
3 | _interceptors?: ((payload: any) => void | boolean)[];
4 | _currentDispatchActionType: undefined | string;
5 | _processingWaitQueue: boolean;
6 | _subscriptions: Record void>>;
7 | _waitQueue: unknown[];
8 |
9 | addDependencies(node1: any, node2: any): void;
10 | dispatch(payload: any): Promise;
11 | flushWaitQueue(): void;
12 | isDispatching(): boolean;
13 | register(name: string, actionHandler: Record void>, storeDidChange: (e: any) => boolean): string;
14 | setInterceptor(interceptor?: (payload: any) => void | boolean): void;
15 | subscribe(actionType: string, callback: (payload: any) => void): void;
16 | unsubscribe(actionType: string, callback: (payload: any) => void): void;
17 | wait(cb: () => void): void;
18 | }
19 |
--------------------------------------------------------------------------------
/src/metro/factories.ts:
--------------------------------------------------------------------------------
1 |
2 | import { FilterCheckDef, FilterDefinition, ModuleExports } from "./types";
3 |
4 | export function createFilterDefinition(
5 | fn: FilterCheckDef,
6 | uniqMaker: (args: A) => string
7 | ): FilterDefinition {
8 | function createHolder(func: T, args: A, raw: boolean) {
9 | return Object.assign(func, {
10 | filter: fn,
11 | raw,
12 | uniq: [
13 | raw && "raw::",
14 | uniqMaker(args)
15 | ].filter(Boolean).join("")
16 | });
17 | }
18 |
19 | const curry = (raw: boolean) => (...args: A) => {
20 | return createHolder((m: ModuleExports, id: number, defaultCheck: boolean) => {
21 | return fn(args, m, id, defaultCheck);
22 | }, args, raw);
23 | };
24 |
25 | return Object.assign(curry(false), {
26 | byRaw: curry(true),
27 | uniqMaker
28 | });
29 | }
30 |
31 | export function createSimpleFilter(
32 | filter: (m: ModuleExports) => boolean,
33 | uniq: string
34 | ) {
35 | return createFilterDefinition(
36 | (_, m) => filter(m),
37 | () => `dynamic::${uniq}`
38 | )();
39 | }
40 |
41 |
42 |
--------------------------------------------------------------------------------
/src/metro/filters.ts:
--------------------------------------------------------------------------------
1 | import { createFilterDefinition } from "./factories";
2 | import { metroModules } from "./internals/modules";
3 |
4 | export const byProps = createFilterDefinition(
5 | (props, m) => props.length === 0 ? m[props[0]] : props.every(p => m[p]),
6 | props => `bunny.metro.byProps(${props.join(",")})`
7 | );
8 |
9 | export const byName = createFilterDefinition<[string]>(
10 | ([name], m) => m.name === name,
11 | name => `bunny.metro.byName(${name})`
12 | );
13 |
14 | export const byDisplayName = createFilterDefinition<[string]>(
15 | ([displayName], m) => m.displayName === displayName,
16 | name => `bunny.metro.byDisplayName(${name})`
17 | );
18 |
19 | export const byTypeName = createFilterDefinition<[string]>(
20 | ([typeName], m) => m.type?.name === typeName,
21 | name => `bunny.metro.byTypeName(${name})`
22 | );
23 |
24 | export const byStoreName = createFilterDefinition<[string]>(
25 | ([name], m) => m.getName?.length === 0 && m.getName() === name,
26 | name => `bunny.metro.byStoreName(${name})`
27 | );
28 |
29 | export const byFilePath = createFilterDefinition<[string, boolean]>(
30 | // module return depends on defaultCheck. if true, it'll return module.default, otherwise the whole module
31 | // unlike filters like byName, defaultCheck doesn't affect the return since we don't rely on exports, but only its ID
32 | // one could say that this is technically a hack, since defaultCheck is meant for filtering exports
33 | ([path, exportDefault], _, id, defaultCheck) => (exportDefault === defaultCheck) && metroModules[id]?.__filePath === path,
34 | ([path, exportDefault]) => `bunny.metro.byFilePath(${path},${exportDefault})`
35 | );
36 |
37 | export const byMutableProp = createFilterDefinition<[string]>(
38 | ([prop], m) => m?.[prop] && !Object.getOwnPropertyDescriptor(m, prop)?.get,
39 | prop => `bunny.metro.byMutableProp(${prop})`
40 | );
41 |
--------------------------------------------------------------------------------
/src/metro/finders.ts:
--------------------------------------------------------------------------------
1 | import { getCacherForUniq } from "./internals/caches";
2 | import { getModules, requireModule } from "./internals/modules";
3 | import { FilterFn } from "./types";
4 |
5 | function filterExports(
6 | moduleExports: any,
7 | moduleId: number,
8 | filter: FilterFn,
9 | ) {
10 | if (
11 | moduleExports.default &&
12 | moduleExports.__esModule &&
13 | filter(moduleExports.default, moduleId, true)
14 | ) {
15 | return {
16 | exports: filter.raw ? moduleExports : moduleExports.default,
17 | defaultExport: !filter.raw
18 | };
19 | }
20 |
21 | if (!filter.raw && filter(moduleExports, moduleId, false)) {
22 | return { exports: moduleExports, defaultExport: false };
23 | }
24 |
25 | return {};
26 | }
27 |
28 | /**
29 | * Returns the [id, defaultExports] of the first module where filter returns non-undefined, and undefined otherwise.
30 | * @param filter find calls filter once for each enumerable module's exports until it finds one where filter returns a thruthy value.
31 | */
32 | export function findModule(filter: FilterFn) {
33 | const { cacheId, finish } = getCacherForUniq(filter.uniq, false);
34 |
35 | for (const [id, moduleExports] of getModules(filter.uniq, false)) {
36 | const { exports: testedExports, defaultExport } = filterExports(
37 | moduleExports,
38 | id,
39 | filter,
40 | );
41 | if (testedExports !== undefined) {
42 | cacheId(id, testedExports);
43 | return { id, defaultExport };
44 | }
45 | }
46 |
47 | finish(true);
48 | return {};
49 | }
50 |
51 | /**
52 | * Returns the id of the first module where filter returns non-undefined, and undefined otherwise.
53 | * @param filter find calls filter once for each enumerable module's exports until it finds one where filter returns a thruthy value.
54 | */
55 | export function findModuleId(filter: FilterFn) {
56 | return findModule(filter)?.id;
57 | }
58 |
59 | /**
60 | * Returns the exports of the first module where filter returns non-undefined, and undefined otherwise.
61 | * @param filter find calls filter once for each enumerable module's exports until it finds one where filter returns a thruthy value.
62 | */
63 | export function findExports(filter: FilterFn) {
64 | const { id, defaultExport } = findModule(filter);
65 | if (id == null) return;
66 |
67 | return defaultExport ? requireModule(id).default : requireModule(id);
68 | }
69 |
70 | /**
71 | * Returns the [id, defaultExports] of all modules where filter returns non-undefined.
72 | * @param filter findAll calls filter once for each enumerable module's exports, adding the exports to the returned array when filter returns a thruthy value.
73 | */
74 | export function findAllModule(filter: FilterFn) {
75 | const { cacheId, finish } = getCacherForUniq(filter.uniq, true);
76 | const foundExports: {id: number, defaultExport: boolean}[] = [];
77 |
78 | for (const [id, moduleExports] of getModules(filter.uniq, true)) {
79 | const { exports: testedExports, defaultExport } = filterExports(
80 | moduleExports,
81 | id,
82 | filter,
83 | );
84 | if (testedExports !== undefined && typeof defaultExport === "boolean") {
85 | foundExports.push({ id, defaultExport });
86 | cacheId(id, testedExports);
87 | }
88 | }
89 |
90 | finish(foundExports.length === 0);
91 | return foundExports;
92 | }
93 |
94 | /**
95 | * Returns the ids of all modules where filter returns non-undefined.
96 | * @param filter findAll calls filter once for each enumerable module's exports, adding the exports to the returned array when filter returns a thruthy value.
97 | */
98 | export function findAllModuleId(filter: FilterFn) {
99 | return findAllModule(filter).map(e => e.id);
100 | }
101 |
102 | /**
103 | * Returns the ids of all exports where filter returns non-undefined.
104 | * @param filter findAll calls filter once for each enumerable module's exports, adding the exports to the returned array when filter returns a thruthy value.
105 | */
106 | export function findAllExports(filter: FilterFn) {
107 | return findAllModule(filter).map(ret => {
108 | if (!ret.id) return;
109 |
110 | const { id, defaultExport } = ret;
111 | return defaultExport ? requireModule(id).default : requireModule(id);
112 | });
113 | }
114 |
--------------------------------------------------------------------------------
/src/metro/index.ts:
--------------------------------------------------------------------------------
1 | export * as common from "./common";
2 | export * as factories from "./factories";
3 | export * as filters from "./filters";
4 | export * from "./finders";
5 | export * as lazy from "./lazy";
6 | export * from "./wrappers";
7 |
--------------------------------------------------------------------------------
/src/metro/internals/caches.ts:
--------------------------------------------------------------------------------
1 | import { fileExists, readFile, writeFile } from "@lib/api/native/fs";
2 | import { NativeClientInfoModule } from "@lib/api/native/modules";
3 | import { debounce } from "es-toolkit";
4 |
5 | import { ModuleFlags, ModulesMapInternal } from "./enums";
6 |
7 | const CACHE_VERSION = 102;
8 | const BUNNY_METRO_CACHE_PATH = "caches/metro_modules.json";
9 |
10 | type ModulesMap = {
11 | [flag in number | `_${ModulesMapInternal}`]?: ModuleFlags;
12 | };
13 |
14 | let _metroCache = null as unknown as ReturnType;
15 |
16 | export const getMetroCache = () => _metroCache;
17 |
18 | function buildInitCache() {
19 | const cache = {
20 | _v: CACHE_VERSION,
21 | _buildNumber: NativeClientInfoModule.Build as number,
22 | _modulesCount: Object.keys(window.modules).length,
23 | flagsIndex: {} as Record,
24 | findIndex: {} as Record,
25 | polyfillIndex: {} as Record
26 | } as const;
27 |
28 | // Force load all modules so useful modules are pre-cached. Add a minor
29 | // delay so the cache is initialized before the modules are loaded.
30 | setTimeout(() => {
31 | for (const id in window.modules) {
32 | require("./modules").requireModule(id);
33 | }
34 | }, 100);
35 |
36 | _metroCache = cache;
37 | return cache;
38 | }
39 |
40 | /** @internal */
41 | export async function initMetroCache() {
42 | if (!await fileExists(BUNNY_METRO_CACHE_PATH)) return void buildInitCache();
43 | const rawCache = await readFile(BUNNY_METRO_CACHE_PATH);
44 |
45 | try {
46 | _metroCache = JSON.parse(rawCache);
47 | if (_metroCache._v !== CACHE_VERSION) {
48 | _metroCache = null!;
49 | throw "cache invalidated; cache version outdated";
50 | }
51 | if (_metroCache._buildNumber !== NativeClientInfoModule.Build) {
52 | _metroCache = null!;
53 | throw "cache invalidated; version mismatch";
54 | }
55 | if (_metroCache._modulesCount !== Object.keys(window.modules).length) {
56 | _metroCache = null!;
57 | throw "cache invalidated; modules count mismatch";
58 | }
59 | } catch {
60 | buildInitCache();
61 | }
62 | }
63 |
64 | const saveCache = debounce(() => {
65 | writeFile(BUNNY_METRO_CACHE_PATH, JSON.stringify(_metroCache));
66 | }, 1000);
67 |
68 | function extractExportsFlags(moduleExports: any) {
69 | if (!moduleExports) return undefined;
70 |
71 | const bit = ModuleFlags.EXISTS;
72 | return bit;
73 | }
74 |
75 | /** @internal */
76 | export function indexExportsFlags(moduleId: number, moduleExports: any) {
77 | const flags = extractExportsFlags(moduleExports);
78 | if (flags && flags !== ModuleFlags.EXISTS) {
79 | _metroCache.flagsIndex[moduleId] = flags;
80 | }
81 | }
82 |
83 | /** @internal */
84 | export function indexBlacklistFlag(id: number) {
85 | _metroCache.flagsIndex[id] |= ModuleFlags.BLACKLISTED;
86 | }
87 |
88 | /** @internal */
89 | export function indexAssetModuleFlag(id: number) {
90 | _metroCache.flagsIndex[id] |= ModuleFlags.ASSET;
91 | }
92 |
93 | /** @internal */
94 | export function getCacherForUniq(uniq: string, allFind: boolean) {
95 | const indexObject = _metroCache.findIndex[uniq] ??= {};
96 |
97 | return {
98 | cacheId(moduleId: number, exports: any) {
99 | indexObject[moduleId] ??= extractExportsFlags(exports);
100 |
101 | saveCache();
102 | },
103 | // Finish may not be called by single find
104 | finish(notFound: boolean) {
105 | if (allFind) indexObject[`_${ModulesMapInternal.FULL_LOOKUP}`] = 1;
106 | if (notFound) indexObject[`_${ModulesMapInternal.NOT_FOUND}`] = 1;
107 |
108 | saveCache();
109 | }
110 | };
111 | }
112 |
113 | /** @internal */
114 | export function getPolyfillModuleCacher(name: string) {
115 | const indexObject = _metroCache.polyfillIndex[name] ??= {};
116 |
117 | return {
118 | getModules() {
119 | return require("@metro/internals/modules").getCachedPolyfillModules(name);
120 | },
121 | cacheId(moduleId: number) {
122 | indexObject[moduleId] = 1;
123 | saveCache();
124 | },
125 | finish() {
126 | indexObject[`_${ModulesMapInternal.FULL_LOOKUP}`] = 1;
127 | saveCache();
128 | }
129 | };
130 | }
131 |
--------------------------------------------------------------------------------
/src/metro/internals/enums.ts:
--------------------------------------------------------------------------------
1 | export enum ModuleFlags {
2 | EXISTS = 1 << 0,
3 | BLACKLISTED = 1 << 1,
4 | ASSET = 1 << 2,
5 | }
6 |
7 | export enum ModulesMapInternal {
8 | FULL_LOOKUP,
9 | NOT_FOUND
10 | }
11 |
--------------------------------------------------------------------------------
/src/metro/lazy.ts:
--------------------------------------------------------------------------------
1 | import { _patcherDelaySymbol } from "@lib/api/patcher";
2 | import { proxyLazy } from "@lib/utils/lazy";
3 |
4 | import { findExports } from "./finders";
5 | import { getMetroCache } from "./internals/caches";
6 | import { metroModules, subscribeModule } from "./internals/modules";
7 | import type { FilterFn, LazyModuleContext } from "./types";
8 |
9 | /** @internal */
10 | export const _lazyContextSymbol = Symbol.for("bunny.metro.lazyContext");
11 |
12 | const _lazyContexts = new WeakMap();
13 |
14 | function getIndexedFind(filter: FilterFn) {
15 | const modulesMap = getMetroCache().findIndex[filter.uniq];
16 | if (!modulesMap) return undefined;
17 |
18 | for (const k in modulesMap)
19 | if (k[0] !== "_") return Number(k);
20 | }
21 |
22 | function subscribeLazyModule(proxy: any, callback: (exports: any) => void) {
23 | const info = getLazyContext(proxy);
24 | if (!info) throw new Error("Subscribing a module for non-proxy-find");
25 | if (!info.indexed) throw new Error("Attempting to subscribe to a non-indexed find");
26 |
27 | return subscribeModule(info.moduleId!, () => {
28 | callback(findExports(info.filter));
29 | });
30 | }
31 |
32 | export function getLazyContext(proxy: any): LazyModuleContext | void {
33 | return _lazyContexts.get(proxy) as unknown as LazyModuleContext;
34 | }
35 |
36 | export function createLazyModule(filter: FilterFn) {
37 | let cache: any = undefined;
38 |
39 | const moduleId = getIndexedFind(filter);
40 | const context: LazyModuleContext = {
41 | filter,
42 | indexed: !!moduleId,
43 | moduleId,
44 | getExports(cb: (exports: any) => void) {
45 | if (!moduleId || metroModules[moduleId]?.isInitialized) {
46 | cb(this.forceLoad());
47 | return () => void 0;
48 | }
49 | return this.subscribe(cb);
50 | },
51 | subscribe(cb: (exports: any) => void) {
52 | return subscribeLazyModule(proxy, cb);
53 | },
54 | get cache() {
55 | return cache;
56 | },
57 | forceLoad() {
58 | cache ??= findExports(filter);
59 | if (!cache) throw new Error(`${filter.uniq} is ${typeof cache}! (id ${context.moduleId ?? "unknown"})`);
60 | return cache;
61 | }
62 | };
63 |
64 | const proxy = proxyLazy(() => context.forceLoad(), {
65 | exemptedEntries: {
66 | [_lazyContextSymbol]: context,
67 | [_patcherDelaySymbol]: (cb: (exports: any) => void) => context.getExports(cb)
68 | }
69 | });
70 |
71 | _lazyContexts.set(proxy, context as LazyModuleContext);
72 |
73 | return proxy;
74 | }
75 |
--------------------------------------------------------------------------------
/src/metro/polyfills/redesign.ts:
--------------------------------------------------------------------------------
1 | import { getPolyfillModuleCacher } from "@metro/internals/caches";
2 | import { LiteralUnion } from "type-fest";
3 |
4 | const redesignProps = new Set([
5 | "AlertActionButton",
6 | "AlertModal",
7 | "AlertModalContainer",
8 | "AvatarDuoPile",
9 | "AvatarPile",
10 | "BACKDROP_OPAQUE_MAX_OPACITY",
11 | "Backdrop",
12 | "Button",
13 | "Card",
14 | "ContextMenu",
15 | "ContextMenuContainer",
16 | "FauxHeader",
17 | "FloatingActionButton",
18 | "GhostInput",
19 | "GuildIconPile",
20 | "HeaderActionButton",
21 | "HeaderButton",
22 | "HeaderSubmittingIndicator",
23 | "IconButton",
24 | "Input",
25 | "InputButton",
26 | "InputContainer",
27 | "LayerContext",
28 | "LayerScope",
29 | "Modal",
30 | "ModalActionButton",
31 | "ModalContent",
32 | "ModalDisclaimer",
33 | "ModalFloatingAction",
34 | "ModalFloatingActionSpacer",
35 | "ModalFooter",
36 | "ModalScreen",
37 | "ModalStepIndicator",
38 | "NAV_BAR_HEIGHT",
39 | "NAV_BAR_HEIGHT_MULTILINE",
40 | "Navigator",
41 | "NavigatorHeader",
42 | "NavigatorScreen",
43 | "Pile",
44 | "PileOverflow",
45 | "RedesignCompat",
46 | "RedesignCompatContext",
47 | "RowButton",
48 | "STATUS_BAR_HEIGHT",
49 | "SceneLoadingIndicator",
50 | "SearchField",
51 | "SegmentedControl",
52 | "SegmentedControlPages",
53 | "Slider",
54 | "Stack",
55 | "StepModal",
56 | "StickyContext",
57 | "StickyHeader",
58 | "StickyWrapper",
59 | "TABLE_ROW_CONTENT_HEIGHT",
60 | "TABLE_ROW_HEIGHT",
61 | "TableCheckboxRow",
62 | "TableRadioGroup",
63 | "TableRadioRow",
64 | "TableRow",
65 | "TableRowGroup",
66 | "TableRowGroupTitle",
67 | "TableRowIcon",
68 | "TableSwitchRow",
69 | "Tabs",
70 | "TextArea",
71 | "TextField",
72 | "TextInput",
73 | "Toast",
74 | "dismissAlerts",
75 | "getHeaderBackButton",
76 | "getHeaderCloseButton",
77 | "getHeaderConditionalBackButton",
78 | "getHeaderNoTitle",
79 | "getHeaderTextButton",
80 | "hideContextMenu",
81 | "navigatorShouldCrossfade",
82 | "openAlert",
83 | "useAccessibilityNativeStackOptions",
84 | "useAndroidNavScrim",
85 | "useCoachmark",
86 | "useFloatingActionButtonScroll",
87 | "useFloatingActionButtonState",
88 | "useNativeStackNavigation",
89 | "useNavigation",
90 | "useNavigationTheme",
91 | "useNavigatorBackPressHandler",
92 | "useNavigatorScreens",
93 | "useNavigatorShouldCrossfade",
94 | "useSegmentedControlState",
95 | "useStackNavigation",
96 | "useTabNavigation",
97 | "useTooltip"
98 | ] as const);
99 |
100 | type Keys = LiteralUnion ? U : string, string>;
101 |
102 | const _module = {} as Record;
103 | const _source = {} as Record;
104 |
105 | const cacher = getPolyfillModuleCacher("redesign_module");
106 |
107 | for (const [id, moduleExports] of cacher.getModules()) {
108 | for (const prop of redesignProps) {
109 | let actualExports: any;
110 |
111 | if (moduleExports[prop]) {
112 | actualExports = moduleExports;
113 | } else if (moduleExports.default?.[prop]) {
114 | actualExports = moduleExports.default;
115 | } else {
116 | continue;
117 | }
118 |
119 | const exportsKeysLength = Reflect.ownKeys(actualExports).length;
120 | if (_source[prop] && exportsKeysLength >= _source[prop]) {
121 | continue;
122 | }
123 |
124 | _module[prop] = actualExports[prop];
125 | _source[prop] = Reflect.ownKeys(actualExports).length;
126 | cacher.cacheId(id);
127 |
128 | if (exportsKeysLength === 1) {
129 | redesignProps.delete(prop);
130 | }
131 | }
132 | }
133 |
134 | cacher.finish();
135 |
136 | export default _module;
137 |
--------------------------------------------------------------------------------
/src/metro/wrappers.ts:
--------------------------------------------------------------------------------
1 | import { byDisplayName, byFilePath, byName, byProps, byStoreName, byTypeName } from "./filters";
2 | import { findAllExports, findExports } from "./finders";
3 | import { createLazyModule } from "./lazy";
4 |
5 | export const findByProps = (...props: string[]) => findExports(byProps(...props));
6 | export const findByPropsLazy = (...props: string[]) => createLazyModule(byProps(...props));
7 | export const findByPropsAll = (...props: string[]) => findAllExports(byProps(...props));
8 |
9 | export const findByName = (name: string, expDefault = true) => findExports(expDefault ? byName(name) : byName.byRaw(name));
10 | export const findByNameLazy = (name: string, expDefault = true) => createLazyModule(expDefault ? byName(name) : byName.byRaw(name));
11 | export const findByNameAll = (name: string, expDefault = true) => findAllExports(expDefault ? byName(name) : byName.byRaw(name));
12 |
13 | export const findByDisplayName = (name: string, expDefault = true) => findExports(expDefault ? byDisplayName(name) : byDisplayName.byRaw(name));
14 | export const findByDisplayNameLazy = (name: string, expDefault = true) => createLazyModule(expDefault ? byDisplayName(name) : byDisplayName.byRaw(name));
15 | export const findByDisplayNameAll = (name: string, expDefault = true) => findAllExports(expDefault ? byDisplayName(name) : byDisplayName.byRaw(name));
16 |
17 | export const findByTypeName = (name: string, expDefault = true) => findExports(expDefault ? byTypeName(name) : byTypeName.byRaw(name));
18 | export const findByTypeNameLazy = (name: string, expDefault = true) => createLazyModule(expDefault ? byTypeName(name) : byTypeName.byRaw(name));
19 | export const findByTypeNameAll = (name: string, expDefault = true) => findAllExports(expDefault ? byTypeName(name) : byTypeName.byRaw(name));
20 |
21 | export const findByStoreName = (name: string) => findExports(byStoreName(name));
22 | export const findByStoreNameLazy = (name: string) => createLazyModule(byStoreName(name));
23 |
24 | export const findByFilePath = (path: string, expDefault = false) => findExports(byFilePath(path, expDefault));
25 | export const findByFilePathLazy = (path: string, expDefault = false) => createLazyModule(byFilePath(path, expDefault));
26 |
--------------------------------------------------------------------------------
/src/modules.d.ts:
--------------------------------------------------------------------------------
1 | declare module "bunny-build-info" {
2 | const version: string;
3 | }
4 |
5 | declare module "*.png" {
6 | const str: string;
7 | export default str;
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "exclude": [
3 | "dist"
4 | ],
5 | "compilerOptions": {
6 | "strict": true,
7 | "paths": {
8 | // Just to discourage from consuming the module directly
9 | "spitroast": ["./shims/emptyModule"],
10 |
11 | "@core/*": ["./src/core/*"],
12 | "@lib": ["./src/lib"],
13 | "@lib/*": ["./src/lib/*"],
14 | "@metro": ["./src/metro"],
15 | "@metro/*": ["./src/metro/*"],
16 | "@ui": ["./src/lib/ui"],
17 | "@ui/*": ["./src/lib/ui/*"],
18 | "@types": ["./src/lib/utils/types.ts"],
19 | "@assets/*": ["./src/assets/*"]
20 | },
21 | "module": "esnext",
22 | "moduleResolution": "bundler",
23 | "resolveJsonModule": true,
24 | "allowJs": true,
25 | "checkJs": true,
26 | "jsx": "react-jsx",
27 | "target": "esnext",
28 |
29 | "skipLibCheck": true,
30 | "declaration": true,
31 | "emitDeclarationOnly": true,
32 | "stripInternal": true
33 | }
34 | }
--------------------------------------------------------------------------------