├── .github
└── workflows
│ └── build.yml
├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── build.mjs
├── package.json
├── pnpm-lock.yaml
├── src
├── def.d.ts
├── entry.ts
├── index.ts
├── lib
│ ├── commands.ts
│ ├── constants.ts
│ ├── debug.ts
│ ├── emitter.ts
│ ├── fixes.ts
│ ├── logger.ts
│ ├── metro
│ │ ├── common.ts
│ │ └── filters.ts
│ ├── native.ts
│ ├── patcher.ts
│ ├── plugins.ts
│ ├── polyfills.ts
│ ├── preinit.ts
│ ├── settings.ts
│ ├── storage
│ │ ├── backends.ts
│ │ └── index.ts
│ ├── themes.ts
│ ├── utils
│ │ ├── findInReactTree.ts
│ │ ├── findInTree.ts
│ │ ├── index.ts
│ │ ├── safeFetch.ts
│ │ ├── unfreeze.ts
│ │ └── without.ts
│ └── windowObject.ts
└── ui
│ ├── alerts.ts
│ ├── assets.ts
│ ├── color.ts
│ ├── components
│ ├── Codeblock.tsx
│ ├── ErrorBoundary.tsx
│ ├── InputAlert.tsx
│ ├── Search.tsx
│ ├── Summary.tsx
│ ├── TabulatedScreen.tsx
│ └── index.ts
│ ├── quickInstall
│ ├── forumPost.tsx
│ ├── index.ts
│ └── url.tsx
│ ├── safeMode.tsx
│ ├── settings
│ ├── components
│ │ ├── AddonPage.tsx
│ │ ├── AssetDisplay.tsx
│ │ ├── Card.tsx
│ │ ├── InstallButton.tsx
│ │ ├── PluginCard.tsx
│ │ ├── SettingsSection.tsx
│ │ ├── ThemeCard.tsx
│ │ └── Version.tsx
│ ├── data.tsx
│ ├── index.ts
│ ├── pages
│ │ ├── AssetBrowser.tsx
│ │ ├── Developer.tsx
│ │ ├── General.tsx
│ │ ├── Plugins.tsx
│ │ ├── Secret.tsx
│ │ └── Themes.tsx
│ └── patches
│ │ ├── panels.tsx
│ │ └── you.tsx
│ ├── shared.ts
│ └── toasts.ts
└── tsconfig.json
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 | on:
3 | push:
4 | branches: [rewrite]
5 |
6 | jobs:
7 | build:
8 | name: Build and push
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - uses: actions/checkout@v3
13 | - uses: actions/checkout@v3
14 | with:
15 | repository: "bound-mod/builds"
16 | path: "builds"
17 | token: ${{ secrets.MAISY_TOKEN }}
18 | - uses: actions/setup-node@v3
19 | with:
20 | node-version: 16
21 |
22 | - name: Install dependencies
23 | run: |
24 | npm i -g pnpm
25 | pnpm i
26 |
27 | - name: Build
28 | run: pnpm build
29 |
30 | - name: Push builds
31 | run: |
32 | rm $GITHUB_WORKSPACE/builds/* || true
33 | cp -r dist/* $GITHUB_WORKSPACE/builds || true
34 | cd $GITHUB_WORKSPACE/builds
35 | git config --local user.email "actions@github.com"
36 | git config --local user.name "GitHub Actions"
37 | git add .
38 | git commit -m "Build $GITHUB_SHA" || exit 0
39 | git push
40 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist/
3 | package-lock.json
4 | yarn.lock
5 | .DS_Store
6 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 240,
3 | "tabWidth": 4,
4 | "singleQuote": false,
5 | "jsxSingleQuote": false,
6 | "bracketSpacing": true,
7 | "useTabs": false
8 | }
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2024, Team Vendetta
4 |
5 | Redistribution and use in source and binary forms, with or without
6 | modification, are permitted provided that the following conditions are met:
7 |
8 | 1. Redistributions of source code must retain the above copyright notice, this
9 | list of conditions and the following disclaimer.
10 |
11 | 2. Redistributions in binary form must reproduce the above copyright notice,
12 | this list of conditions and the following disclaimer in the documentation
13 | and/or other materials provided with the distribution.
14 |
15 | 3. Neither the name of the copyright holder nor the names of its
16 | contributors may be used to endorse or promote products derived from
17 | this software without specific prior written permission.
18 |
19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Bound
2 | Discord mobile client aimed at providing the user no control, instability and zero customizability.
3 |
4 |
5 | ...
6 |
7 |
8 |
--------------------------------------------------------------------------------
/build.mjs:
--------------------------------------------------------------------------------
1 | import { build } from "esbuild";
2 | import alias from "esbuild-plugin-alias";
3 | import swc from "@swc/core";
4 | import { promisify } from "util";
5 | import { exec as _exec } from "child_process";
6 | import fs from "fs/promises";
7 | import path from "path";
8 | const exec = promisify(_exec);
9 |
10 | const tsconfig = JSON.parse(await fs.readFile("./tsconfig.json"));
11 | const aliases = Object.fromEntries(Object.entries(tsconfig.compilerOptions.paths).map(([alias, [target]]) => [alias, path.resolve(target)]));
12 | const commit = (await exec("git rev-parse HEAD")).stdout.trim().substring(0, 7) || "custom";
13 |
14 | try {
15 | await build({
16 | entryPoints: ["./src/entry.ts"],
17 | outfile: "./dist/bound.js",
18 | minify: true,
19 | bundle: true,
20 | format: "iife",
21 | target: "esnext",
22 | plugins: [
23 | {
24 | name: "swc",
25 | setup: (build) => {
26 | build.onLoad({ filter: /\.[jt]sx?/ }, async (args) => {
27 | // This actually works for dependencies as well!!
28 | const result = await swc.transformFile(args.path, {
29 | jsc: {
30 | externalHelpers: true,
31 | },
32 | env: {
33 | targets: "defaults",
34 | include: [
35 | "transform-classes",
36 | "transform-arrow-functions",
37 | ],
38 | },
39 | });
40 | return { contents: result.code };
41 | });
42 | },
43 | },
44 | alias(aliases),
45 | ],
46 | define: {
47 | __vendettaVersion: `"${commit}"`,
48 | },
49 | footer: {
50 | js: "//# sourceURL=Bound",
51 | },
52 | legalComments: "none",
53 | });
54 |
55 | console.log("Build successful!");
56 | } catch (e) {
57 | console.error("Build failed...", e);
58 | process.exit(1);
59 | }
60 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bound",
3 | "description": "Discord mobile client aimed at providing the user no control, instability and zero customizability.",
4 | "version": "3.0.0",
5 | "private": true,
6 | "scripts": {
7 | "build": "node build.mjs"
8 | },
9 | "author": "Team Vendetta",
10 | "license": "BSD-3-Clause",
11 | "devDependencies": {
12 | "@react-native-clipboard/clipboard": "1.10.0",
13 | "@swc/core": "1.3.50",
14 | "@types/chroma-js": "^2.4.0",
15 | "@types/lodash": "^4.14.194",
16 | "@types/react": "18.0.35",
17 | "@types/react-native": "0.70.6",
18 | "esbuild": "^0.17.16",
19 | "esbuild-plugin-alias": "^0.2.1",
20 | "moment": "2.22.2",
21 | "typescript": "^5.0.4"
22 | },
23 | "dependencies": {
24 | "@swc/helpers": "0.5.0",
25 | "spitroast": "^1.4.3"
26 | },
27 | "pnpm": {
28 | "peerDependencyRules": {
29 | "ignoreMissing": [
30 | "react",
31 | "react-native"
32 | ]
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/pnpm-lock.yaml:
--------------------------------------------------------------------------------
1 | lockfileVersion: '6.0'
2 |
3 | settings:
4 | autoInstallPeers: true
5 | excludeLinksFromLockfile: false
6 |
7 | dependencies:
8 | '@swc/helpers':
9 | specifier: 0.5.0
10 | version: 0.5.0
11 | spitroast:
12 | specifier: ^1.4.3
13 | version: 1.4.3
14 |
15 | devDependencies:
16 | '@react-native-clipboard/clipboard':
17 | specifier: 1.10.0
18 | version: 1.10.0
19 | '@swc/core':
20 | specifier: 1.3.50
21 | version: 1.3.50(@swc/helpers@0.5.0)
22 | '@types/chroma-js':
23 | specifier: ^2.4.0
24 | version: 2.4.0
25 | '@types/lodash':
26 | specifier: ^4.14.194
27 | version: 4.14.194
28 | '@types/react':
29 | specifier: 18.0.35
30 | version: 18.0.35
31 | '@types/react-native':
32 | specifier: 0.70.6
33 | version: 0.70.6
34 | esbuild:
35 | specifier: ^0.17.16
36 | version: 0.17.16
37 | esbuild-plugin-alias:
38 | specifier: ^0.2.1
39 | version: 0.2.1
40 | moment:
41 | specifier: 2.22.2
42 | version: 2.22.2
43 | typescript:
44 | specifier: ^5.0.4
45 | version: 5.2.2
46 |
47 | packages:
48 |
49 | /@esbuild/android-arm64@0.17.16:
50 | resolution: {integrity: sha512-QX48qmsEZW+gcHgTmAj+x21mwTz8MlYQBnzF6861cNdQGvj2jzzFjqH0EBabrIa/WVZ2CHolwMoqxVryqKt8+Q==}
51 | engines: {node: '>=12'}
52 | cpu: [arm64]
53 | os: [android]
54 | requiresBuild: true
55 | dev: true
56 | optional: true
57 |
58 | /@esbuild/android-arm@0.17.16:
59 | resolution: {integrity: sha512-baLqRpLe4JnKrUXLJChoTN0iXZH7El/mu58GE3WIA6/H834k0XWvLRmGLG8y8arTRS9hJJibPnF0tiGhmWeZgw==}
60 | engines: {node: '>=12'}
61 | cpu: [arm]
62 | os: [android]
63 | requiresBuild: true
64 | dev: true
65 | optional: true
66 |
67 | /@esbuild/android-x64@0.17.16:
68 | resolution: {integrity: sha512-G4wfHhrrz99XJgHnzFvB4UwwPxAWZaZBOFXh+JH1Duf1I4vIVfuYY9uVLpx4eiV2D/Jix8LJY+TAdZ3i40tDow==}
69 | engines: {node: '>=12'}
70 | cpu: [x64]
71 | os: [android]
72 | requiresBuild: true
73 | dev: true
74 | optional: true
75 |
76 | /@esbuild/darwin-arm64@0.17.16:
77 | resolution: {integrity: sha512-/Ofw8UXZxuzTLsNFmz1+lmarQI6ztMZ9XktvXedTbt3SNWDn0+ODTwxExLYQ/Hod91EZB4vZPQJLoqLF0jvEzA==}
78 | engines: {node: '>=12'}
79 | cpu: [arm64]
80 | os: [darwin]
81 | requiresBuild: true
82 | dev: true
83 | optional: true
84 |
85 | /@esbuild/darwin-x64@0.17.16:
86 | resolution: {integrity: sha512-SzBQtCV3Pdc9kyizh36Ol+dNVhkDyIrGb/JXZqFq8WL37LIyrXU0gUpADcNV311sCOhvY+f2ivMhb5Tuv8nMOQ==}
87 | engines: {node: '>=12'}
88 | cpu: [x64]
89 | os: [darwin]
90 | requiresBuild: true
91 | dev: true
92 | optional: true
93 |
94 | /@esbuild/freebsd-arm64@0.17.16:
95 | resolution: {integrity: sha512-ZqftdfS1UlLiH1DnS2u3It7l4Bc3AskKeu+paJSfk7RNOMrOxmeFDhLTMQqMxycP1C3oj8vgkAT6xfAuq7ZPRA==}
96 | engines: {node: '>=12'}
97 | cpu: [arm64]
98 | os: [freebsd]
99 | requiresBuild: true
100 | dev: true
101 | optional: true
102 |
103 | /@esbuild/freebsd-x64@0.17.16:
104 | resolution: {integrity: sha512-rHV6zNWW1tjgsu0dKQTX9L0ByiJHHLvQKrWtnz8r0YYJI27FU3Xu48gpK2IBj1uCSYhJ+pEk6Y0Um7U3rIvV8g==}
105 | engines: {node: '>=12'}
106 | cpu: [x64]
107 | os: [freebsd]
108 | requiresBuild: true
109 | dev: true
110 | optional: true
111 |
112 | /@esbuild/linux-arm64@0.17.16:
113 | resolution: {integrity: sha512-8yoZhGkU6aHu38WpaM4HrRLTFc7/VVD9Q2SvPcmIQIipQt2I/GMTZNdEHXoypbbGao5kggLcxg0iBKjo0SQYKA==}
114 | engines: {node: '>=12'}
115 | cpu: [arm64]
116 | os: [linux]
117 | requiresBuild: true
118 | dev: true
119 | optional: true
120 |
121 | /@esbuild/linux-arm@0.17.16:
122 | resolution: {integrity: sha512-n4O8oVxbn7nl4+m+ISb0a68/lcJClIbaGAoXwqeubj/D1/oMMuaAXmJVfFlRjJLu/ZvHkxoiFJnmbfp4n8cdSw==}
123 | engines: {node: '>=12'}
124 | cpu: [arm]
125 | os: [linux]
126 | requiresBuild: true
127 | dev: true
128 | optional: true
129 |
130 | /@esbuild/linux-ia32@0.17.16:
131 | resolution: {integrity: sha512-9ZBjlkdaVYxPNO8a7OmzDbOH9FMQ1a58j7Xb21UfRU29KcEEU3VTHk+Cvrft/BNv0gpWJMiiZ/f4w0TqSP0gLA==}
132 | engines: {node: '>=12'}
133 | cpu: [ia32]
134 | os: [linux]
135 | requiresBuild: true
136 | dev: true
137 | optional: true
138 |
139 | /@esbuild/linux-loong64@0.17.16:
140 | resolution: {integrity: sha512-TIZTRojVBBzdgChY3UOG7BlPhqJz08AL7jdgeeu+kiObWMFzGnQD7BgBBkWRwOtKR1i2TNlO7YK6m4zxVjjPRQ==}
141 | engines: {node: '>=12'}
142 | cpu: [loong64]
143 | os: [linux]
144 | requiresBuild: true
145 | dev: true
146 | optional: true
147 |
148 | /@esbuild/linux-mips64el@0.17.16:
149 | resolution: {integrity: sha512-UPeRuFKCCJYpBbIdczKyHLAIU31GEm0dZl1eMrdYeXDH+SJZh/i+2cAmD3A1Wip9pIc5Sc6Kc5cFUrPXtR0XHA==}
150 | engines: {node: '>=12'}
151 | cpu: [mips64el]
152 | os: [linux]
153 | requiresBuild: true
154 | dev: true
155 | optional: true
156 |
157 | /@esbuild/linux-ppc64@0.17.16:
158 | resolution: {integrity: sha512-io6yShgIEgVUhExJejJ21xvO5QtrbiSeI7vYUnr7l+v/O9t6IowyhdiYnyivX2X5ysOVHAuyHW+Wyi7DNhdw6Q==}
159 | engines: {node: '>=12'}
160 | cpu: [ppc64]
161 | os: [linux]
162 | requiresBuild: true
163 | dev: true
164 | optional: true
165 |
166 | /@esbuild/linux-riscv64@0.17.16:
167 | resolution: {integrity: sha512-WhlGeAHNbSdG/I2gqX2RK2gfgSNwyJuCiFHMc8s3GNEMMHUI109+VMBfhVqRb0ZGzEeRiibi8dItR3ws3Lk+cA==}
168 | engines: {node: '>=12'}
169 | cpu: [riscv64]
170 | os: [linux]
171 | requiresBuild: true
172 | dev: true
173 | optional: true
174 |
175 | /@esbuild/linux-s390x@0.17.16:
176 | resolution: {integrity: sha512-gHRReYsJtViir63bXKoFaQ4pgTyah4ruiMRQ6im9YZuv+gp3UFJkNTY4sFA73YDynmXZA6hi45en4BGhNOJUsw==}
177 | engines: {node: '>=12'}
178 | cpu: [s390x]
179 | os: [linux]
180 | requiresBuild: true
181 | dev: true
182 | optional: true
183 |
184 | /@esbuild/linux-x64@0.17.16:
185 | resolution: {integrity: sha512-mfiiBkxEbUHvi+v0P+TS7UnA9TeGXR48aK4XHkTj0ZwOijxexgMF01UDFaBX7Q6CQsB0d+MFNv9IiXbIHTNd4g==}
186 | engines: {node: '>=12'}
187 | cpu: [x64]
188 | os: [linux]
189 | requiresBuild: true
190 | dev: true
191 | optional: true
192 |
193 | /@esbuild/netbsd-x64@0.17.16:
194 | resolution: {integrity: sha512-n8zK1YRDGLRZfVcswcDMDM0j2xKYLNXqei217a4GyBxHIuPMGrrVuJ+Ijfpr0Kufcm7C1k/qaIrGy6eG7wvgmA==}
195 | engines: {node: '>=12'}
196 | cpu: [x64]
197 | os: [netbsd]
198 | requiresBuild: true
199 | dev: true
200 | optional: true
201 |
202 | /@esbuild/openbsd-x64@0.17.16:
203 | resolution: {integrity: sha512-lEEfkfsUbo0xC47eSTBqsItXDSzwzwhKUSsVaVjVji07t8+6KA5INp2rN890dHZeueXJAI8q0tEIfbwVRYf6Ew==}
204 | engines: {node: '>=12'}
205 | cpu: [x64]
206 | os: [openbsd]
207 | requiresBuild: true
208 | dev: true
209 | optional: true
210 |
211 | /@esbuild/sunos-x64@0.17.16:
212 | resolution: {integrity: sha512-jlRjsuvG1fgGwnE8Afs7xYDnGz0dBgTNZfgCK6TlvPH3Z13/P5pi6I57vyLE8qZYLrGVtwcm9UbUx1/mZ8Ukag==}
213 | engines: {node: '>=12'}
214 | cpu: [x64]
215 | os: [sunos]
216 | requiresBuild: true
217 | dev: true
218 | optional: true
219 |
220 | /@esbuild/win32-arm64@0.17.16:
221 | resolution: {integrity: sha512-TzoU2qwVe2boOHl/3KNBUv2PNUc38U0TNnzqOAcgPiD/EZxT2s736xfC2dYQbszAwo4MKzzwBV0iHjhfjxMimg==}
222 | engines: {node: '>=12'}
223 | cpu: [arm64]
224 | os: [win32]
225 | requiresBuild: true
226 | dev: true
227 | optional: true
228 |
229 | /@esbuild/win32-ia32@0.17.16:
230 | resolution: {integrity: sha512-B8b7W+oo2yb/3xmwk9Vc99hC9bNolvqjaTZYEfMQhzdpBsjTvZBlXQ/teUE55Ww6sg//wlcDjOaqldOKyigWdA==}
231 | engines: {node: '>=12'}
232 | cpu: [ia32]
233 | os: [win32]
234 | requiresBuild: true
235 | dev: true
236 | optional: true
237 |
238 | /@esbuild/win32-x64@0.17.16:
239 | resolution: {integrity: sha512-xJ7OH/nanouJO9pf03YsL9NAFQBHd8AqfrQd7Pf5laGyyTt/gToul6QYOA/i5i/q8y9iaM5DQFNTgpi995VkOg==}
240 | engines: {node: '>=12'}
241 | cpu: [x64]
242 | os: [win32]
243 | requiresBuild: true
244 | dev: true
245 | optional: true
246 |
247 | /@react-native-clipboard/clipboard@1.10.0:
248 | resolution: {integrity: sha512-1L+I0vmeUJgMi8MnNsqI00391/RFLkmmxj9qAuOS2madpvce/oNqJb8Pwk2Fc/uxIJSxOckTpq+dQwyPU6s+7w==}
249 | peerDependencies:
250 | react: '>=16.0'
251 | react-native: '>=0.57.0'
252 | peerDependenciesMeta:
253 | react:
254 | optional: true
255 | react-native:
256 | optional: true
257 | dev: true
258 |
259 | /@swc/core-darwin-arm64@1.3.50:
260 | resolution: {integrity: sha512-riJGLORCFOMeUccEV0hzua0iyJFks7kef+5GfcmC93SLno+LHFDnaJ4mKVXcCAmQ7GYhVTPJs9gSHIW2fO5anQ==}
261 | engines: {node: '>=10'}
262 | cpu: [arm64]
263 | os: [darwin]
264 | requiresBuild: true
265 | dev: true
266 | optional: true
267 |
268 | /@swc/core-darwin-x64@1.3.50:
269 | resolution: {integrity: sha512-XaAhpeUoAK8tOUYSXH/v35yEjIoSP6ClGV/EaqBmDuCzAPue6uJMlIAW+nTmdtqVm5ZNZy2cKtP4ZHhVlfl7xw==}
270 | engines: {node: '>=10'}
271 | cpu: [x64]
272 | os: [darwin]
273 | requiresBuild: true
274 | dev: true
275 | optional: true
276 |
277 | /@swc/core-linux-arm-gnueabihf@1.3.50:
278 | resolution: {integrity: sha512-8hDtXs0T5biMtA3I21JQG1uxL+Hb/D2t0NZENuajVK5Vky3GXmf+ICVeQzwGzSXiyftaDgyNAvBidbKPBlNEtw==}
279 | engines: {node: '>=10'}
280 | cpu: [arm]
281 | os: [linux]
282 | requiresBuild: true
283 | dev: true
284 | optional: true
285 |
286 | /@swc/core-linux-arm64-gnu@1.3.50:
287 | resolution: {integrity: sha512-iS908P5cNTHWus4QefSg2jn4lDYcl15sN1Fvx8fQgqYQra2O9CsR8lXBJYkvllykkzoKvWfcOLRCTquz3vsnVQ==}
288 | engines: {node: '>=10'}
289 | cpu: [arm64]
290 | os: [linux]
291 | requiresBuild: true
292 | dev: true
293 | optional: true
294 |
295 | /@swc/core-linux-arm64-musl@1.3.50:
296 | resolution: {integrity: sha512-ysh8MeaWjGqVwIPCDUhUOr4oczIx5qb8vFBoegI+SOUfcpWik22/+hG25LWzZY6PwAtqUGkhsJt/+5dY4IMhEA==}
297 | engines: {node: '>=10'}
298 | cpu: [arm64]
299 | os: [linux]
300 | requiresBuild: true
301 | dev: true
302 | optional: true
303 |
304 | /@swc/core-linux-x64-gnu@1.3.50:
305 | resolution: {integrity: sha512-Ci4LQaGIPweWNVWgR2f47nrYEfq7002Pj6WWKGrnO6g+k5cwR3izxHMOnZhcKyAD3cWOS904i/GbfgXs2wBCDQ==}
306 | engines: {node: '>=10'}
307 | cpu: [x64]
308 | os: [linux]
309 | requiresBuild: true
310 | dev: true
311 | optional: true
312 |
313 | /@swc/core-linux-x64-musl@1.3.50:
314 | resolution: {integrity: sha512-SEXOhGjmC4rdBeucCvNmtO2vflUEhkmWLfqvkalHYDbPMA/gwLSoYu3D85u5XqB8DatDi4TOCUx80IR1b/vDBQ==}
315 | engines: {node: '>=10'}
316 | cpu: [x64]
317 | os: [linux]
318 | requiresBuild: true
319 | dev: true
320 | optional: true
321 |
322 | /@swc/core-win32-arm64-msvc@1.3.50:
323 | resolution: {integrity: sha512-DPsJ2r9mYU8VzF9vhK323psyE8modj5be9M9diOsqF58Fu9ARtOfuulY+eiS5e41ya2XM/H2N/qOfsA+h2KRcg==}
324 | engines: {node: '>=10'}
325 | cpu: [arm64]
326 | os: [win32]
327 | requiresBuild: true
328 | dev: true
329 | optional: true
330 |
331 | /@swc/core-win32-ia32-msvc@1.3.50:
332 | resolution: {integrity: sha512-2iyzHLat0C93S3XLp7QJ6RTA9Md+EcPl2fq1S/m2EZqofcq7wu5SuywaXoF89xOibOJBnWe6KwOnOFWFaXrPjQ==}
333 | engines: {node: '>=10'}
334 | cpu: [ia32]
335 | os: [win32]
336 | requiresBuild: true
337 | dev: true
338 | optional: true
339 |
340 | /@swc/core-win32-x64-msvc@1.3.50:
341 | resolution: {integrity: sha512-iFRU2Y5DVIEdjaWnlLij8QQBM5Q91UJotNNgzuevPSIOhOyhF6V2AQS1QC4mfkPCy3Bw0GrZDChu3GcuBj9Rzw==}
342 | engines: {node: '>=10'}
343 | cpu: [x64]
344 | os: [win32]
345 | requiresBuild: true
346 | dev: true
347 | optional: true
348 |
349 | /@swc/core@1.3.50(@swc/helpers@0.5.0):
350 | resolution: {integrity: sha512-soTAHlwkI8zukR9KftWZ0gZ7HKU99B/C3CtBxzSI3N23QG+EfSSOgrYARfuZk5g4yLWpsU0rEkojd78vixqkwg==}
351 | engines: {node: '>=10'}
352 | requiresBuild: true
353 | peerDependencies:
354 | '@swc/helpers': ^0.5.0
355 | peerDependenciesMeta:
356 | '@swc/helpers':
357 | optional: true
358 | dependencies:
359 | '@swc/helpers': 0.5.0
360 | optionalDependencies:
361 | '@swc/core-darwin-arm64': 1.3.50
362 | '@swc/core-darwin-x64': 1.3.50
363 | '@swc/core-linux-arm-gnueabihf': 1.3.50
364 | '@swc/core-linux-arm64-gnu': 1.3.50
365 | '@swc/core-linux-arm64-musl': 1.3.50
366 | '@swc/core-linux-x64-gnu': 1.3.50
367 | '@swc/core-linux-x64-musl': 1.3.50
368 | '@swc/core-win32-arm64-msvc': 1.3.50
369 | '@swc/core-win32-ia32-msvc': 1.3.50
370 | '@swc/core-win32-x64-msvc': 1.3.50
371 | dev: true
372 |
373 | /@swc/helpers@0.5.0:
374 | resolution: {integrity: sha512-SjY/p4MmECVVEWspzSRpQEM3sjR17sP8PbGxELWrT+YZMBfiUyt1MRUNjMV23zohwlG2HYtCQOsCwsTHguXkyg==}
375 | dependencies:
376 | tslib: 2.6.2
377 |
378 | /@types/chroma-js@2.4.0:
379 | resolution: {integrity: sha512-JklMxityrwjBTjGY2anH8JaTx3yjRU3/sEHSblLH1ba5lqcSh1LnImXJZO5peJfXyqKYWjHTGy4s5Wz++hARrw==}
380 | dev: true
381 |
382 | /@types/lodash@4.14.194:
383 | resolution: {integrity: sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g==}
384 | dev: true
385 |
386 | /@types/prop-types@15.7.5:
387 | resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
388 | dev: true
389 |
390 | /@types/react-native@0.70.6:
391 | resolution: {integrity: sha512-ynQ2jj0km9d7dbnyKqVdQ6Nti7VQ8SLTA/KKkkS5+FnvGyvij2AOo1/xnkBR/jnSNXuzrvGVzw2n0VWfppmfKw==}
392 | dependencies:
393 | '@types/react': 18.0.35
394 | dev: true
395 |
396 | /@types/react@18.0.35:
397 | resolution: {integrity: sha512-6Laome31HpetaIUGFWl1VQ3mdSImwxtFZ39rh059a1MNnKGqBpC88J6NJ8n/Is3Qx7CefDGLgf/KhN/sYCf7ag==}
398 | dependencies:
399 | '@types/prop-types': 15.7.5
400 | '@types/scheduler': 0.16.3
401 | csstype: 3.1.2
402 | dev: true
403 |
404 | /@types/scheduler@0.16.3:
405 | resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==}
406 | dev: true
407 |
408 | /csstype@3.1.2:
409 | resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==}
410 | dev: true
411 |
412 | /esbuild-plugin-alias@0.2.1:
413 | resolution: {integrity: sha512-jyfL/pwPqaFXyKnj8lP8iLk6Z0m099uXR45aSN8Av1XD4vhvQutxxPzgA2bTcAwQpa1zCXDcWOlhFgyP3GKqhQ==}
414 | dev: true
415 |
416 | /esbuild@0.17.16:
417 | resolution: {integrity: sha512-aeSuUKr9aFVY9Dc8ETVELGgkj4urg5isYx8pLf4wlGgB0vTFjxJQdHnNH6Shmx4vYYrOTLCHtRI5i1XZ9l2Zcg==}
418 | engines: {node: '>=12'}
419 | hasBin: true
420 | requiresBuild: true
421 | optionalDependencies:
422 | '@esbuild/android-arm': 0.17.16
423 | '@esbuild/android-arm64': 0.17.16
424 | '@esbuild/android-x64': 0.17.16
425 | '@esbuild/darwin-arm64': 0.17.16
426 | '@esbuild/darwin-x64': 0.17.16
427 | '@esbuild/freebsd-arm64': 0.17.16
428 | '@esbuild/freebsd-x64': 0.17.16
429 | '@esbuild/linux-arm': 0.17.16
430 | '@esbuild/linux-arm64': 0.17.16
431 | '@esbuild/linux-ia32': 0.17.16
432 | '@esbuild/linux-loong64': 0.17.16
433 | '@esbuild/linux-mips64el': 0.17.16
434 | '@esbuild/linux-ppc64': 0.17.16
435 | '@esbuild/linux-riscv64': 0.17.16
436 | '@esbuild/linux-s390x': 0.17.16
437 | '@esbuild/linux-x64': 0.17.16
438 | '@esbuild/netbsd-x64': 0.17.16
439 | '@esbuild/openbsd-x64': 0.17.16
440 | '@esbuild/sunos-x64': 0.17.16
441 | '@esbuild/win32-arm64': 0.17.16
442 | '@esbuild/win32-ia32': 0.17.16
443 | '@esbuild/win32-x64': 0.17.16
444 | dev: true
445 |
446 | /moment@2.22.2:
447 | resolution: {integrity: sha512-LRvkBHaJGnrcWvqsElsOhHCzj8mU39wLx5pQ0pc6s153GynCTsPdGdqsVNKAQD9sKnWj11iF7TZx9fpLwdD3fw==}
448 | dev: true
449 |
450 | /spitroast@1.4.3:
451 | resolution: {integrity: sha512-JdkzAy2tT82ahx+eEtM5ohBeHICqFln/Yzo+vPGnE5sX1LYgPHCU2qcaSIJfR/xNrhI0q+ftwFz0H2aJysv3EA==}
452 | dev: false
453 |
454 | /tslib@2.6.2:
455 | resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
456 |
457 | /typescript@5.2.2:
458 | resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==}
459 | engines: {node: '>=14.17'}
460 | hasBin: true
461 | dev: true
462 |
--------------------------------------------------------------------------------
/src/def.d.ts:
--------------------------------------------------------------------------------
1 | import * as _spitroast from "spitroast";
2 | import _React from "react";
3 | import _RN from "react-native";
4 | import _Clipboard from "@react-native-clipboard/clipboard";
5 | import _moment from "moment";
6 | import _chroma from "chroma-js";
7 | import _lodash from "lodash";
8 |
9 | type MetroModules = { [id: number]: any };
10 |
11 | // Component types
12 | interface SummaryProps {
13 | label: string;
14 | icon?: string;
15 | noPadding?: boolean;
16 | noAnimation?: boolean;
17 | children: JSX.Element | JSX.Element[];
18 | }
19 |
20 | interface CodeblockProps {
21 | selectable?: boolean;
22 | style?: _RN.TextStyle;
23 | children?: string;
24 | }
25 |
26 | interface SearchProps {
27 | onChangeText?: (v: string) => void;
28 | placeholder?: string;
29 | style?: _RN.TextStyle;
30 | }
31 |
32 | interface ErrorBoundaryState {
33 | hasErr: boolean;
34 | errText?: string;
35 | }
36 |
37 | interface TabulatedScreenTab {
38 | id: string;
39 | title: string;
40 | render?: React.ComponentType;
41 | onPress?: (tab?: string) => void;
42 | }
43 |
44 | interface TabulatedScreenProps {
45 | tabs: TabulatedScreenTab[];
46 | }
47 |
48 | // Helper types for API functions
49 | type PropIntellisense
= Record
& Record;
50 | type PropsFinder = (...props: T[]) => PropIntellisense;
51 | type PropsFinderAll = (...props: T[]) => PropIntellisense[];
52 |
53 | type LoggerFunction = (...messages: any[]) => void;
54 | interface Logger {
55 | log: LoggerFunction;
56 | info: LoggerFunction;
57 | warn: LoggerFunction;
58 | error: LoggerFunction;
59 | time: LoggerFunction;
60 | trace: LoggerFunction;
61 | verbose: LoggerFunction;
62 | }
63 |
64 | type SearchTree = Record;
65 | type SearchFilter = (tree: SearchTree) => boolean;
66 | interface FindInTreeOptions {
67 | walkable?: string[];
68 | ignore?: string[];
69 | maxDepth?: number;
70 | }
71 |
72 | interface Asset {
73 | name: string;
74 | id: number;
75 | }
76 |
77 | export enum ButtonColors {
78 | BRAND = "brand",
79 | RED = "red",
80 | GREEN = "green",
81 | PRIMARY = "primary",
82 | TRANSPARENT = "transparent",
83 | GREY = "grey",
84 | LIGHTGREY = "lightgrey",
85 | WHITE = "white",
86 | LINK = "link"
87 | }
88 |
89 | interface ConfirmationAlertOptions {
90 | title?: string;
91 | content: string | JSX.Element | (string | JSX.Element)[];
92 | confirmText?: string;
93 | confirmColor?: ButtonColors;
94 | onConfirm: () => void;
95 | secondaryConfirmText?: string;
96 | onConfirmSecondary?: () => void;
97 | cancelText?: string;
98 | onCancel?: () => void;
99 | isDismissable?: boolean;
100 | }
101 |
102 | interface InputAlertProps {
103 | title?: string;
104 | confirmText?: string;
105 | confirmColor?: ButtonColors;
106 | onConfirm: (input: string) => (void | Promise);
107 | cancelText?: string;
108 | placeholder?: string;
109 | initialValue?: string;
110 | secureTextEntry?: boolean;
111 | }
112 |
113 | interface Author {
114 | name: string;
115 | id?: string;
116 | }
117 |
118 | // See https://github.com/vendetta-mod/polymanifest
119 | interface PluginManifest {
120 | name: string;
121 | description: string;
122 | authors: Author[];
123 | main: string;
124 | hash: string;
125 | // Vendor-specific field, contains our own data
126 | vendetta?: {
127 | icon?: string;
128 | };
129 | }
130 |
131 | interface Plugin {
132 | id: string;
133 | manifest: PluginManifest;
134 | enabled: boolean;
135 | update: boolean;
136 | js: string;
137 | }
138 |
139 | interface ThemeData {
140 | name: string;
141 | description?: string;
142 | authors?: Author[];
143 | spec: number;
144 | semanticColors?: Record;
145 | rawColors?: Record;
146 | background?: {
147 | url: string;
148 | blur?: number;
149 | /**
150 | * The alpha value of the background.
151 | * `CHAT_BACKGROUND` of semanticColors alpha value will be ignored when this is specified
152 | */
153 | alpha?: number;
154 | }
155 | }
156 |
157 | interface Theme {
158 | id: string;
159 | selected: boolean;
160 | data: ThemeData;
161 | }
162 |
163 | interface Settings extends StorageObject {
164 | debuggerUrl: string;
165 | developerSettings: boolean;
166 | debugBridgeEnabled: boolean;
167 | rdtEnabled: boolean;
168 | errorBoundaryEnabled: boolean;
169 | inspectionDepth: number;
170 | safeMode?: {
171 | enabled: boolean;
172 | currentThemeId?: string;
173 | };
174 | }
175 |
176 | interface ApplicationCommand {
177 | description: string;
178 | name: string;
179 | options: ApplicationCommandOption[];
180 | execute: (args: any[], ctx: CommandContext) => CommandResult | void | Promise | Promise;
181 | id?: string;
182 | applicationId: string;
183 | displayName: string;
184 | displayDescription: string;
185 | inputType: ApplicationCommandInputType;
186 | type: ApplicationCommandType;
187 | }
188 |
189 | export enum ApplicationCommandInputType {
190 | BUILT_IN,
191 | BUILT_IN_TEXT,
192 | BUILT_IN_INTEGRATION,
193 | BOT,
194 | PLACEHOLDER,
195 | }
196 |
197 | interface ApplicationCommandOption {
198 | name: string;
199 | description: string;
200 | required?: boolean;
201 | type: ApplicationCommandOptionType;
202 | displayName: string;
203 | displayDescription: string;
204 | }
205 |
206 | export enum ApplicationCommandOptionType {
207 | SUB_COMMAND = 1,
208 | SUB_COMMAND_GROUP,
209 | STRING,
210 | INTEGER,
211 | BOOLEAN,
212 | USER,
213 | CHANNEL,
214 | ROLE,
215 | MENTIONABLE,
216 | NUMBER,
217 | ATTACHMENT,
218 | }
219 |
220 | export enum ApplicationCommandType {
221 | CHAT = 1,
222 | USER,
223 | MESSAGE,
224 | }
225 |
226 | interface CommandContext {
227 | channel: any;
228 | guild: any;
229 | }
230 |
231 | interface CommandResult {
232 | content: string;
233 | tts?: boolean;
234 | }
235 |
236 | interface RNConstants extends _RN.PlatformConstants {
237 | // Android
238 | Version: number;
239 | Release: string;
240 | Serial: string;
241 | Fingerprint: string;
242 | Model: string;
243 | Brand: string;
244 | Manufacturer: string;
245 | ServerHost?: string;
246 |
247 | // iOS
248 | forceTouchAvailable: boolean;
249 | interfaceIdiom: string;
250 | osVersion: string;
251 | systemName: string;
252 | }
253 |
254 | /**
255 | * A key-value storage based upon `SharedPreferences` on Android.
256 | *
257 | * These types are based on Android though everything should be the same between
258 | * platforms.
259 | */
260 | interface MMKVManager {
261 | /**
262 | * Get the value for the given `key`, or null
263 | * @param key The key to fetch
264 | */
265 | getItem: (key: string) => Promise;
266 | /**
267 | * Deletes the value for the given `key`
268 | * @param key The key to delete
269 | */
270 | removeItem: (key: string) => void;
271 | /**
272 | * Sets the value of `key` to `value`
273 | */
274 | setItem: (key: string, value: string) => void;
275 | /**
276 | * Goes through every item in storage and returns it, excluding the
277 | * keys specified in `exclude`.
278 | * @param exclude A list of items to exclude from result
279 | */
280 | refresh: (exclude: string[]) => Promise>;
281 | /**
282 | * You will be murdered if you use this function.
283 | * Clears ALL of Discord's settings.
284 | */
285 | clear: () => void;
286 | }
287 |
288 | interface FileManager {
289 | /**
290 | * @param path **Full** path to file
291 | */
292 | fileExists: (path: string) => Promise;
293 | /**
294 | * Allowed URI schemes on Android: `file://`, `content://` ([See here](https://developer.android.com/reference/android/content/ContentResolver#accepts-the-following-uri-schemes:_3))
295 | */
296 | getSize: (uri: string) => Promise;
297 | /**
298 | * @param path **Full** path to file
299 | * @param encoding Set to `base64` in order to encode response
300 | */
301 | readFile(path: string, encoding: "base64" | "utf8"): Promise;
302 | saveFileToGallery?(uri: string, fileName: string, fileType: "PNG" | "JPEG"): Promise;
303 | /**
304 | * Beware! This function has differing functionality on iOS and Android.
305 | * @param storageDir Either `cache` or `documents`.
306 | * @param path Path in `storageDir`, parents are recursively created.
307 | * @param data The data to write to the file
308 | * @param encoding Set to `base64` if `data` is base64 encoded.
309 | * @returns Promise that resolves to path of the file once it got written
310 | */
311 | writeFile(storageDir: "cache" | "documents", path: string, data: string, encoding: "base64" | "utf8"): Promise;
312 | removeFile(storageDir: "cache" | "documents", path: string): Promise;
313 | getConstants: () => {
314 | /**
315 | * The path the `documents` storage dir (see {@link writeFile}) represents.
316 | */
317 | DocumentsDirPath: string;
318 | CacheDirPath: string;
319 | };
320 | /**
321 | * Will apparently cease to exist some time in the future so please use {@link getConstants} instead.
322 | * @deprecated
323 | */
324 | DocumentsDirPath: string;
325 | }
326 |
327 | type EmitterEvent = "SET" | "GET" | "DEL";
328 |
329 | interface EmitterListenerData {
330 | path: string[];
331 | value?: any;
332 | }
333 |
334 | type EmitterListener = (
335 | event: EmitterEvent,
336 | data: EmitterListenerData | any
337 | ) => any;
338 |
339 | type EmitterListeners = Record>
340 |
341 | interface Emitter {
342 | listeners: EmitterListeners;
343 | on: (event: EmitterEvent, listener: EmitterListener) => void;
344 | off: (event: EmitterEvent, listener: EmitterListener) => void;
345 | once: (event: EmitterEvent, listener: EmitterListener) => void;
346 | emit: (event: EmitterEvent, data: EmitterListenerData) => void;
347 | }
348 |
349 | interface StorageObject> {
350 | [key: symbol]: keyof T | Emitter;
351 | }
352 |
353 | interface StorageBackend {
354 | get: () => unknown | Promise;
355 | set: (data: unknown) => void | Promise;
356 | }
357 |
358 | interface LoaderConfig extends StorageObject {
359 | customLoadUrl: {
360 | enabled: boolean;
361 | url: string;
362 | };
363 | loadReactDevTools: boolean;
364 | }
365 |
366 | interface LoaderIdentity {
367 | name: string;
368 | features: {
369 | loaderConfig?: boolean;
370 | devtools?: {
371 | prop: string;
372 | version: string;
373 | },
374 | themes?: {
375 | prop: string;
376 | }
377 | }
378 | }
379 |
380 | interface DiscordStyleSheet {
381 | [index: string]: any,
382 | createStyles: >(sheet: T | (() => T)) => () => T;
383 | createThemedStyleSheet: typeof import("react-native").StyleSheet.create;
384 | }
385 |
386 | interface VendettaObject {
387 | patcher: {
388 | after: typeof _spitroast.after;
389 | before: typeof _spitroast.before;
390 | instead: typeof _spitroast.instead;
391 | };
392 | metro: {
393 | find: (filter: (m: any) => boolean) => any;
394 | findAll: (filter: (m: any) => boolean) => any[];
395 | findByProps: PropsFinder;
396 | findByPropsAll: PropsFinderAll;
397 | findByName: (name: string, defaultExp?: boolean) => any;
398 | findByNameAll: (name: string, defaultExp?: boolean) => any[];
399 | findByDisplayName: (displayName: string, defaultExp?: boolean) => any;
400 | findByDisplayNameAll: (displayName: string, defaultExp?: boolean) => any[];
401 | findByTypeName: (typeName: string, defaultExp?: boolean) => any;
402 | findByTypeNameAll: (typeName: string, defaultExp?: boolean) => any[];
403 | findByStoreName: (name: string) => any;
404 | common: {
405 | constants: PropIntellisense<"Fonts" | "Permissions">;
406 | channels: PropIntellisense<"getVoiceChannelId">;
407 | i18n: PropIntellisense<"Messages">;
408 | url: PropIntellisense<"openURL">;
409 | toasts: PropIntellisense<"open" | "close">;
410 | stylesheet: DiscordStyleSheet;
411 | clipboard: typeof _Clipboard;
412 | assets: PropIntellisense<"registerAsset">;
413 | invites: PropIntellisense<"acceptInviteAndTransitionToInviteChannel">;
414 | commands: PropIntellisense<"getBuiltInCommands">;
415 | navigation: PropIntellisense<"pushLazy">;
416 | navigationStack: PropIntellisense<"createStackNavigator">;
417 | NavigationNative: PropIntellisense<"NavigationContainer">;
418 | // You may ask: "Why not just install Flux's types?"
419 | // Answer: Discord have a (presumably proprietary) fork. It's wildly different.
420 | Flux: PropIntellisense<"connectStores">;
421 | FluxDispatcher: PropIntellisense<"_currentDispatchActionType">;
422 | React: typeof _React;
423 | ReactNative: typeof _RN;
424 | moment: typeof _moment;
425 | chroma: typeof _chroma;
426 | lodash: typeof _lodash;
427 | util: PropIntellisense<"inspect" | "isNullOrUndefined">;
428 | };
429 | };
430 | constants: {
431 | DISCORD_SERVER: string;
432 | DISCORD_SERVER_ID: string;
433 | PLUGINS_CHANNEL_ID: string;
434 | THEMES_CHANNEL_ID: string;
435 | GITHUB: string;
436 | PROXY_PREFIX: string;
437 | HTTP_REGEX: RegExp;
438 | HTTP_REGEX_MULTI: RegExp;
439 | };
440 | utils: {
441 | findInReactTree: (tree: SearchTree, filter: SearchFilter) => any;
442 | findInTree: (tree: SearchTree, filter: SearchFilter, options: FindInTreeOptions) => any;
443 | safeFetch: (input: RequestInfo | URL, options?: RequestInit, timeout?: number) => Promise;
444 | unfreeze: (obj: object) => object;
445 | without: (object: O, ...keys: K) => Omit;
446 | };
447 | debug: {
448 | connectToDebugger: (url: string) => void;
449 | // TODO: Type output?
450 | getDebugInfo: () => void;
451 | }
452 | ui: {
453 | components: {
454 | // Discord
455 | Forms: PropIntellisense<"Form" | "FormSection">;
456 | General: PropIntellisense<"Button" | "Text" | "View">;
457 | Alert: _React.ComponentType;
458 | Button: _React.ComponentType & { Looks: any, Colors: ButtonColors, Sizes: any };
459 | HelpMessage: _React.ComponentType;
460 | SafeAreaView: typeof _RN.SafeAreaView;
461 | // Vendetta
462 | Summary: _React.ComponentType;
463 | ErrorBoundary: _React.ComponentType;
464 | Codeblock: _React.ComponentType;
465 | Search: _React.ComponentType;
466 | TabulatedScreen: _React.ComponentType
467 | }
468 | toasts: {
469 | showToast: (content: string, asset?: number) => void;
470 | };
471 | alerts: {
472 | showConfirmationAlert: (options: ConfirmationAlertOptions) => void;
473 | showCustomAlert: (component: _React.ComponentType, props: any) => void;
474 | showInputAlert: (options: InputAlertProps) => void;
475 | };
476 | assets: {
477 | all: Record;
478 | find: (filter: (a: any) => void) => Asset | null | undefined;
479 | getAssetByName: (name: string) => Asset;
480 | getAssetByID: (id: number) => Asset;
481 | getAssetIDByName: (name: string) => number;
482 | };
483 | // TODO: Make a vain attempt to type these
484 | semanticColors: Record;
485 | rawColors: Record;
486 | };
487 | plugins: {
488 | plugins: Record;
489 | fetchPlugin: (id: string) => Promise;
490 | installPlugin: (id: string, enabled?: boolean) => Promise;
491 | startPlugin: (id: string) => Promise;
492 | stopPlugin: (id: string, disable?: boolean) => void;
493 | removePlugin: (id: string) => void;
494 | getSettings: (id: string) => JSX.Element;
495 | };
496 | themes: {
497 | themes: Record;
498 | fetchTheme: (id: string, selected?: boolean) => Promise;
499 | installTheme: (id: string) => Promise;
500 | selectTheme: (id: string) => Promise;
501 | removeTheme: (id: string) => Promise;
502 | getCurrentTheme: () => Theme | null;
503 | updateThemes: () => Promise;
504 | };
505 | commands: {
506 | registerCommand: (command: ApplicationCommand) => () => void;
507 | };
508 | storage: {
509 | createProxy: (target: T) => { proxy: T, emitter: Emitter };
510 | useProxy: (storage: StorageObject) => T;
511 | createStorage: (backend: StorageBackend) => Promise>;
512 | wrapSync: >(store: T) => Awaited;
513 | awaitSyncWrapper: (store: any) => Promise;
514 | createMMKVBackend: (store: string) => StorageBackend;
515 | createFileBackend: (file: string) => StorageBackend;
516 | };
517 | settings: Settings;
518 | loader: {
519 | identity?: LoaderIdentity;
520 | config: LoaderConfig;
521 | };
522 | logger: Logger;
523 | version: string;
524 | unload: () => void;
525 | }
526 |
527 | interface VendettaPluginObject {
528 | id: string;
529 | manifest: PluginManifest;
530 | storage: Record;
531 | }
532 |
533 | declare global {
534 | type React = typeof _React;
535 | const __vendettaVersion: string;
536 |
537 | interface Window {
538 | [key: PropertyKey]: any;
539 | modules: MetroModules;
540 | vendetta: VendettaObject;
541 | React: typeof _React;
542 | __vendetta_loader?: LoaderIdentity;
543 | }
544 | }
545 |
--------------------------------------------------------------------------------
/src/entry.ts:
--------------------------------------------------------------------------------
1 | import { ClientInfoManager } from "@lib/native";
2 |
3 | // This logs in the native logging implementation, e.g. logcat
4 | console.log("Binding your Discord app in chains...");
5 |
6 | // Make 'freeze' and 'seal' do nothing
7 | Object.freeze = Object;
8 | Object.seal = Object;
9 |
10 | // Prevent Discord from assigning the broken toString polyfill, so the app loads on 221.6+
11 | const origToString = Function.prototype.toString;
12 | Object.defineProperty(Function.prototype, "toString", {
13 | value: origToString,
14 | configurable: true,
15 | writable: false,
16 | });
17 |
18 | import(".").then((m) => m.default()).catch((e) => {
19 | console.log(e?.stack ?? e.toString());
20 | alert([
21 | "Failed to bind your Discord app!\n",
22 | `Build Number: ${ClientInfoManager.Build}`,
23 | `Bound: ${__vendettaVersion}`,
24 | e?.stack || e.toString(),
25 | ].join("\n"));
26 | });
27 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { ReactNative as RN } from "@metro/common";
2 | import { connectToDebugger, connectToRDT, patchLogHook } from "@lib/debug";
3 | import { awaitSyncWrapper } from "@lib/storage";
4 | import { patchCommands } from "@lib/commands";
5 | import { initPlugins } from "@lib/plugins";
6 | import { patchChatBackground } from "@lib/themes";
7 | import { patchAssets } from "@ui/assets";
8 | import initQuickInstall from "@ui/quickInstall";
9 | import initSafeMode from "@ui/safeMode";
10 | import initSettings from "@ui/settings";
11 | import initFixes from "@lib/fixes";
12 | import logger from "@lib/logger";
13 | import windowObject from "@lib/windowObject";
14 | import settings from "@lib/settings";
15 |
16 | export default async () => {
17 | // Load everything in parallel
18 | const unloads = await Promise.all([
19 | patchLogHook(),
20 | patchAssets(),
21 | patchCommands(),
22 | patchChatBackground(),
23 | initFixes(),
24 | initSafeMode(),
25 | initSettings(),
26 | initQuickInstall(),
27 | ]);
28 |
29 | // Wait for our settings proxy shit to be ready
30 | await awaitSyncWrapper(settings);
31 |
32 | // Assign window object
33 | window.vendetta = await windowObject(unloads);
34 |
35 | // Init developer tools
36 | if (settings.debugBridgeEnabled) connectToDebugger(settings.debuggerUrl);
37 | if (settings.rdtEnabled) connectToRDT();
38 |
39 | // Once done, load plugins
40 | unloads.push(await initPlugins());
41 |
42 | // Do the funny
43 | await RN.Image.prefetch("https://bound-mod.github.io/assets/images/fools.png");
44 |
45 | // We good :)
46 | logger.log("Your Discord app has been successfully bound in chains!");
47 | }
48 |
--------------------------------------------------------------------------------
/src/lib/commands.ts:
--------------------------------------------------------------------------------
1 | import { ApplicationCommand, ApplicationCommandType } from "@types";
2 | import { commands as commandsModule } from "@metro/common";
3 | import { after } from "@lib/patcher";
4 |
5 | let commands: ApplicationCommand[] = [];
6 |
7 | export function patchCommands() {
8 | const unpatch = after("getBuiltInCommands", commandsModule, ([type], res: ApplicationCommand[]) => {
9 | if (type === ApplicationCommandType.CHAT) return res.concat(commands);
10 | });
11 |
12 | return () => {
13 | commands = [];
14 | unpatch();
15 | };
16 | }
17 |
18 | export function registerCommand(command: ApplicationCommand): () => void {
19 | // Get built in commands
20 | const builtInCommands = commandsModule.getBuiltInCommands(ApplicationCommandType.CHAT, true, false);
21 | builtInCommands.sort((a: ApplicationCommand, b: ApplicationCommand) => parseInt(b.id!) - parseInt(a.id!));
22 |
23 | const lastCommand = builtInCommands[builtInCommands.length - 1];
24 |
25 | // Override the new command's id to the last command id - 1
26 | command.id = (parseInt(lastCommand.id, 10) - 1).toString();
27 |
28 | // Add it to the commands array
29 | commands.push(command);
30 |
31 | // Return command id so it can be unregistered
32 | return () => (commands = commands.filter(({ id }) => id !== command.id));
33 | }
34 |
--------------------------------------------------------------------------------
/src/lib/constants.ts:
--------------------------------------------------------------------------------
1 | export const DISCORD_SERVER = "https://discord.gg/n9QQ4XhhJP";
2 | export const DISCORD_SERVER_ID = "1015931589865246730";
3 | export const PLUGINS_CHANNEL_ID = "1091880384561684561";
4 | export const THEMES_CHANNEL_ID = "1091880434939482202";
5 | export const GITHUB = "https://github.com/bound-mod";
6 | export const PROXY_PREFIX = "https://vd-plugins.github.io/proxy";
7 | export const HTTP_REGEX = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$/;
8 | 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;
9 |
--------------------------------------------------------------------------------
/src/lib/debug.ts:
--------------------------------------------------------------------------------
1 | import { RNConstants } from "@types";
2 | import { ReactNative as RN } from "@metro/common";
3 | import { after } from "@lib/patcher";
4 | import { getCurrentTheme, selectTheme } from "@lib/themes";
5 | import { ClientInfoManager, DeviceManager } from "@lib/native";
6 | import { getAssetIDByName } from "@ui/assets";
7 | import { showToast } from "@ui/toasts";
8 | import settings from "@lib/settings";
9 | import logger from "@lib/logger";
10 | export let socket: WebSocket;
11 |
12 | export function setSafeMode(state: boolean) {
13 | settings.safeMode = { ...settings.safeMode, enabled: state };
14 |
15 | if (window.__vendetta_loader?.features.themes) {
16 | if (getCurrentTheme()?.id) settings.safeMode!.currentThemeId = getCurrentTheme()!.id;
17 | if (settings.safeMode?.enabled) {
18 | selectTheme("default");
19 | } else if (settings.safeMode?.currentThemeId) {
20 | selectTheme(settings.safeMode?.currentThemeId);
21 | }
22 | }
23 | }
24 |
25 | export function connectToDebugger(url: string) {
26 | if (socket !== undefined && socket.readyState !== WebSocket.CLOSED) socket.close();
27 |
28 | if (!url) {
29 | showToast("Invalid debugger URL!", getAssetIDByName("Small"));
30 | return;
31 | }
32 |
33 | socket = new WebSocket(`ws://${url}`);
34 |
35 | socket.addEventListener("open", () => showToast("Connected to debugger.", getAssetIDByName("Check")));
36 | socket.addEventListener("message", (message: any) => {
37 | try {
38 | (0, eval)(message.data);
39 | } catch (e) {
40 | console.error(e);
41 | }
42 | });
43 |
44 | socket.addEventListener("error", (err: any) => {
45 | console.log(`Debugger error: ${err.message}`);
46 | showToast("An error occurred with the debugger connection!", getAssetIDByName("Small"));
47 | });
48 | }
49 |
50 | export const connectToRDT = () => window.__vendetta_rdc?.connectToDevTools({
51 | host: settings.debuggerUrl.split(":")?.[0],
52 | resolveRNStyle: RN.StyleSheet.flatten,
53 | });
54 |
55 | export function patchLogHook() {
56 | const unpatch = after("nativeLoggingHook", globalThis, (args) => {
57 | if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ message: args[0], level: args[1] }));
58 | logger.log(args[0]);
59 | });
60 |
61 | return () => {
62 | socket && socket.close();
63 | unpatch();
64 | }
65 | }
66 |
67 | export const versionHash: string = __vendettaVersion;
68 |
69 | export function getDebugInfo() {
70 | // Hermes
71 | const hermesProps = window.HermesInternal.getRuntimeProperties();
72 | const hermesVer = hermesProps["OSS Release Version"];
73 | const padding = "for RN ";
74 |
75 | // RN
76 | const PlatformConstants = RN.Platform.constants as RNConstants;
77 | const rnVer = PlatformConstants.reactNativeVersion;
78 |
79 | return {
80 | vendetta: {
81 | version: versionHash,
82 | loader: window.__vendetta_loader?.name.replaceAll("Vendetta", "Bound") /* <--- awful hack lmao */ ?? "Unknown",
83 | },
84 | discord: {
85 | version: ClientInfoManager.Version,
86 | build: ClientInfoManager.Build,
87 | },
88 | react: {
89 | version: React.version,
90 | nativeVersion: hermesVer.startsWith(padding) ? hermesVer.substring(padding.length) : `${rnVer.major}.${rnVer.minor}.${rnVer.patch}`,
91 | },
92 | hermes: {
93 | version: hermesVer,
94 | buildType: hermesProps["Build"],
95 | bytecodeVersion: hermesProps["Bytecode Version"],
96 | },
97 | ...RN.Platform.select(
98 | {
99 | android: {
100 | os: {
101 | name: "Android",
102 | version: PlatformConstants.Release,
103 | sdk: PlatformConstants.Version
104 | },
105 | },
106 | ios: {
107 | os: {
108 | name: PlatformConstants.systemName,
109 | version: PlatformConstants.osVersion
110 | },
111 | }
112 | }
113 | )!,
114 | ...RN.Platform.select(
115 | {
116 | android: {
117 | device: {
118 | manufacturer: PlatformConstants.Manufacturer,
119 | brand: PlatformConstants.Brand,
120 | model: PlatformConstants.Model,
121 | codename: DeviceManager.device
122 | }
123 | },
124 | ios: {
125 | device: {
126 | manufacturer: DeviceManager.deviceManufacturer,
127 | brand: DeviceManager.deviceBrand,
128 | model: DeviceManager.deviceModel,
129 | codename: DeviceManager.device
130 | }
131 | }
132 | }
133 | )!
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/src/lib/emitter.ts:
--------------------------------------------------------------------------------
1 | import { Emitter, EmitterEvent, EmitterListener, EmitterListenerData, EmitterListeners } from "@types";
2 |
3 | export enum Events {
4 | GET = "GET",
5 | SET = "SET",
6 | DEL = "DEL",
7 | };
8 |
9 | export default function createEmitter(): Emitter {
10 | return {
11 | listeners: Object.values(Events).reduce((acc, val: string) => ((acc[val] = new Set()), acc), {}) as EmitterListeners,
12 |
13 | on(event: EmitterEvent, listener: EmitterListener) {
14 | if (!this.listeners[event].has(listener)) this.listeners[event].add(listener);
15 | },
16 |
17 | off(event: EmitterEvent, listener: EmitterListener) {
18 | this.listeners[event].delete(listener);
19 | },
20 |
21 | once(event: EmitterEvent, listener: EmitterListener) {
22 | const once = (event: EmitterEvent, data: EmitterListenerData) => {
23 | this.off(event, once);
24 | listener(event, data);
25 | };
26 | this.on(event, once);
27 | },
28 |
29 | emit(event: EmitterEvent, data: EmitterListenerData) {
30 | for (const listener of this.listeners[event]) listener(event, data);
31 | },
32 | };
33 | }
34 |
--------------------------------------------------------------------------------
/src/lib/fixes.ts:
--------------------------------------------------------------------------------
1 | import { FluxDispatcher, moment } from "@metro/common";
2 | import { findByProps, findByStoreName } from "@metro/filters";
3 | import logger from "@lib/logger";
4 |
5 | const ThemeManager = findByProps("updateTheme", "overrideTheme");
6 | const AMOLEDThemeManager = findByProps("setAMOLEDThemeEnabled");
7 | const ThemeStore = findByStoreName("ThemeStore");
8 | const UnsyncedUserSettingsStore = findByStoreName("UnsyncedUserSettingsStore");
9 |
10 | function onDispatch({ locale }: { locale: string }) {
11 | // Theming
12 | // Based on https://github.com/Aliucord/AliucordRN/blob/main/src/ui/patchTheme.ts
13 | try {
14 | if (ThemeManager) {
15 | ThemeManager.overrideTheme(ThemeStore?.theme ?? "dark");
16 | if (AMOLEDThemeManager && UnsyncedUserSettingsStore.useAMOLEDTheme === 2) AMOLEDThemeManager.setAMOLEDThemeEnabled(true);
17 | }
18 | } catch(e) {
19 | logger.error("Failed to fix theme...", e);
20 | }
21 |
22 | // Timestamps
23 | try {
24 | // TODO: Test if this works with all locales
25 | moment.locale(locale.toLowerCase());
26 | } catch(e) {
27 | logger.error("Failed to fix timestamps...", e);
28 | }
29 |
30 | // We're done here!
31 | FluxDispatcher.unsubscribe("I18N_LOAD_SUCCESS", onDispatch);
32 | }
33 |
34 | export default () => FluxDispatcher.subscribe("I18N_LOAD_SUCCESS", onDispatch);
--------------------------------------------------------------------------------
/src/lib/logger.ts:
--------------------------------------------------------------------------------
1 | import { Logger } from "@types";
2 | import { findByProps } from "@metro/filters";
3 |
4 | export const logModule = findByProps("setLogFn").default;
5 | const logger: Logger = new logModule("Bound");
6 |
7 | export default logger;
8 |
--------------------------------------------------------------------------------
/src/lib/metro/common.ts:
--------------------------------------------------------------------------------
1 | import { find, findByProps, findByStoreName } from "@metro/filters";
2 | import { DiscordStyleSheet } from "@types";
3 | import { ReactNative as RN } from "@lib/preinit";
4 | import type { StyleSheet } from "react-native";
5 |
6 | const ThemeStore = findByStoreName("ThemeStore");
7 | const colorModule = findByProps("colors", "unsafe_rawColors");
8 | const colorResolver = colorModule?.internal ?? colorModule?.meta;
9 |
10 | // Reimplementation of Discord's createThemedStyleSheet, which was removed since 204201
11 | // Not exactly a 1:1 reimplementation, but sufficient to keep compatibility with existing plugins
12 | function createThemedStyleSheet>(sheet: T) {
13 | if (!colorModule) return;
14 | for (const key in sheet) {
15 | // @ts-ignore
16 | sheet[key] = new Proxy(RN.StyleSheet.flatten(sheet[key]), {
17 | get(target, prop, receiver) {
18 | const res = Reflect.get(target, prop, receiver);
19 | return colorResolver.isSemanticColor(res)
20 | ? colorResolver.resolveSemanticColor(ThemeStore.theme, res)
21 | : res
22 | }
23 | });
24 | }
25 |
26 | return sheet;
27 | }
28 |
29 | // Discord
30 | export const constants = findByProps("Fonts", "Permissions");
31 | export const channels = findByProps("getVoiceChannelId");
32 | export const i18n = findByProps("Messages");
33 | export const url = findByProps("openURL", "openDeeplink");
34 | export const toasts = find(m => m.open && m.close && !m.startDrag && !m.init && !m.openReplay && !m.setAlwaysOnTop && !m.setAccountFlag);
35 |
36 | // Compatible with pre-204201 versions since createThemedStyleSheet is undefined.
37 | export const stylesheet = {
38 | ...find(m => m.createStyles && !m.ActionSheet),
39 | createThemedStyleSheet,
40 | ...findByProps("createThemedStyleSheet") as {},
41 | } as DiscordStyleSheet;
42 |
43 | export const clipboard = findByProps("setString", "getString", "hasString") as typeof import("@react-native-clipboard/clipboard").default;
44 | export const assets = findByProps("registerAsset");
45 | export const invites = findByProps("acceptInviteAndTransitionToInviteChannel");
46 | export const commands = findByProps("getBuiltInCommands");
47 | export const navigation = findByProps("pushLazy");
48 | export const navigationStack = findByProps("createStackNavigator");
49 | export const NavigationNative = findByProps("NavigationContainer");
50 | export const { TextStyleSheet } = findByProps("TextStyleSheet");
51 |
52 | // Flux
53 | export const Flux = findByProps("connectStores");
54 | export const FluxDispatcher = findByProps("_currentDispatchActionType");
55 |
56 | // React
57 | export const React = window.React as typeof import("react");
58 | export { ReactNative } from "@lib/preinit";
59 |
60 | // Moment
61 | export const moment = findByProps("isMoment") as typeof import("moment");
62 |
63 | // chroma.js
64 | export { chroma } from "@lib/preinit";
65 |
66 | // Lodash
67 | export const lodash = findByProps("forEachRight") as typeof import("lodash");
68 |
69 | // The node:util polyfill for RN
70 | // TODO: Find types for this
71 | export const util = findByProps("inspect", "isNullOrUndefined");
72 |
--------------------------------------------------------------------------------
/src/lib/metro/filters.ts:
--------------------------------------------------------------------------------
1 | import { MetroModules, PropsFinder, PropsFinderAll } from "@types";
2 |
3 | // Metro require
4 | declare const __r: (moduleId: number) => any;
5 |
6 | // Internal Metro error reporting logic
7 | const originalHandler = window.ErrorUtils.getGlobalHandler();
8 |
9 | // Function to blacklist a module, preventing it from being searched again
10 | const blacklist = (id: number) => Object.defineProperty(window.modules, id, {
11 | value: window.modules[id],
12 | enumerable: false,
13 | configurable: true,
14 | writable: true
15 | });
16 |
17 | // Blacklist any "bad-actor" modules, e.g. the dreaded null proxy, the window itself, or undefined modules
18 | for (const key in window.modules) {
19 | const id = Number(key);
20 | const module = window.modules[id]?.publicModule?.exports;
21 |
22 | if (!module || module === window || module["proxygone"] === null) {
23 | blacklist(id);
24 | continue;
25 | }
26 | }
27 |
28 | // Function to filter through modules
29 | const filterModules = (modules: MetroModules, single = false) => (filter: (m: any) => boolean) => {
30 | const found = [];
31 |
32 | for (const key in modules) {
33 | const id = Number(key);
34 | const module = modules[id]?.publicModule?.exports;
35 |
36 | // HACK: Override the function used to report fatal JavaScript errors (that crash the app) to prevent module-requiring side effects
37 | // Credit to @pylixonly (492949202121261067) for the initial version of this fix
38 | if (!modules[id].isInitialized) try {
39 | window.ErrorUtils.setGlobalHandler(() => {});
40 | __r(id);
41 | window.ErrorUtils.setGlobalHandler(originalHandler);
42 | } catch {}
43 |
44 | if (!module) {
45 | blacklist(id);
46 | continue;
47 | }
48 |
49 | if (module.default && module.__esModule && filter(module.default)) {
50 | if (single) return module.default;
51 | found.push(module.default);
52 | }
53 |
54 | if (filter(module)) {
55 | if (single) return module;
56 | else found.push(module);
57 | }
58 | }
59 |
60 | if (!single) return found;
61 | }
62 |
63 | export const modules = window.modules;
64 | export const find = filterModules(modules, true);
65 | export const findAll = filterModules(modules);
66 |
67 | const propsFilter = (props: (string | symbol)[]) => (m: any) => props.every((p) => m[p] !== undefined);
68 | const nameFilter = (name: string, defaultExp: boolean) => (defaultExp ? (m: any) => m?.name === name : (m: any) => m?.default?.name === name);
69 | const dNameFilter = (displayName: string, defaultExp: boolean) => (defaultExp ? (m: any) => m?.displayName === displayName : (m: any) => m?.default?.displayName === displayName);
70 | const tNameFilter = (typeName: string, defaultExp: boolean) => (defaultExp ? (m: any) => m?.type?.name === typeName : (m: any) => m?.default?.type?.name === typeName);
71 | const storeFilter = (name: string) => (m: any) => m.getName && m.getName.length === 0 && m.getName() === name;
72 |
73 | export const findByProps: PropsFinder = (...props) => find(propsFilter(props));
74 | export const findByPropsAll: PropsFinderAll = (...props) => findAll(propsFilter(props));
75 | export const findByName = (name: string, defaultExp = true) => find(nameFilter(name, defaultExp));
76 | export const findByNameAll = (name: string, defaultExp = true) => findAll(nameFilter(name, defaultExp));
77 | export const findByDisplayName = (displayName: string, defaultExp = true) => find(dNameFilter(displayName, defaultExp));
78 | export const findByDisplayNameAll = (displayName: string, defaultExp = true) => findAll(dNameFilter(displayName, defaultExp));
79 | export const findByTypeName = (typeName: string, defaultExp = true) => find(tNameFilter(typeName, defaultExp));
80 | export const findByTypeNameAll = (typeName: string, defaultExp = true) => findAll(tNameFilter(typeName, defaultExp));
81 | export const findByStoreName = (name: string) => find(storeFilter(name));
82 |
--------------------------------------------------------------------------------
/src/lib/native.ts:
--------------------------------------------------------------------------------
1 | import { MMKVManager as _MMKVManager, FileManager as _FileManager } from "@types";
2 | const nmp = window.nativeModuleProxy;
3 |
4 | export const MMKVManager = nmp.MMKVManager as _MMKVManager;
5 | //! 173.10 renamed this to RTNFileManager.
6 | export const FileManager = (nmp.DCDFileManager ?? nmp.RTNFileManager) as _FileManager;
7 | //! 173.13 renamed this to RTNClientInfoManager.
8 | export const ClientInfoManager = nmp.InfoDictionaryManager ?? nmp.RTNClientInfoManager;
9 | //! 173.14 renamed this to RTNDeviceManager.
10 | export const DeviceManager = nmp.DCDDeviceManager ?? nmp.RTNDeviceManager;
11 | export const BundleUpdaterManager = nmp.BundleUpdaterManager;
--------------------------------------------------------------------------------
/src/lib/patcher.ts:
--------------------------------------------------------------------------------
1 | import * as _spitroast from "spitroast";
2 |
3 | export * from "spitroast";
4 | export default { ..._spitroast };
--------------------------------------------------------------------------------
/src/lib/plugins.ts:
--------------------------------------------------------------------------------
1 | import { PluginManifest, Plugin } from "@types";
2 | import { safeFetch } from "@lib/utils";
3 | import { awaitSyncWrapper, createMMKVBackend, createStorage, purgeStorage, wrapSync } from "@lib/storage";
4 | import { allSettled } from "@lib/polyfills";
5 | import logger, { logModule } from "@lib/logger";
6 | import settings from "@lib/settings";
7 |
8 | type EvaledPlugin = {
9 | onLoad?(): void;
10 | onUnload(): void;
11 | settings: JSX.Element;
12 | };
13 |
14 | export const plugins = wrapSync(createStorage>(createMMKVBackend("VENDETTA_PLUGINS")));
15 | const loadedPlugins: Record = {};
16 |
17 | export async function fetchPlugin(id: string) {
18 | if (!id.endsWith("/")) id += "/";
19 | const existingPlugin = plugins[id];
20 |
21 | let pluginManifest: PluginManifest;
22 |
23 | try {
24 | pluginManifest = await (await safeFetch(id + "manifest.json", { cache: "no-store" })).json();
25 | } catch {
26 | throw new Error(`Failed to fetch manifest for ${id}`);
27 | }
28 |
29 | let pluginJs: string | undefined;
30 |
31 | if (existingPlugin?.manifest.hash !== pluginManifest.hash) {
32 | try {
33 | // by polymanifest spec, plugins should always specify their main file, but just in case
34 | pluginJs = await (await safeFetch(id + (pluginManifest.main || "index.js"), { cache: "no-store" })).text();
35 | } catch {} // Empty catch, checked below
36 | }
37 |
38 | if (!pluginJs && !existingPlugin) throw new Error(`Failed to fetch JS for ${id}`);
39 |
40 | plugins[id] = {
41 | id: id,
42 | manifest: pluginManifest,
43 | enabled: existingPlugin?.enabled ?? false,
44 | update: existingPlugin?.update ?? true,
45 | js: pluginJs ?? existingPlugin.js,
46 | };
47 | }
48 |
49 | export async function installPlugin(id: string, enabled = true) {
50 | if (!id.endsWith("/")) id += "/";
51 | if (typeof id !== "string" || id in plugins) throw new Error("Plugin already installed");
52 | await fetchPlugin(id);
53 | if (enabled) await startPlugin(id);
54 | }
55 |
56 | export async function evalPlugin(plugin: Plugin) {
57 | const vendettaForPlugins = {
58 | ...window.vendetta,
59 | plugin: {
60 | id: plugin.id,
61 | manifest: plugin.manifest,
62 | // Wrapping this with wrapSync is NOT an option.
63 | storage: await createStorage>(createMMKVBackend(plugin.id)),
64 | },
65 | logger: new logModule(`Vendetta » ${plugin.manifest.name}`),
66 | };
67 | const pluginString = `vendetta=>{return ${plugin.js}}\n//# sourceURL=${plugin.id}`;
68 |
69 | const raw = (0, eval)(pluginString)(vendettaForPlugins);
70 | const ret = typeof raw == "function" ? raw() : raw;
71 | return ret?.default ?? ret ?? {};
72 | }
73 |
74 | export async function startPlugin(id: string) {
75 | if (!id.endsWith("/")) id += "/";
76 | const plugin = plugins[id];
77 | if (!plugin) throw new Error("Attempted to start non-existent plugin");
78 |
79 | try {
80 | if (!settings.safeMode?.enabled) {
81 | const pluginRet: EvaledPlugin = await evalPlugin(plugin);
82 | loadedPlugins[id] = pluginRet;
83 | pluginRet.onLoad?.();
84 | }
85 | plugin.enabled = true;
86 | } catch (e) {
87 | logger.error(`Plugin ${plugin.id} errored whilst loading, and will be unloaded`, e);
88 |
89 | try {
90 | loadedPlugins[plugin.id]?.onUnload?.();
91 | } catch (e2) {
92 | logger.error(`Plugin ${plugin.id} errored whilst unloading`, e2);
93 | }
94 |
95 | delete loadedPlugins[id];
96 | plugin.enabled = false;
97 | }
98 | }
99 |
100 | export function stopPlugin(id: string, disable = true) {
101 | if (!id.endsWith("/")) id += "/";
102 | const plugin = plugins[id];
103 | const pluginRet = loadedPlugins[id];
104 | if (!plugin) throw new Error("Attempted to stop non-existent plugin");
105 |
106 | if (!settings.safeMode?.enabled) {
107 | try {
108 | pluginRet?.onUnload?.();
109 | } catch (e) {
110 | logger.error(`Plugin ${plugin.id} errored whilst unloading`, e);
111 | }
112 |
113 | delete loadedPlugins[id];
114 | }
115 |
116 | disable && (plugin.enabled = false);
117 | }
118 |
119 | export async function removePlugin(id: string) {
120 | if (!id.endsWith("/")) id += "/";
121 | const plugin = plugins[id];
122 | if (plugin.enabled) stopPlugin(id);
123 | delete plugins[id];
124 | await purgeStorage(id);
125 | }
126 |
127 | export async function initPlugins() {
128 | await awaitSyncWrapper(settings);
129 | await awaitSyncWrapper(plugins);
130 | const allIds = Object.keys(plugins);
131 |
132 | if (!settings.safeMode?.enabled) {
133 | // Loop over any plugin that is enabled, update it if allowed, then start it.
134 | await allSettled(allIds.filter(pl => plugins[pl].enabled).map(async (pl) => (plugins[pl].update && await fetchPlugin(pl).catch((e: Error) => logger.error(e.message)), await startPlugin(pl))));
135 | // Wait for the above to finish, then update all disabled plugins that are allowed to.
136 | allIds.filter(pl => !plugins[pl].enabled && plugins[pl].update).forEach(pl => fetchPlugin(pl));
137 | };
138 |
139 | return stopAllPlugins;
140 | }
141 |
142 | const stopAllPlugins = () => Object.keys(loadedPlugins).forEach(p => stopPlugin(p, false));
143 |
144 | export const getSettings = (id: string) => loadedPlugins[id]?.settings;
145 |
--------------------------------------------------------------------------------
/src/lib/polyfills.ts:
--------------------------------------------------------------------------------
1 | //! Starting from 202.4, Promise.allSettled may be undefined due to conflicting then/promise versions, so we use our own.
2 | const allSettledFulfill = (value: T) => ({ status: "fulfilled", value });
3 | const allSettledReject = (reason: T) => ({ status: "rejected", reason });
4 | const mapAllSettled = (item: T) => Promise.resolve(item).then(allSettledFulfill, allSettledReject);
5 | export const allSettled = (iterator: T) => Promise.all(Array.from(iterator).map(mapAllSettled));
6 |
--------------------------------------------------------------------------------
/src/lib/preinit.ts:
--------------------------------------------------------------------------------
1 | import { initThemes } from "@lib/themes";
2 | import { instead } from "@lib/patcher";
3 |
4 | // Hoist required modules
5 | // This used to be in filters.ts, but things became convoluted
6 |
7 | const basicFind = (filter: (m: any) => any | string) => {
8 | for (const key in window.modules) {
9 | const exp = window.modules[key]?.publicModule.exports;
10 | if (exp && filter(exp)) return exp;
11 | }
12 | }
13 |
14 | const requireNativeComponent = basicFind(m => m?.default?.name === "requireNativeComponent");
15 |
16 | if (requireNativeComponent) {
17 | // > "Tried to register two views with the same name DCDVisualEffectView"
18 | // This serves as a workaround for the crashing You tab on Android starting from version 192.x
19 | // How? We simply ignore it.
20 | instead("default", requireNativeComponent, (args, orig) => {
21 | try {
22 | return orig(...args);
23 | } catch {
24 | return args[0];
25 | }
26 | })
27 | }
28 |
29 | // Hoist React on window
30 | window.React = basicFind(m => m.createElement) as typeof import("react");
31 |
32 | // Export ReactNative
33 | export const ReactNative = basicFind(m => m.AppRegistry) as typeof import("react-native");
34 |
35 | // Export chroma.js
36 | export const chroma = basicFind(m => m.brewer) as typeof import("chroma-js");
37 |
38 | // Themes
39 | if (window.__vendetta_loader?.features.themes) {
40 | try {
41 | initThemes();
42 | } catch (e) {
43 | console.error("[Bound] Failed to initialize themes...", e);
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/lib/settings.ts:
--------------------------------------------------------------------------------
1 | import { LoaderConfig, Settings } from "@types";
2 | import { createFileBackend, createMMKVBackend, createStorage, wrapSync } from "@lib/storage";
3 |
4 | export default wrapSync(createStorage(createMMKVBackend("VENDETTA_SETTINGS")));
5 | export const loaderConfig = wrapSync(createStorage(createFileBackend("vendetta_loader.json")));
6 |
--------------------------------------------------------------------------------
/src/lib/storage/backends.ts:
--------------------------------------------------------------------------------
1 | import { StorageBackend } from "@types";
2 | import { MMKVManager, FileManager } from "@lib/native";
3 | import { ReactNative as RN } from "@metro/common";
4 |
5 | const ILLEGAL_CHARS_REGEX = /[<>:"\/\\|?*]/g;
6 |
7 | const filePathFixer = (file: string): string => RN.Platform.select({
8 | default: file,
9 | ios: FileManager.saveFileToGallery ? file : `Documents/${file}`,
10 | });
11 |
12 | const getMMKVPath = (name: string): string => {
13 | if (ILLEGAL_CHARS_REGEX.test(name)) {
14 | // Replace forbidden characters with hyphens
15 | name = name.replace(ILLEGAL_CHARS_REGEX, '-').replace(/-+/g, '-');
16 | }
17 |
18 | return `vd_mmkv/${name}`;
19 | }
20 |
21 | export const purgeStorage = async (store: string) => {
22 | if (await MMKVManager.getItem(store)) {
23 | MMKVManager.removeItem(store);
24 | }
25 |
26 | const mmkvPath = getMMKVPath(store);
27 | if (await FileManager.fileExists(`${FileManager.getConstants().DocumentsDirPath}/${mmkvPath}`)) {
28 | await FileManager.removeFile?.("documents", mmkvPath);
29 | }
30 | }
31 |
32 | export const createMMKVBackend = (store: string) => {
33 | const mmkvPath = getMMKVPath(store);
34 | return createFileBackend(mmkvPath, (async () => {
35 | try {
36 | const path = `${FileManager.getConstants().DocumentsDirPath}/${mmkvPath}`;
37 | if (await FileManager.fileExists(path)) return;
38 |
39 | let oldData = await MMKVManager.getItem(store) ?? "{}";
40 |
41 | // From the testing on Android, it seems to return this if the data is too large
42 | if (oldData === "!!LARGE_VALUE!!") {
43 | const cachePath = `${FileManager.getConstants().CacheDirPath}/mmkv/${store}`;
44 | if (await FileManager.fileExists(cachePath)) {
45 | oldData = await FileManager.readFile(cachePath, "utf8")
46 | } else {
47 | console.log(`${store}: Experienced data loss :(`);
48 | oldData = "{}";
49 | }
50 | }
51 |
52 | await FileManager.writeFile("documents", filePathFixer(mmkvPath), oldData, "utf8");
53 | if (await MMKVManager.getItem(store) !== null) {
54 | MMKVManager.removeItem(store);
55 | console.log(`Successfully migrated ${store} store from MMKV storage to fs`);
56 | }
57 | } catch (err) {
58 | console.error("Failed to migrate to fs from MMKVManager ", err)
59 | }
60 | })());
61 | }
62 |
63 | export const createFileBackend = (file: string, migratePromise?: Promise): StorageBackend => {
64 | let created: boolean;
65 | return {
66 | get: async () => {
67 | await migratePromise;
68 | const path = `${FileManager.getConstants().DocumentsDirPath}/${file}`;
69 | if (!created && !(await FileManager.fileExists(path))) return (created = true), FileManager.writeFile("documents", filePathFixer(file), "{}", "utf8");
70 | return JSON.parse(await FileManager.readFile(path, "utf8"));
71 | },
72 | set: async (data) => {
73 | await migratePromise;
74 | await FileManager.writeFile("documents", filePathFixer(file), JSON.stringify(data), "utf8");
75 | }
76 | };
77 | };
78 |
--------------------------------------------------------------------------------
/src/lib/storage/index.ts:
--------------------------------------------------------------------------------
1 | import { Emitter, StorageBackend, StorageObject } from "@types";
2 | import createEmitter from "@lib/emitter";
3 |
4 | const emitterSymbol = Symbol.for("vendetta.storage.emitter");
5 | const syncAwaitSymbol = Symbol.for("vendetta.storage.accessor");
6 | const storageErrorSymbol = Symbol.for("vendetta.storage.error");
7 |
8 | export function createProxy(target: any = {}): { proxy: any; emitter: Emitter } {
9 | const emitter = createEmitter();
10 |
11 | function createProxy(target: any, path: string[]): any {
12 | return new Proxy(target, {
13 | get(target, prop: string) {
14 | if ((prop as unknown) === emitterSymbol) return emitter;
15 |
16 | const newPath = [...path, prop];
17 | const value: any = target[prop];
18 |
19 | if (value !== undefined && value !== null) {
20 | emitter.emit("GET", {
21 | path: newPath,
22 | value,
23 | });
24 | if (typeof value === "object") {
25 | return createProxy(value, newPath);
26 | }
27 | return value;
28 | }
29 |
30 | return value;
31 | },
32 |
33 | set(target, prop: string, value) {
34 | target[prop] = value;
35 | emitter.emit("SET", {
36 | path: [...path, prop],
37 | value,
38 | });
39 | // we do not care about success, if this actually does fail we have other problems
40 | return true;
41 | },
42 |
43 | deleteProperty(target, prop: string) {
44 | const success = delete target[prop];
45 | if (success)
46 | emitter.emit("DEL", {
47 | path: [...path, prop],
48 | });
49 | return success;
50 | },
51 | });
52 | }
53 |
54 | return {
55 | proxy: createProxy(target, []),
56 | emitter,
57 | };
58 | }
59 |
60 | export function useProxy(storage: StorageObject): T {
61 | if (storage[storageErrorSymbol]) throw storage[storageErrorSymbol];
62 |
63 | const emitter = storage[emitterSymbol] as any as Emitter;
64 |
65 | if (!emitter) throw new Error("InvalidArgumentExcpetion - storage[emitterSymbol] is " + typeof emitter);
66 |
67 | const [, forceUpdate] = React.useReducer((n) => ~n, 0);
68 |
69 | React.useEffect(() => {
70 | const listener = () => forceUpdate();
71 |
72 | emitter.on("SET", listener);
73 | emitter.on("DEL", listener);
74 |
75 | return () => {
76 | emitter.off("SET", listener);
77 | emitter.off("DEL", listener);
78 | };
79 | }, []);
80 |
81 | return storage as T;
82 | }
83 |
84 | export async function createStorage(backend: StorageBackend): Promise> {
85 | const data = await backend.get();
86 | const { proxy, emitter } = createProxy(data);
87 |
88 | const handler = () => backend.set(proxy);
89 | emitter.on("SET", handler);
90 | emitter.on("DEL", handler);
91 |
92 | return proxy;
93 | }
94 |
95 | export function wrapSync>(store: T): Awaited {
96 | let awaited: any = undefined;
97 | let error: any = undefined;
98 |
99 | const awaitQueue: (() => void)[] = [];
100 | const awaitInit = (cb: () => void) => (awaited ? cb() : awaitQueue.push(cb));
101 |
102 | store.then((v) => {
103 | awaited = v;
104 | awaitQueue.forEach((cb) => cb());
105 | }).catch((e) => {
106 | error = e;
107 | });
108 |
109 | return new Proxy({} as Awaited, {
110 | ...Object.fromEntries(
111 | Object.getOwnPropertyNames(Reflect)
112 | // @ts-expect-error
113 | .map((k) => [k, (t: T, ...a: any[]) => Reflect[k](awaited ?? t, ...a)])
114 | ),
115 | get(target, prop, recv) {
116 | if (prop === storageErrorSymbol) return error;
117 | if (prop === syncAwaitSymbol) return awaitInit;
118 | return Reflect.get(awaited ?? target, prop, recv);
119 | },
120 | });
121 | }
122 |
123 | export const awaitSyncWrapper = (store: any) => new Promise((res) => store[syncAwaitSymbol](res));
124 |
125 | export * from "@lib/storage/backends";
126 |
--------------------------------------------------------------------------------
/src/lib/themes.ts:
--------------------------------------------------------------------------------
1 | import { Theme, ThemeData } from "@types";
2 | import { ReactNative as RN, chroma } from "@metro/common";
3 | import { findInReactTree, safeFetch } from "@lib/utils";
4 | import { findByName, findByProps } from "@metro/filters";
5 | import { instead, after } from "@lib/patcher";
6 | import { createFileBackend, createMMKVBackend, createStorage, wrapSync, awaitSyncWrapper } from "@lib/storage";
7 | import logger from "./logger";
8 |
9 | //! As of 173.10, early-finding this does not work.
10 | // Somehow, this is late enough, though?
11 | export const color = findByProps("SemanticColor");
12 |
13 | export const themes = wrapSync(createStorage>(createMMKVBackend("VENDETTA_THEMES")));
14 |
15 | const semanticAlternativeMap: Record = {
16 | "BG_BACKDROP": "BACKGROUND_FLOATING",
17 | "BG_BASE_PRIMARY": "BACKGROUND_PRIMARY",
18 | "BG_BASE_SECONDARY": "BACKGROUND_SECONDARY",
19 | "BG_BASE_TERTIARY": "BACKGROUND_SECONDARY_ALT",
20 | "BG_MOD_FAINT": "BACKGROUND_MODIFIER_ACCENT",
21 | "BG_MOD_STRONG": "BACKGROUND_MODIFIER_ACCENT",
22 | "BG_MOD_SUBTLE": "BACKGROUND_MODIFIER_ACCENT",
23 | "BG_SURFACE_OVERLAY": "BACKGROUND_FLOATING",
24 | "BG_SURFACE_OVERLAY_TMP": "BACKGROUND_FLOATING",
25 | "BG_SURFACE_RAISED": "BACKGROUND_MOBILE_PRIMARY"
26 | }
27 |
28 | async function writeTheme(theme: Theme | {}) {
29 | if (typeof theme !== "object") throw new Error("Theme must be an object");
30 |
31 | // Save the current theme as vendetta_theme.json. When supported by loader,
32 | // this json will be written to window.__vendetta_theme and be used to theme the native side.
33 | await createFileBackend("vendetta_theme.json").set(theme);
34 | }
35 |
36 | export function patchChatBackground() {
37 | const currentBackground = getCurrentTheme()?.data?.background;
38 | if (!currentBackground) return;
39 |
40 | const MessagesWrapperConnected = findByName("MessagesWrapperConnected", false);
41 | if (!MessagesWrapperConnected) return;
42 | const { MessagesWrapper } = findByProps("MessagesWrapper");
43 | if (!MessagesWrapper) return;
44 |
45 | const patches = [
46 | after("default", MessagesWrapperConnected, (_, ret) => React.createElement(RN.ImageBackground, {
47 | style: { flex: 1, height: "100%" },
48 | source: { uri: currentBackground.url },
49 | blurRadius: typeof currentBackground.blur === "number" ? currentBackground.blur : 0,
50 | children: ret,
51 | })),
52 | after("render", MessagesWrapper.prototype, (_, ret) => {
53 | const Messages = findInReactTree(ret, (x) => "HACK_fixModalInteraction" in x?.props && x?.props?.style);
54 | if (Messages)
55 | Messages.props.style = Object.assign(
56 | RN.StyleSheet.flatten(Messages.props.style ?? {}),
57 | {
58 | backgroundColor: "#0000"
59 | }
60 | );
61 | else
62 | logger.error("Didn't find Messages when patching MessagesWrapper!");
63 | })
64 | ];
65 |
66 | return () => patches.forEach(x => x());
67 | }
68 |
69 | function normalizeToHex(colorString: string): string {
70 | if (chroma.valid(colorString)) return chroma(colorString).hex();
71 |
72 | const color = Number(RN.processColor(colorString));
73 |
74 | return chroma.rgb(
75 | color >> 16 & 0xff, // red
76 | color >> 8 & 0xff, // green
77 | color & 0xff, // blue
78 | color >> 24 & 0xff // alpha
79 | ).hex();
80 | }
81 |
82 | // Process data for some compatiblity with native side
83 | function processData(data: ThemeData) {
84 | if (data.semanticColors) {
85 | const semanticColors = data.semanticColors;
86 |
87 | for (const key in semanticColors) {
88 | for (const index in semanticColors[key]) {
89 | semanticColors[key][index] &&= normalizeToHex(semanticColors[key][index] as string);
90 | }
91 | }
92 | }
93 |
94 | if (data.rawColors) {
95 | const rawColors = data.rawColors;
96 |
97 | for (const key in rawColors) {
98 | data.rawColors[key] = normalizeToHex(rawColors[key]);
99 | }
100 |
101 | if (RN.Platform.OS === "android") applyAndroidAlphaKeys(rawColors);
102 | }
103 |
104 | return data;
105 | }
106 |
107 | function applyAndroidAlphaKeys(rawColors: Record) {
108 | // these are native Discord Android keys
109 | const alphaMap: Record = {
110 | "BLACK_ALPHA_60": ["BLACK", 0.6],
111 | "BRAND_NEW_360_ALPHA_20": ["BRAND_360", 0.2],
112 | "BRAND_NEW_360_ALPHA_25": ["BRAND_360", 0.25],
113 | "BRAND_NEW_500_ALPHA_20": ["BRAND_500", 0.2],
114 | "PRIMARY_DARK_500_ALPHA_20": ["PRIMARY_500", 0.2],
115 | "PRIMARY_DARK_700_ALPHA_60": ["PRIMARY_700", 0.6],
116 | "STATUS_GREEN_500_ALPHA_20": ["GREEN_500", 0.2],
117 | "STATUS_RED_500_ALPHA_20": ["RED_500", 0.2],
118 | };
119 |
120 | for (const key in alphaMap) {
121 | const [colorKey, alpha] = alphaMap[key];
122 | if (!rawColors[colorKey]) continue;
123 | rawColors[key] = chroma(rawColors[colorKey]).alpha(alpha).hex();
124 | }
125 | }
126 |
127 | export async function fetchTheme(id: string, selected = false) {
128 | let themeJSON: any;
129 |
130 | try {
131 | themeJSON = await (await safeFetch(id, { cache: "no-store" })).json();
132 | } catch {
133 | throw new Error(`Failed to fetch theme at ${id}`);
134 | }
135 |
136 | themes[id] = {
137 | id: id,
138 | selected: selected,
139 | data: processData(themeJSON),
140 | };
141 |
142 | // TODO: Should we prompt when the selected theme is updated?
143 | if (selected) writeTheme(themes[id]);
144 | }
145 |
146 | export async function installTheme(id: string) {
147 | if (typeof id !== "string" || id in themes) throw new Error("Theme already installed");
148 | await fetchTheme(id);
149 | }
150 |
151 | export async function selectTheme(id: string) {
152 | if (id === "default") return await writeTheme({});
153 | const selectedThemeId = Object.values(themes).find(i => i.selected)?.id;
154 |
155 | if (selectedThemeId) themes[selectedThemeId].selected = false;
156 | themes[id].selected = true;
157 | await writeTheme(themes[id]);
158 | }
159 |
160 | export async function removeTheme(id: string) {
161 | const theme = themes[id];
162 | if (theme.selected) await selectTheme("default");
163 | delete themes[id];
164 |
165 | return theme.selected;
166 | }
167 |
168 | export function getCurrentTheme(): Theme | null {
169 | const themeProp = window.__vendetta_loader?.features?.themes?.prop;
170 | if (!themeProp) return null;
171 | return window[themeProp] || null;
172 | }
173 |
174 | export async function updateThemes() {
175 | await awaitSyncWrapper(themes);
176 | const currentTheme = getCurrentTheme();
177 | await Promise.allSettled(Object.keys(themes).map(id => fetchTheme(id, currentTheme?.id === id)));
178 | }
179 |
180 | export async function initThemes() {
181 | //! Native code is required here!
182 | // Awaiting the sync wrapper is too slow, to the point where semanticColors are not correctly overwritten.
183 | // We need a workaround, and it will unfortunately have to be done on the native side.
184 | // await awaitSyncWrapper(themes);
185 |
186 | const selectedTheme = getCurrentTheme();
187 | if (!selectedTheme) return;
188 |
189 | const oldRaw = color.default.unsafe_rawColors;
190 |
191 | color.default.unsafe_rawColors = new Proxy(oldRaw, {
192 | get: (_, colorProp: string) => {
193 | if (!selectedTheme) return Reflect.get(oldRaw, colorProp);
194 |
195 | return selectedTheme.data?.rawColors?.[colorProp] ?? Reflect.get(oldRaw, colorProp);
196 | }
197 | });
198 |
199 | instead("resolveSemanticColor", color.default.meta ?? color.default.internal, (args, orig) => {
200 | if (!selectedTheme) return orig(...args);
201 |
202 | const [theme, propIndex] = args;
203 | const [name, colorDef] = extractInfo(theme, propIndex);
204 |
205 | const themeIndex = theme === "amoled" ? 2 : theme === "light" ? 1 : 0;
206 |
207 | //! As of 192.7, Tabs v2 uses BG_ semantic colors instead of BACKGROUND_ ones
208 | const alternativeName = semanticAlternativeMap[name] ?? name;
209 |
210 | const semanticColorVal = (selectedTheme.data?.semanticColors?.[name] ?? selectedTheme.data?.semanticColors?.[alternativeName])?.[themeIndex];
211 | if (name === "CHAT_BACKGROUND" && typeof selectedTheme.data?.background?.alpha === "number") {
212 | return chroma(semanticColorVal || "black").alpha(1 - selectedTheme.data.background.alpha).hex();
213 | }
214 |
215 | if (semanticColorVal) return semanticColorVal;
216 |
217 | const rawValue = selectedTheme.data?.rawColors?.[colorDef.raw];
218 | if (rawValue) {
219 | // Set opacity if needed
220 | return colorDef.opacity === 1 ? rawValue : chroma(rawValue).alpha(colorDef.opacity).hex();
221 | }
222 |
223 | // Fallback to default
224 | return orig(...args);
225 | });
226 |
227 | await updateThemes();
228 | }
229 |
230 | function extractInfo(themeMode: string, colorObj: any): [name: string, colorDef: any] {
231 | // @ts-ignore - assigning to extractInfo._sym
232 | const propName = colorObj[extractInfo._sym ??= Object.getOwnPropertySymbols(colorObj)[0]];
233 | const colorDef = color.SemanticColor[propName];
234 |
235 | return [propName, colorDef[themeMode.toLowerCase()]];
236 | }
--------------------------------------------------------------------------------
/src/lib/utils/findInReactTree.ts:
--------------------------------------------------------------------------------
1 | import { SearchFilter } from "@types";
2 | import { findInTree } from "@lib/utils";
3 |
4 | export default (tree: { [key: string]: any }, filter: SearchFilter): any => findInTree(tree, filter, {
5 | walkable: ["props", "children", "child", "sibling"],
6 | });
--------------------------------------------------------------------------------
/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 | import { FindInTreeOptions, SearchTree, SearchFilter } from "@types";
5 |
6 | function treeSearch(tree: SearchTree, filter: SearchFilter, opts: Required, depth: number): any {
7 | if (depth > opts.maxDepth) return;
8 | if (!tree) return;
9 |
10 | try {
11 | if (filter(tree)) return tree;
12 | } catch {}
13 |
14 | if (Array.isArray(tree)) {
15 | for (const item of tree) {
16 | if (typeof item !== "object" || item === null) continue;
17 |
18 | try {
19 | const found = treeSearch(item, filter, opts, depth + 1);
20 | if (found) return found;
21 | } catch {}
22 | }
23 | } else if (typeof tree === "object") {
24 | for (const key of Object.keys(tree)) {
25 | if (typeof tree[key] !== "object" || tree[key] === null) continue;
26 | if (opts.walkable.length && !opts.walkable.includes(key)) continue;
27 | if (opts.ignore.includes(key)) continue;
28 |
29 | try {
30 | const found = treeSearch(tree[key], filter, opts, depth + 1);
31 | if (found) return found;
32 | } catch {}
33 | }
34 | }
35 | }
36 |
37 | export default (
38 | tree: SearchTree,
39 | filter: SearchFilter,
40 | {
41 | walkable = [],
42 | ignore = [],
43 | maxDepth = 100
44 | }: FindInTreeOptions = {},
45 | ): any | undefined => treeSearch(tree, filter, { walkable, ignore, maxDepth }, 0);
46 |
--------------------------------------------------------------------------------
/src/lib/utils/index.ts:
--------------------------------------------------------------------------------
1 | // Makes mass-importing utils cleaner, chosen over moving utils to one file
2 |
3 | export { default as findInReactTree } from "@lib/utils/findInReactTree";
4 | export { default as findInTree } from "@lib/utils/findInTree";
5 | export { default as safeFetch } from "@lib/utils/safeFetch";
6 | export { default as unfreeze } from "@lib/utils/unfreeze";
7 | export { default as without } from "@lib/utils/without";
--------------------------------------------------------------------------------
/src/lib/utils/safeFetch.ts:
--------------------------------------------------------------------------------
1 | // A really basic fetch wrapper which throws on non-ok response codes
2 |
3 | export default async function safeFetch(input: RequestInfo | URL, options?: RequestInit, timeout = 10000) {
4 | const req = await fetch(input, {
5 | signal: timeoutSignal(timeout),
6 | ...options
7 | });
8 |
9 | if (!req.ok) throw new Error("Request returned non-ok");
10 | return req;
11 | }
12 |
13 | function timeoutSignal(ms: number): AbortSignal {
14 | const controller = new AbortController();
15 | setTimeout(() => controller.abort(`Timed out after ${ms}ms`), ms);
16 | return controller.signal;
17 | }
18 |
--------------------------------------------------------------------------------
/src/lib/utils/unfreeze.ts:
--------------------------------------------------------------------------------
1 | // https://stackoverflow.com/a/68339174
2 |
3 | export default function unfreeze(obj: object) {
4 | if (Object.isFrozen(obj)) return Object.assign({}, obj);
5 | return obj;
6 | }
--------------------------------------------------------------------------------
/src/lib/utils/without.ts:
--------------------------------------------------------------------------------
1 | export default function without(object: O, ...keys: K): Omit {
2 | const cloned = { ...object };
3 | keys.forEach((k) => delete cloned[k]);
4 | return cloned;
5 | }
--------------------------------------------------------------------------------
/src/lib/windowObject.ts:
--------------------------------------------------------------------------------
1 | import { VendettaObject } from "@types";
2 | import patcher from "@lib/patcher";
3 | import logger from "@lib/logger";
4 | import settings, { loaderConfig } from "@lib/settings";
5 | import * as constants from "@lib/constants";
6 | import * as debug from "@lib/debug";
7 | import * as plugins from "@lib/plugins";
8 | import * as themes from "@lib/themes";
9 | import * as commands from "@lib/commands";
10 | import * as storage from "@lib/storage";
11 | import * as metro from "@metro/filters";
12 | import * as common from "@metro/common";
13 | import * as components from "@ui/components";
14 | import * as toasts from "@ui/toasts";
15 | import * as alerts from "@ui/alerts";
16 | import * as assets from "@ui/assets";
17 | import * as color from "@ui/color";
18 | import * as utils from "@lib/utils";
19 |
20 | export default async (unloads: any[]): Promise => ({
21 | patcher: utils.without(patcher, "unpatchAll"),
22 | metro: { ...metro, common: { ...common } },
23 | constants,
24 | utils,
25 | debug: utils.without(debug, "versionHash", "patchLogHook", "setSafeMode"),
26 | ui: {
27 | components,
28 | toasts,
29 | alerts,
30 | assets,
31 | ...color,
32 | },
33 | plugins: utils.without(plugins, "initPlugins", "evalPlugin"),
34 | themes: utils.without(themes, "initThemes"),
35 | commands: utils.without(commands, "patchCommands"),
36 | storage,
37 | settings,
38 | loader: {
39 | identity: window.__vendetta_loader,
40 | config: loaderConfig,
41 | },
42 | logger,
43 | version: debug.versionHash,
44 | unload: () => {
45 | unloads.filter(i => typeof i === "function").forEach(p => p());
46 | // @ts-expect-error explode
47 | delete window.vendetta;
48 | },
49 | });
50 |
--------------------------------------------------------------------------------
/src/ui/alerts.ts:
--------------------------------------------------------------------------------
1 | import { ConfirmationAlertOptions, InputAlertProps } from "@types";
2 | import { findByProps } from "@metro/filters";
3 | import InputAlert from "@ui/components/InputAlert";
4 |
5 | const Alerts = findByProps("openLazy", "close");
6 |
7 | interface InternalConfirmationAlertOptions extends Omit {
8 | content?: ConfirmationAlertOptions["content"];
9 | body?: ConfirmationAlertOptions["content"];
10 | };
11 |
12 | export function showConfirmationAlert(options: ConfirmationAlertOptions) {
13 | const internalOptions = options as InternalConfirmationAlertOptions;
14 |
15 | internalOptions.body = options.content;
16 | delete internalOptions.content;
17 |
18 | internalOptions.isDismissable ??= true;
19 |
20 | return Alerts.show(internalOptions);
21 | };
22 |
23 | export const showCustomAlert = (component: React.ComponentType, props: any) => Alerts.openLazy({
24 | importer: async () => () => React.createElement(component, props),
25 | });
26 |
27 | export const showInputAlert = (options: InputAlertProps) => showCustomAlert(InputAlert, options);
28 |
--------------------------------------------------------------------------------
/src/ui/assets.ts:
--------------------------------------------------------------------------------
1 | import { Asset } from "@types";
2 | import { assets } from "@metro/common";
3 | import { after } from "@lib/patcher";
4 |
5 | export const all: Record = {};
6 |
7 | export function patchAssets() {
8 | const unpatch = after("registerAsset", assets, (args: Asset[], id: number) => {
9 | const asset = args[0];
10 | all[asset.name] = { ...asset, id: id };
11 | });
12 |
13 | for (let id = 1; ; id++) {
14 | const asset = assets.getAssetByID(id);
15 | if (!asset) break;
16 | if (all[asset.name]) continue;
17 | all[asset.name] = { ...asset, id: id };
18 | };
19 |
20 | return unpatch;
21 | }
22 |
23 | export const find = (filter: (a: any) => void): Asset | null | undefined => Object.values(all).find(filter);
24 | export const getAssetByName = (name: string): Asset => all[name];
25 | export const getAssetByID = (id: number): Asset => assets.getAssetByID(id);
26 | export const getAssetIDByName = (name: string) => all[name]?.id;
--------------------------------------------------------------------------------
/src/ui/color.ts:
--------------------------------------------------------------------------------
1 | import { constants } from "@metro/common";
2 | import { color } from "@lib/themes";
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 = findByProps("colors", "meta");
11 |
12 | //? SemanticColor and default.colors are effectively ThemeColorMap
13 | export const semanticColors = (color?.default?.colors ?? constants?.ThemeColorMap);
14 |
15 | //? RawColor and default.unsafe_rawColors are effectively Colors
16 | //* Note that constants.Colors does still appear to exist on newer versions despite Discord not internally using it - what the fuck?
17 | export const rawColors = (color?.default?.unsafe_rawColors ?? constants?.Colors);
--------------------------------------------------------------------------------
/src/ui/components/Codeblock.tsx:
--------------------------------------------------------------------------------
1 | import { CodeblockProps } from "@types";
2 | import { ReactNative as RN, stylesheet, constants } from "@metro/common";
3 | import { semanticColors } from "@ui/color";
4 | import { cardStyle } from "@ui/shared";
5 |
6 | const styles = stylesheet.createThemedStyleSheet({
7 | codeBlock: {
8 | ...cardStyle,
9 | color: semanticColors.TEXT_NORMAL,
10 | fontFamily: constants.Fonts.CODE_SEMIBOLD,
11 | fontSize: 12,
12 | textAlignVertical: "center",
13 | paddingHorizontal: 12,
14 | },
15 | });
16 |
17 | // iOS doesn't support the selectable property on RN.Text...
18 | const InputBasedCodeblock = ({ style, children }: CodeblockProps) =>
19 | const TextBasedCodeblock = ({ selectable, style, children }: CodeblockProps) => {children}
20 |
21 | export default function Codeblock({ selectable, style, children }: CodeblockProps) {
22 | if (!selectable) return ;
23 |
24 | return RN.Platform.select({
25 | ios: ,
26 | default: ,
27 | });
28 | }
29 |
--------------------------------------------------------------------------------
/src/ui/components/ErrorBoundary.tsx:
--------------------------------------------------------------------------------
1 | import { ErrorBoundaryState } from "@types";
2 | import { React, constants, TextStyleSheet } from "@metro/common";
3 | import { Tabs, Forms } from "@ui/components";
4 |
5 | export default class ErrorBoundary extends React.PureComponent {
6 | state: ErrorBoundaryState = { hasErr: false };
7 |
8 | static getDerivedStateFromError = (error: Error) => ({ hasErr: true, errText: error.message });
9 |
10 | render() {
11 | if (!this.state.hasErr) return this.props.children;
12 |
13 | return (
14 |
15 |
16 | Uh oh.
17 | {this.state.errText}
18 | this.setState({ hasErr: false, errText: undefined })} />
19 |
20 |
21 | )
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/ui/components/InputAlert.tsx:
--------------------------------------------------------------------------------
1 | import { InputAlertProps } from "@types";
2 | import { findByProps } from "@metro/filters";
3 | import { Forms, Alert } from "@ui/components";
4 |
5 | const { FormInput } = Forms;
6 | const Alerts = findByProps("openLazy", "close");
7 |
8 | export default function InputAlert({ title, confirmText, confirmColor, onConfirm, cancelText, placeholder, initialValue = "", secureTextEntry }: InputAlertProps) {
9 | const [value, setValue] = React.useState(initialValue);
10 | const [error, setError] = React.useState("");
11 |
12 | function onConfirmWrapper() {
13 | const asyncOnConfirm = Promise.resolve(onConfirm(value))
14 |
15 | asyncOnConfirm.then(() => {
16 | Alerts.close();
17 | }).catch((e: Error) => {
18 | setError(e.message);
19 | });
20 | };
21 |
22 | return (
23 | Alerts.close()}
31 | >
32 | {
36 | setValue(typeof v === "string" ? v : v.text);
37 | if (error) setError("");
38 | }}
39 | returnKeyType="done"
40 | onSubmitEditing={onConfirmWrapper}
41 | error={error || undefined}
42 | secureTextEntry={secureTextEntry}
43 | autoFocus={true}
44 | showBorder={true}
45 | style={{ paddingVertical: 5, alignSelf: "stretch", paddingHorizontal: 0 }}
46 | />
47 |
48 | );
49 | };
--------------------------------------------------------------------------------
/src/ui/components/Search.tsx:
--------------------------------------------------------------------------------
1 | // https://github.com/pyoncord/Pyoncord/blob/08c6b5ee1580991704640385b715d772859f34b7/src/lib/ui/components/Search.tsx
2 |
3 | import { SearchProps } from "@types";
4 | import { ReactNative as RN } from "@metro/common";
5 | import { getAssetIDByName } from "@ui/assets";
6 | import { Tabs } from "@ui/components";
7 |
8 | const SearchIcon = () => ;
9 |
10 | export default ({ onChangeText, placeholder, style }: SearchProps) => {
11 | const [query, setQuery] = React.useState("");
12 |
13 | const onChange = (value: string) => {
14 | setQuery(value);
15 | onChangeText?.(value);
16 | };
17 |
18 | return
19 |
31 |
32 | };
33 |
--------------------------------------------------------------------------------
/src/ui/components/Summary.tsx:
--------------------------------------------------------------------------------
1 | import { SummaryProps } from "@types";
2 | import { ReactNative as RN } from "@metro/common";
3 | import { getAssetIDByName } from "@ui/assets";
4 | import { Forms } from "@ui/components";
5 |
6 | export default function Summary({ label, icon, noPadding = false, noAnimation = false, children }: SummaryProps) {
7 | const { FormRow, FormDivider } = Forms;
8 | const [hidden, setHidden] = React.useState(true);
9 |
10 | return (
11 | <>
12 | }
15 | trailing={}
16 | onPress={() => {
17 | setHidden(!hidden);
18 | if (!noAnimation) RN.LayoutAnimation.configureNext(RN.LayoutAnimation.Presets.easeInEaseOut);
19 | }}
20 | />
21 | {!hidden && <>
22 |
23 | {children}
24 | >}
25 | >
26 | )
27 | }
--------------------------------------------------------------------------------
/src/ui/components/TabulatedScreen.tsx:
--------------------------------------------------------------------------------
1 | // https://github.com/maisymoe/strife/blob/54f4768ef41d66e682a0917b078129df5c34f0f8/plugins/Mockups/src/shared/TabulatedScreen.tsx
2 |
3 | import { TabulatedScreenProps, TabulatedScreenTab } from "@types";
4 | import { React, ReactNative as RN } from "@metro/common";
5 | import { findByProps } from "@metro/filters";
6 |
7 | const { BadgableTabBar } = findByProps("BadgableTabBar");
8 |
9 | export default ({ tabs }: TabulatedScreenProps) => {
10 | const [activeTab, setActiveTab] = React.useState(tabs[0]);
11 |
12 | return (
13 |
14 | {activeTab.render && }
15 |
16 | {
20 | const tab = tabs.find(t => t.id === id);
21 | if (!tab) return;
22 |
23 | tab.onPress?.(tab.id);
24 | tab.render && setActiveTab(tab);
25 | }}
26 | />
27 |
28 |
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/src/ui/components/index.ts:
--------------------------------------------------------------------------------
1 | import { ReactNative as RN } from "@metro/common";
2 | import { findByDisplayName, findByName, findByProps, find } from "@metro/filters";
3 |
4 | // https://github.com/pyoncord/Pyoncord/blob/08c6b5ee1580991704640385b715d772859f34b7/src/lib/ui/components/discord/Redesign.ts#L4C1-L4C98
5 | const findSingular = (prop: string) => find(m => m[prop] && Object.keys(m).length === 1)?.[prop];
6 |
7 | // Discord
8 | export const Forms = findByProps("Form", "FormSection");
9 | export const Tabs = {
10 | ...findByProps("TableRow", "TableRowGroup"),
11 | RedesignSwitch: findSingular("FormSwitch"),
12 | RedesignCheckbox: findSingular("FormCheckbox"),
13 | } as Record;
14 | export const General = findByProps("Button", "Text", "View");
15 | export const Alert = findByDisplayName("FluxContainer(Alert)");
16 | export const Button = findByProps("Looks", "Colors", "Sizes") as React.ComponentType & { Looks: any, Colors: any, Sizes: any };
17 | export const HelpMessage = findByName("HelpMessage");
18 | // React Native's included SafeAreaView only adds padding on iOS.
19 | export const SafeAreaView = findByProps("useSafeAreaInsets").SafeAreaView as typeof RN.SafeAreaView;
20 |
21 | // Vendetta
22 | export { default as Summary } from "@ui/components/Summary";
23 | export { default as ErrorBoundary } from "@ui/components/ErrorBoundary";
24 | export { default as Codeblock } from "@ui/components/Codeblock";
25 | export { default as Search } from "@ui/components/Search";
26 | export { default as TabulatedScreen } from "@ui/components/TabulatedScreen";
27 |
--------------------------------------------------------------------------------
/src/ui/quickInstall/forumPost.tsx:
--------------------------------------------------------------------------------
1 | import { findByName, findByProps } from "@metro/filters";
2 | import { DISCORD_SERVER_ID, PLUGINS_CHANNEL_ID, THEMES_CHANNEL_ID, HTTP_REGEX_MULTI, PROXY_PREFIX } from "@lib/constants";
3 | import { after } from "@lib/patcher";
4 | import { installPlugin } from "@lib/plugins";
5 | import { installTheme } from "@lib/themes";
6 | import { findInReactTree } from "@lib/utils";
7 | import { getAssetIDByName } from "@ui/assets";
8 | import { showToast } from "@ui/toasts";
9 | import { Forms } from "@ui/components";
10 |
11 | const ForumPostLongPressActionSheet = findByName("ForumPostLongPressActionSheet", false);
12 | const { FormRow, FormIcon } = Forms;
13 |
14 | const { useFirstForumPostMessage } = findByProps("useFirstForumPostMessage");
15 | const { hideActionSheet } = findByProps("openLazy", "hideActionSheet");
16 |
17 | export default () => after("default", ForumPostLongPressActionSheet, ([{ thread }], res) => {
18 | if (thread.guild_id !== DISCORD_SERVER_ID) return;
19 |
20 | // Determine what type of addon this is.
21 | let postType: "Plugin" | "Theme";
22 | if (thread.parent_id === PLUGINS_CHANNEL_ID) {
23 | postType = "Plugin";
24 | } else if (thread.parent_id === THEMES_CHANNEL_ID && window.__vendetta_loader?.features.themes) {
25 | postType = "Theme";
26 | } else return;
27 |
28 | const { firstMessage } = useFirstForumPostMessage(thread);
29 |
30 | let urls = firstMessage?.content?.match(HTTP_REGEX_MULTI);
31 | if (!urls) return;
32 |
33 | if (postType === "Plugin") {
34 | urls = urls.filter((url: string) => url.startsWith(PROXY_PREFIX));
35 | } else {
36 | urls = urls.filter((url: string) => url.endsWith(".json"));
37 | };
38 |
39 | const url = urls[0];
40 | if (!url) return;
41 |
42 | const actions = findInReactTree(res, (t) => t?.[0]?.key);
43 | const ActionsSection = actions[0].type;
44 |
45 | actions.unshift(
46 | }
48 | label={`Install ${postType}`}
49 | onPress={() =>
50 | (postType === "Plugin" ? installPlugin : installTheme)(url).then(() => {
51 | showToast(`Successfully installed ${thread.name}`, getAssetIDByName("Check"));
52 | }).catch((e: Error) => {
53 | showToast(e.message, getAssetIDByName("Small"));
54 | }).finally(() => hideActionSheet())
55 | }
56 | />
57 | );
58 | });
59 |
--------------------------------------------------------------------------------
/src/ui/quickInstall/index.ts:
--------------------------------------------------------------------------------
1 | import patchForumPost from "@ui/quickInstall/forumPost";
2 | import patchUrl from "@ui/quickInstall/url";
3 |
4 | export default function initQuickInstall() {
5 | const patches = new Array;
6 |
7 | patches.push(patchForumPost());
8 | patches.push(patchUrl());
9 |
10 | return () => patches.forEach(p => p());
11 | };
12 |
--------------------------------------------------------------------------------
/src/ui/quickInstall/url.tsx:
--------------------------------------------------------------------------------
1 | import { findByProps, find } from "@metro/filters";
2 | import { ReactNative as RN, channels, url } from "@metro/common";
3 | import { PROXY_PREFIX, THEMES_CHANNEL_ID } from "@lib/constants";
4 | import { after, instead } from "@lib/patcher";
5 | import { installPlugin } from "@lib/plugins";
6 | import { installTheme } from "@lib/themes";
7 | import { showConfirmationAlert } from "@ui/alerts";
8 | import { getAssetIDByName } from "@ui/assets";
9 | import { showToast } from "@ui/toasts";
10 |
11 | const showSimpleActionSheet = find((m) => m?.showSimpleActionSheet && !Object.getOwnPropertyDescriptor(m, "showSimpleActionSheet")?.get);
12 | const handleClick = findByProps("handleClick");
13 | const { openURL } = url;
14 | const { getChannelId } = channels;
15 | const { getChannel } = findByProps("getChannel");
16 |
17 | const { TextStyleSheet } = findByProps("TextStyleSheet");
18 |
19 | function typeFromUrl(url: string) {
20 | if (url.startsWith(PROXY_PREFIX)) {
21 | return "Plugin";
22 | } else if (url.endsWith(".json") && window.__vendetta_loader?.features.themes) {
23 | return "Theme";
24 | } else return;
25 | }
26 |
27 | function installWithToast(type: "Plugin" | "Theme", url: string) {
28 | (type === "Plugin" ? installPlugin : installTheme)(url)
29 | .then(() => {
30 | showToast("Successfully installed", getAssetIDByName("Check"));
31 | })
32 | .catch((e: Error) => {
33 | showToast(e.message, getAssetIDByName("Small"));
34 | });
35 | }
36 |
37 | export default () => {
38 | const patches = new Array();
39 |
40 | patches.push(
41 | after("showSimpleActionSheet", showSimpleActionSheet, (args) => {
42 | if (args[0].key !== "LongPressUrl") return;
43 | const {
44 | header: { title: url },
45 | options,
46 | } = args[0];
47 |
48 | const urlType = typeFromUrl(url);
49 | if (!urlType) return;
50 |
51 | options.push({
52 | label: `Install ${urlType}`,
53 | onPress: () => installWithToast(urlType, url),
54 | });
55 | })
56 | );
57 |
58 | patches.push(
59 | instead("handleClick", handleClick, async function (this: any, args, orig) {
60 | const { href: url } = args[0];
61 |
62 | const urlType = typeFromUrl(url);
63 | if (!urlType) return orig.apply(this, args);
64 |
65 | // Make clicking on theme links only work in #themes, should there be a theme proxy in the future, this can be removed.
66 | if (urlType === "Theme" && getChannel(getChannelId())?.parent_id !== THEMES_CHANNEL_ID) return orig.apply(this, args);
67 |
68 | showConfirmationAlert({
69 | title: "Hold Up",
70 | content: ["This link is a ", {urlType}, ", would you like to install it?"],
71 | onConfirm: () => installWithToast(urlType, url),
72 | confirmText: "Install",
73 | cancelText: "Cancel",
74 | secondaryConfirmText: "Open in Browser",
75 | onConfirmSecondary: () => openURL(url),
76 | });
77 | })
78 | );
79 |
80 | return () => patches.forEach((p) => p());
81 | };
82 |
--------------------------------------------------------------------------------
/src/ui/safeMode.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNative as RN, TextStyleSheet, stylesheet } from "@metro/common";
2 | import { findByName, findByProps } from "@metro/filters";
3 | import { after } from "@lib/patcher";
4 | import { setSafeMode } from "@lib/debug";
5 | import { DeviceManager } from "@lib/native";
6 | import { semanticColors } from "@ui/color";
7 | import { cardStyle } from "@ui/shared";
8 | import { Tabs, Codeblock, ErrorBoundary as _ErrorBoundary, SafeAreaView } from "@ui/components";
9 | import settings from "@lib/settings";
10 |
11 | const ErrorBoundary = findByName("ErrorBoundary");
12 |
13 | // Let's just pray they have this.
14 | const { BadgableTabBar } = findByProps("BadgableTabBar");
15 |
16 | const styles = stylesheet.createThemedStyleSheet({
17 | container: {
18 | flex: 1,
19 | backgroundColor: semanticColors.BACKGROUND_PRIMARY,
20 | paddingHorizontal: 16,
21 | },
22 | header: {
23 | flex: 1,
24 | flexDirection: "row",
25 | justifyContent: "center",
26 | alignItems: "center",
27 | marginTop: 8,
28 | marginBottom: 16,
29 | ...cardStyle,
30 | },
31 | headerTitle: {
32 | ...TextStyleSheet["heading-lg/semibold"],
33 | color: semanticColors.HEADER_PRIMARY,
34 | marginBottom: 4,
35 | },
36 | headerDescription: {
37 | ...TextStyleSheet["text-sm/medium"],
38 | color: semanticColors.TEXT_MUTED,
39 | },
40 | body: {
41 | flex: 6,
42 | },
43 | footer: {
44 | flexDirection: DeviceManager.isTablet ? "row" : "column",
45 | justifyContent: "center",
46 | marginBottom: 16,
47 | },
48 | });
49 |
50 | interface Tab {
51 | id: string;
52 | title: string;
53 | trimWhitespace?: boolean;
54 | }
55 |
56 | interface Button {
57 | text: string;
58 | // TODO: Proper types for the below
59 | variant?: string;
60 | size?: string;
61 | onPress: () => void;
62 | }
63 |
64 | const tabs: Tab[] = [
65 | { id: "stack", title: "Stack Trace" },
66 | { id: "component", title: "Component", trimWhitespace: true },
67 | ];
68 |
69 | export default () => after("render", ErrorBoundary.prototype, function (this: any, _, ret) {
70 | if (!(settings.errorBoundaryEnabled ?? true)) return;
71 | if (!this.state.error) return;
72 |
73 | // Not using setState here as we don't want to cause a re-render, we want this to be set in the initial render
74 | this.state.activeTab ??= "stack";
75 | const tabData = tabs.find(t => t.id === this.state.activeTab);
76 | const errorText: string = this.state.error[this.state.activeTab];
77 |
78 | // This is in the patch and not outside of it so that we can use `this`, e.g. for setting state
79 | const buttons: Button[] = [
80 | { text: "Restart Discord", onPress: this.handleReload },
81 | ...!settings.safeMode?.enabled ? [{ text: "Restart in Recovery Mode", onPress: setSafeMode }] : [],
82 | { variant: "destructive", text: "Retry Render", onPress: () => this.setState({ info: null, error: null }) },
83 | ]
84 |
85 | return (
86 | <_ErrorBoundary>
87 |
88 |
89 |
90 | {ret.props.title}
91 | {ret.props.body}
92 |
93 | {ret.props.Illustration && }
94 |
95 |
96 |
100 | {/*
101 | TODO: I tried to get this working as intended using regex and failed.
102 | When trimWhitespace is true, each line should have it's whitespace removed but with it's spaces kept.
103 | */}
104 | {tabData?.trimWhitespace ? errorText?.split("\n").filter(i => i.length !== 0).map(i => i.trim()).join("\n") : errorText}
105 |
106 |
107 | {/* Are errors caught by ErrorBoundary guaranteed to have the component stack? */}
108 | { this.setState({ activeTab: tab }) }}
112 | />
113 |
114 |
115 |
116 | {buttons.map(button => {
117 | const buttonIndex = buttons.indexOf(button) !== 0 ? 8 : 0;
118 |
119 | return
126 | })}
127 |
128 |
129 |
130 | )
131 | });
132 |
--------------------------------------------------------------------------------
/src/ui/settings/components/AddonPage.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNative as RN } from "@metro/common";
2 | import { useProxy } from "@lib/storage";
3 | import { HelpMessage, ErrorBoundary, Search } from "@ui/components";
4 | import { CardWrapper } from "@ui/settings/components/Card";
5 | import settings from "@lib/settings";
6 |
7 | interface AddonPageProps {
8 | items: Record;
9 | safeModeMessage: string;
10 | safeModeExtras?: JSX.Element | JSX.Element[];
11 | card: React.ComponentType>;
12 | }
13 |
14 | export default function AddonPage({ items, safeModeMessage, safeModeExtras, card: CardComponent }: AddonPageProps) {
15 | useProxy(settings)
16 | useProxy(items);
17 | const [search, setSearch] = React.useState("");
18 |
19 | return (
20 |
21 | {/* TODO: Implement better searching than just by ID */}
22 |
24 | {settings.safeMode?.enabled &&
25 | {safeModeMessage}
26 | {safeModeExtras}
27 | }
28 | setSearch(v.toLowerCase())}
31 | placeholder="Search"
32 | />
33 | >}
34 | style={{ paddingHorizontal: 12, paddingTop: 12 }}
35 | contentContainerStyle={{ paddingBottom: 20 }}
36 | data={Object.values(items).filter(i => i.id?.toLowerCase().includes(search))}
37 | renderItem={({ item, index }) => }
38 | />
39 |
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/src/ui/settings/components/AssetDisplay.tsx:
--------------------------------------------------------------------------------
1 | import { Asset } from "@types";
2 | import { ReactNative as RN, clipboard } from "@metro/common";
3 | import { showToast } from "@ui/toasts";
4 | import { getAssetIDByName } from "@ui/assets";
5 | import { Forms } from "@ui/components";
6 |
7 | interface AssetDisplayProps { asset: Asset }
8 |
9 | const { FormRow } = Forms;
10 |
11 | export default function AssetDisplay({ asset }: AssetDisplayProps) {
12 | return (
13 | }
16 | onPress={() => {
17 | clipboard.setString(asset.name);
18 | showToast("Copied asset name to clipboard.", getAssetIDByName("toast_copy_link"));
19 | }}
20 | />
21 | )
22 | }
--------------------------------------------------------------------------------
/src/ui/settings/components/Card.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNative as RN, stylesheet } from "@metro/common";
2 | import { findByProps } from "@metro/filters";
3 | import { getAssetIDByName } from "@ui/assets";
4 | import { semanticColors } from "@ui/color";
5 | import { Forms, Tabs } from "@ui/components";
6 |
7 | const { FormRow } = Forms;
8 | const { RedesignSwitch, RedesignCheckbox } = Tabs;
9 | const { hideActionSheet } = findByProps("openLazy", "hideActionSheet");
10 | const { showSimpleActionSheet } = findByProps("showSimpleActionSheet");
11 |
12 | // TODO: These styles work weirdly. iOS has cramped text, Android with low DPI probably does too. Fix?
13 | const styles = stylesheet.createThemedStyleSheet({
14 | card: {
15 | backgroundColor: semanticColors?.BACKGROUND_SECONDARY,
16 | borderRadius: 16,
17 | },
18 | header: {
19 | padding: 0,
20 | backgroundColor: semanticColors?.BACKGROUND_TERTIARY,
21 | borderTopLeftRadius: 16,
22 | borderTopRightRadius: 16,
23 | },
24 | actions: {
25 | flexDirection: "row-reverse",
26 | alignItems: "center",
27 | },
28 | icon: {
29 | width: 22,
30 | height: 22,
31 | marginLeft: 5,
32 | tintColor: semanticColors?.INTERACTIVE_NORMAL,
33 | },
34 | })
35 |
36 | interface Action {
37 | icon: string;
38 | onPress: () => void;
39 | }
40 |
41 | interface OverflowAction extends Action {
42 | label: string;
43 | isDestructive?: boolean;
44 | }
45 |
46 | export interface CardWrapper {
47 | item: T;
48 | index: number;
49 | }
50 |
51 | interface CardProps {
52 | index?: number;
53 | headerLabel: string | React.ComponentType;
54 | headerIcon?: string;
55 | toggleType?: "switch" | "radio";
56 | toggleValue?: boolean;
57 | onToggleChange?: (v: boolean) => void;
58 | descriptionLabel?: string | React.ComponentType;
59 | actions?: Action[];
60 | overflowTitle?: string;
61 | overflowActions?: OverflowAction[];
62 | }
63 |
64 | export default function Card(props: CardProps) {
65 | let pressableState = props.toggleValue ?? false;
66 |
67 | return (
68 |
69 | }
73 | trailing={props.toggleType && (props.toggleType === "switch" ?
74 | ()
78 | :
79 | ( {
80 | pressableState = !pressableState;
81 | props.onToggleChange?.(pressableState)
82 | }}>
83 |
84 | )
85 | )}
86 | />
87 |
91 | {props.overflowActions && showSimpleActionSheet({
93 | key: "CardOverflow",
94 | header: {
95 | title: props.overflowTitle,
96 | icon: props.headerIcon && ,
97 | onClose: () => hideActionSheet(),
98 | },
99 | options: props.overflowActions?.map(i => ({ ...i, icon: getAssetIDByName(i.icon) })),
100 | })}
101 | >
102 |
103 | }
104 | {props.actions?.map(({ icon, onPress }) => (
105 |
108 |
109 |
110 | ))}
111 |
112 | }
113 | />
114 |
115 | )
116 | }
117 |
--------------------------------------------------------------------------------
/src/ui/settings/components/InstallButton.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNative as RN, stylesheet, clipboard } from "@metro/common";
2 | import { HTTP_REGEX_MULTI } from "@lib/constants";
3 | import { showInputAlert } from "@ui/alerts";
4 | import { getAssetIDByName } from "@ui/assets";
5 | import { semanticColors } from "@ui/color";
6 |
7 | const styles = stylesheet.createThemedStyleSheet({
8 | icon: {
9 | marginRight: 10,
10 | tintColor: semanticColors.HEADER_PRIMARY,
11 | },
12 | });
13 |
14 | interface InstallButtonProps {
15 | alertTitle: string;
16 | installFunction: (id: string) => Promise;
17 | }
18 |
19 | export default function InstallButton({ alertTitle, installFunction: fetchFunction }: InstallButtonProps) {
20 | return (
21 |
22 | clipboard.getString().then((content) =>
23 | showInputAlert({
24 | title: alertTitle,
25 | initialValue: content.match(HTTP_REGEX_MULTI)?.[0] ?? "",
26 | placeholder: "https://example.com/",
27 | onConfirm: (input: string) => fetchFunction(input),
28 | confirmText: "Install",
29 | cancelText: "Cancel",
30 | })
31 | )
32 | }>
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/ui/settings/components/PluginCard.tsx:
--------------------------------------------------------------------------------
1 | import { ButtonColors, Plugin } from "@types";
2 | import { NavigationNative, clipboard } from "@metro/common";
3 | import { removePlugin, startPlugin, stopPlugin, getSettings, fetchPlugin } from "@lib/plugins";
4 | import { MMKVManager } from "@lib/native";
5 | import { getAssetIDByName } from "@ui/assets";
6 | import { showToast } from "@ui/toasts";
7 | import { showConfirmationAlert } from "@ui/alerts";
8 | import Card, { CardWrapper } from "@ui/settings/components/Card";
9 |
10 | async function stopThenStart(plugin: Plugin, callback: Function) {
11 | if (plugin.enabled) stopPlugin(plugin.id, false);
12 | callback();
13 | if (plugin.enabled) await startPlugin(plugin.id);
14 | }
15 |
16 | export default function PluginCard({ item: plugin, index }: CardWrapper) {
17 | const settings = getSettings(plugin.id);
18 | const navigation = NavigationNative.useNavigation();
19 | const [removed, setRemoved] = React.useState(false);
20 |
21 | // This is needed because of React™
22 | if (removed) return null;
23 |
24 | return (
25 | i.name).join(", ")}`}
29 | headerIcon={plugin.manifest.vendetta?.icon || "ic_application_command_24px"}
30 | toggleType="switch"
31 | toggleValue={plugin.enabled}
32 | onToggleChange={(v: boolean) => {
33 | try {
34 | if (v) startPlugin(plugin.id); else stopPlugin(plugin.id);
35 | } catch (e) {
36 | showToast((e as Error).message, getAssetIDByName("Small"));
37 | }
38 | }}
39 | descriptionLabel={plugin.manifest.description}
40 | overflowTitle={plugin.manifest.name}
41 | overflowActions={[
42 | {
43 | icon: "ic_sync_24px",
44 | label: "Refetch",
45 | onPress: async () => {
46 | stopThenStart(plugin, () => {
47 | fetchPlugin(plugin.id).then(async () => {
48 | showToast("Successfully refetched plugin.", getAssetIDByName("toast_image_saved"));
49 | }).catch(() => {
50 | showToast("Failed to refetch plugin!", getAssetIDByName("Small"));
51 | })
52 | });
53 | },
54 | },
55 | {
56 | icon: "copy",
57 | label: "Copy URL",
58 | onPress: () => {
59 | clipboard.setString(plugin.id);
60 | showToast("Copied plugin URL to clipboard.", getAssetIDByName("toast_copy_link"));
61 | }
62 | },
63 | {
64 | icon: "ic_download_24px",
65 | label: plugin.update ? "Disable updates" : "Enable updates",
66 | onPress: () => {
67 | plugin.update = !plugin.update;
68 | showToast(`${plugin.update ? "Enabled" : "Disabled"} updates for ${plugin.manifest.name}.`, getAssetIDByName("toast_image_saved"));
69 | }
70 | },
71 | {
72 | icon: "ic_duplicate",
73 | label: "Clear data",
74 | isDestructive: true,
75 | onPress: () => showConfirmationAlert({
76 | title: "Wait!",
77 | content: `Are you sure you wish to clear the data of ${plugin.manifest.name}?`,
78 | confirmText: "Clear",
79 | cancelText: "Cancel",
80 | confirmColor: ButtonColors.RED,
81 | onConfirm: () => {
82 | stopThenStart(plugin, () => {
83 | try {
84 | MMKVManager.removeItem(plugin.id);
85 | showToast(`Cleared data for ${plugin.manifest.name}.`, getAssetIDByName("trash"));
86 | } catch {
87 | showToast(`Failed to clear data for ${plugin.manifest.name}!`, getAssetIDByName("Small"));
88 | }
89 | });
90 | }
91 | }),
92 | },
93 | {
94 | icon: "ic_message_delete",
95 | label: "Delete",
96 | isDestructive: true,
97 | onPress: () => showConfirmationAlert({
98 | title: "Wait!",
99 | content: `Are you sure you wish to delete ${plugin.manifest.name}? This will clear all of the plugin's data.`,
100 | confirmText: "Delete",
101 | cancelText: "Cancel",
102 | confirmColor: ButtonColors.RED,
103 | onConfirm: () => {
104 | try {
105 | removePlugin(plugin.id);
106 | setRemoved(true);
107 | } catch (e) {
108 | showToast((e as Error).message, getAssetIDByName("Small"));
109 | }
110 | }
111 | }),
112 | },
113 | ]}
114 | actions={[
115 | ...(settings ? [{
116 | icon: "settings",
117 | onPress: () => navigation.push("VendettaCustomPage", {
118 | title: plugin.manifest.name,
119 | render: settings,
120 | })
121 | }] : []),
122 | ]}
123 | />
124 | )
125 | }
126 |
--------------------------------------------------------------------------------
/src/ui/settings/components/SettingsSection.tsx:
--------------------------------------------------------------------------------
1 | import { NavigationNative } from "@metro/common";
2 | import { useProxy } from "@lib/storage";
3 | import { getAssetIDByName } from "@ui/assets";
4 | import { getRenderableScreens } from "@ui/settings/data";
5 | import { ErrorBoundary, Forms } from "@ui/components";
6 | import settings from "@lib/settings";
7 |
8 | const { FormRow, FormSection, FormDivider } = Forms;
9 |
10 | export default function SettingsSection() {
11 | const navigation = NavigationNative.useNavigation();
12 | useProxy(settings);
13 |
14 | const screens = getRenderableScreens()
15 |
16 | return (
17 |
18 |
19 | {screens.map((s, i) => (
20 | <>
21 | }
24 | trailing={FormRow.Arrow}
25 | onPress={() => navigation.push(s.key)}
26 | />
27 | {i !== screens.length - 1 && }
28 | >
29 | ))}
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/src/ui/settings/components/ThemeCard.tsx:
--------------------------------------------------------------------------------
1 | import { ButtonColors, Theme } from "@types";
2 | import { clipboard } from "@metro/common";
3 | import { fetchTheme, removeTheme, selectTheme } from "@lib/themes";
4 | import { useProxy } from "@lib/storage";
5 | import { BundleUpdaterManager } from "@lib/native";
6 | import { getAssetIDByName } from "@ui/assets";
7 | import { showConfirmationAlert } from "@ui/alerts";
8 | import { showToast } from "@ui/toasts";
9 | import settings from "@lib/settings";
10 | import Card, { CardWrapper } from "@ui/settings/components/Card";
11 |
12 | async function selectAndReload(value: boolean, id: string) {
13 | await selectTheme(value ? id : "default");
14 | BundleUpdaterManager.reload();
15 | }
16 |
17 | export default function ThemeCard({ item: theme, index }: CardWrapper) {
18 | useProxy(settings);
19 | const [removed, setRemoved] = React.useState(false);
20 |
21 | // This is needed because of React™
22 | if (removed) return null;
23 |
24 | const authors = theme.data.authors;
25 |
26 | return (
27 | i.name).join(", ")}` : ""}`}
30 | descriptionLabel={theme.data.description ?? "No description."}
31 | toggleType={!settings.safeMode?.enabled ? "radio" : undefined}
32 | toggleValue={theme.selected}
33 | onToggleChange={(v: boolean) => {
34 | selectAndReload(v, theme.id);
35 | }}
36 | overflowTitle={theme.data.name}
37 | overflowActions={[
38 | {
39 | icon: "ic_sync_24px",
40 | label: "Refetch",
41 | onPress: () => {
42 | fetchTheme(theme.id, theme.selected).then(() => {
43 | if (theme.selected) {
44 | showConfirmationAlert({
45 | title: "Theme refetched",
46 | content: "A reload is required to see the changes. Do you want to reload now?",
47 | confirmText: "Reload",
48 | cancelText: "Cancel",
49 | confirmColor: ButtonColors.RED,
50 | onConfirm: () => BundleUpdaterManager.reload(),
51 | })
52 | } else {
53 | showToast("Successfully refetched theme.", getAssetIDByName("toast_image_saved"));
54 | }
55 | }).catch(() => {
56 | showToast("Failed to refetch theme!", getAssetIDByName("Small"));
57 | });
58 | },
59 | },
60 | {
61 | icon: "copy",
62 | label: "Copy URL",
63 | onPress: () => {
64 | clipboard.setString(theme.id);
65 | showToast("Copied theme URL to clipboard.", getAssetIDByName("toast_copy_link"));
66 | }
67 | },
68 | {
69 | icon: "ic_message_delete",
70 | label: "Delete",
71 | isDestructive: true,
72 | onPress: () => showConfirmationAlert({
73 | title: "Wait!",
74 | content: `Are you sure you wish to delete ${theme.data.name}?`,
75 | confirmText: "Delete",
76 | cancelText: "Cancel",
77 | confirmColor: ButtonColors.RED,
78 | onConfirm: () => {
79 | removeTheme(theme.id).then((wasSelected) => {
80 | setRemoved(true);
81 | if (wasSelected) selectAndReload(false, theme.id);
82 | }).catch((e: Error) => {
83 | showToast(e.message, getAssetIDByName("Small"));
84 | });
85 | }
86 | })
87 | },
88 | ]}
89 | />
90 | )
91 | }
92 |
--------------------------------------------------------------------------------
/src/ui/settings/components/Version.tsx:
--------------------------------------------------------------------------------
1 | import { clipboard } from "@metro/common";
2 | import { getAssetIDByName } from "@ui/assets";
3 | import { showToast } from "@ui/toasts";
4 | import { Forms } from "@ui/components";
5 |
6 | interface VersionProps {
7 | label: string;
8 | version: string;
9 | icon: string;
10 | }
11 |
12 | const { FormRow, FormText } = Forms;
13 |
14 | export default function Version({ label, version, icon }: VersionProps) {
15 | return (
16 | }
19 | trailing={{version}}
20 | onPress={() => {
21 | clipboard.setString(`${label} - ${version}`);
22 | showToast("Copied version to clipboard.", getAssetIDByName("toast_copy_link"));
23 | }}
24 | />
25 | )
26 | }
--------------------------------------------------------------------------------
/src/ui/settings/data.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNative as RN, NavigationNative, stylesheet, lodash } from "@metro/common";
2 | import { installPlugin } from "@lib/plugins";
3 | import { installTheme } from "@lib/themes";
4 | import { showConfirmationAlert } from "@ui/alerts";
5 | import { semanticColors } from "@ui/color";
6 | import { showToast } from "@ui/toasts";
7 | import { without } from "@lib/utils";
8 | import { getAssetIDByName } from "@ui/assets";
9 | import settings from "@lib/settings";
10 | import ErrorBoundary from "@ui/components/ErrorBoundary";
11 | import InstallButton from "@ui/settings/components/InstallButton";
12 | import General from "@ui/settings/pages/General";
13 | import Plugins from "@ui/settings/pages/Plugins";
14 | import Themes from "@ui/settings/pages/Themes";
15 | import Secret from "@ui/settings/pages/Secret";
16 | import { PROXY_PREFIX } from "@/lib/constants";
17 |
18 | interface Screen {
19 | [index: string]: any;
20 | key: string;
21 | title: string;
22 | icon?: string;
23 | shouldRender?: () => boolean;
24 | options?: Record;
25 | render: React.ComponentType;
26 | }
27 |
28 | const styles = stylesheet.createThemedStyleSheet({ container: { flex: 1, backgroundColor: semanticColors.BACKGROUND_MOBILE_PRIMARY } });
29 | const formatKey = (key: string, youKeys: boolean) => youKeys ? lodash.snakeCase(key).toUpperCase() : key;
30 | // If a function is passed, it is called with the screen object, and the return value is mapped. If a string is passed, we map to the value of the property with that name on the screen. Else, just map to the given data.
31 | // Question: Isn't this overengineered?
32 | // Answer: Maybe.
33 | const keyMap = (screens: Screen[], data: string | ((s: Screen) => any) | null) => Object.fromEntries(screens.map(s => [s.key, typeof data === "function" ? data(s) : typeof data === "string" ? s[data] : data]));
34 |
35 | export const getScreens = (youKeys = false): Screen[] => [
36 | {
37 | key: formatKey("VendettaSettings", youKeys),
38 | title: "Settings",
39 | icon: "settings",
40 | render: General,
41 | },
42 | {
43 | key: formatKey("VendettaPlugins", youKeys),
44 | title: "Plugins",
45 | icon: "debug",
46 | options: {
47 | headerRight: () => (
48 | {
51 | if (!input.startsWith(PROXY_PREFIX) && !settings.developerSettings)
52 | setImmediate(() => showConfirmationAlert({
53 | title: "Unproxied Plugin",
54 | content: "The plugin you are trying to install has not been proxied/verified by Bound's staff. Are you sure you want to continue?",
55 | confirmText: "Install",
56 | onConfirm: () =>
57 | installPlugin(input)
58 | .then(() => showToast("Installed plugin", getAssetIDByName("Check")))
59 | .catch((x) => showToast(x?.message ?? `${x}`, getAssetIDByName("Small"))),
60 | cancelText: "Cancel",
61 | }));
62 | else return await installPlugin(input);
63 | }}
64 | />
65 | ),
66 | },
67 | render: Plugins,
68 | },
69 | {
70 | key: formatKey("VendettaThemes", youKeys),
71 | title: "Design",
72 | icon: "PencilSparkleIcon",
73 | // TODO: bad
74 | shouldRender: () => window.__vendetta_loader?.features.hasOwnProperty("themes") ?? false,
75 | options: {
76 | headerRight: () => !settings.safeMode?.enabled && ,
77 | },
78 | render: Themes,
79 | },
80 | {
81 | key: formatKey("BoundUpdater", youKeys),
82 | title: "Updater",
83 | icon: "ic_download_24px",
84 | render: Secret,
85 | },
86 | {
87 | key: formatKey("VendettaCustomPage", youKeys),
88 | title: "Bound Page",
89 | shouldRender: () => false,
90 | render: ({ render: PageView, noErrorBoundary, ...options }: { render: React.ComponentType; noErrorBoundary: boolean } & Record) => {
91 | const navigation = NavigationNative.useNavigation();
92 |
93 | navigation.addListener("focus", () => navigation.setOptions(without(options, "render", "noErrorBoundary")));
94 | return noErrorBoundary ? :
95 | },
96 | },
97 | ];
98 |
99 | export const getRenderableScreens = (youKeys = false) => getScreens(youKeys).filter(s => s.shouldRender?.() ?? true);
100 |
101 | export const getPanelsScreens = () => keyMap(getScreens(), (s) => ({
102 | title: s.title,
103 | render: s.render,
104 | ...s.options,
105 | }));
106 |
107 | export const getYouData = () => {
108 | const screens = getScreens(true);
109 |
110 | return {
111 | getLayout: () => ({
112 | title: "Bound",
113 | label: "Bound",
114 | // We can't use our keyMap function here since `settings` is an array not an object
115 | settings: getRenderableScreens(true).map(s => s.key)
116 | }),
117 | titleConfig: keyMap(screens, "title"),
118 | relationships: keyMap(screens, null),
119 | rendererConfigs: keyMap(screens, (s) => {
120 | const WrappedComponent = React.memo(({ navigation, route }: any) => {
121 | navigation.addListener("focus", () => navigation.setOptions(s.options));
122 | return
123 | });
124 |
125 | return {
126 | type: "route",
127 | title: () => s.title,
128 | icon: s.icon ? getAssetIDByName(s.icon) : null,
129 | screen: {
130 | // TODO: This is bad, we should not re-convert the key casing
131 | // For some context, just using the key here would make the route key be VENDETTA_CUSTOM_PAGE in you tab, which breaks compat with panels UI navigation
132 | route: lodash.chain(s.key).camelCase().upperFirst().value(),
133 | getComponent: () => WrappedComponent,
134 | }
135 | }
136 | }),
137 | };
138 | };
139 |
--------------------------------------------------------------------------------
/src/ui/settings/index.ts:
--------------------------------------------------------------------------------
1 | import patchPanels from "@ui/settings/patches/panels";
2 | import patchYou from "@ui/settings/patches/you";
3 |
4 | export default function initSettings() {
5 | const patches = [
6 | patchPanels(),
7 | patchYou(),
8 | ]
9 |
10 | return () => patches.forEach(p => p?.());
11 | }
12 |
--------------------------------------------------------------------------------
/src/ui/settings/pages/AssetBrowser.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNative as RN } from "@metro/common";
2 | import { all } from "@ui/assets";
3 | import { Forms, Search, ErrorBoundary } from "@ui/components";
4 | import AssetDisplay from "@ui/settings/components/AssetDisplay";
5 |
6 | const { FormDivider } = Forms;
7 |
8 | export default function AssetBrowser() {
9 | const [search, setSearch] = React.useState("");
10 |
11 | return (
12 |
13 |
14 | setSearch(v)}
17 | placeholder="Search"
18 | />
19 | a.name.includes(search) || a.id.toString() === search)}
21 | renderItem={({ item }) => }
22 | ItemSeparatorComponent={FormDivider}
23 | keyExtractor={item => item.name}
24 | />
25 |
26 |
27 | )
28 | }
--------------------------------------------------------------------------------
/src/ui/settings/pages/Developer.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNative as RN, NavigationNative } from "@metro/common";
2 | import { findByProps } from "@metro/filters";
3 | import { connectToDebugger, connectToRDT, socket } from "@lib/debug";
4 | import { BundleUpdaterManager } from "@lib/native";
5 | import { useProxy } from "@lib/storage";
6 | import { showToast } from "@ui/toasts";
7 | import { getAssetIDByName } from "@ui/assets";
8 | import { Forms, Tabs, ErrorBoundary } from "@ui/components";
9 | import settings, { loaderConfig } from "@lib/settings";
10 | import AssetBrowser from "@ui/settings/pages/AssetBrowser";
11 | import Secret from "@ui/settings/pages/Secret";
12 |
13 | const { Stack, TableRow, TableRowIcon, TableSwitchRow, TableRowGroup, TextInput, Slider } = Tabs;
14 | const { hideActionSheet } = findByProps("openLazy", "hideActionSheet");
15 | const { showSimpleActionSheet } = findByProps("showSimpleActionSheet");
16 |
17 | export default function Developer() {
18 | const navigation = NavigationNative.useNavigation();
19 |
20 | useProxy(settings);
21 | useProxy(loaderConfig);
22 |
23 | return (
24 |
25 |
26 |
27 |
28 | {
33 | settings.debugBridgeEnabled = v;
34 | try {
35 | v ? connectToDebugger(settings.debuggerUrl) : socket.close();
36 | } catch {}
37 | }}
38 | />
39 |
40 | {
46 | settings.debuggerUrl = v;
47 | }}
48 | />
49 |
50 |
51 | {window.__vendetta_loader?.features.loaderConfig &&
52 | showToast("I was too lazy to edit the native side for this - maisy")}
57 | />
58 | {
63 | settings.rdtEnabled = v;
64 | if (v) connectToRDT();
65 | }}
66 | />
67 | showToast("Why is this even needed - maisy")}
72 | />
73 |
74 | {
80 | loaderConfig.customLoadUrl.url = v;
81 | }}
82 | />
83 |
84 | }
85 |
86 | {
91 | settings.errorBoundaryEnabled = v;
92 | }}
93 | />
94 | showSimpleActionSheet({
98 | key: "ErrorBoundaryTools",
99 | header: {
100 | title: "Which ErrorBoundary do you want to trip?",
101 | icon: ,
102 | onClose: () => hideActionSheet(),
103 | },
104 | options: [
105 | // @ts-expect-error
106 | // Of course, to trigger an error, we need to do something incorrectly. The below will do!
107 | { label: "Bound", onPress: () => navigation.push("VendettaCustomPage", { render: () => }) },
108 | { label: "Discord", isDestructive: true, onPress: () => navigation.push("VendettaCustomPage", { noErrorBoundary: true }) },
109 | ],
110 | })}
111 | arrow
112 | />
113 |
114 |
115 | }
118 | />
119 |
120 | {
123 | settings.inspectionDepth = v;
124 | }}
125 | minimumValue={1}
126 | maximumValue={6}
127 | step={1}
128 | />
129 |
130 | }
133 | onPress={() => navigation.push("VendettaCustomPage", {
134 | render: Secret,
135 | })}
136 | arrow
137 | />
138 |
139 |
140 | }
143 | onPress={() => BundleUpdaterManager.reload()}
144 | arrow
145 | />
146 | }
149 | onPress={() => window.gc?.()}
150 | arrow
151 | />
152 | }
155 | onPress={() => navigation.push("VendettaCustomPage", {
156 | title: "Asset Browser",
157 | render: AssetBrowser,
158 | })}
159 | arrow
160 | />
161 |
162 |
163 |
164 |
165 | )
166 | }
167 |
--------------------------------------------------------------------------------
/src/ui/settings/pages/General.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNative as RN, NavigationNative, url } from "@metro/common";
2 | import { DISCORD_SERVER, GITHUB } from "@lib/constants";
3 | import { setSafeMode } from "@lib/debug";
4 | import { useProxy } from "@lib/storage";
5 | import { plugins } from "@lib/plugins";
6 | import { themes } from "@lib/themes";
7 | import { showToast } from "@ui/toasts";
8 | import { getAssetIDByName } from "@ui/assets";
9 | import { Tabs, ErrorBoundary } from "@ui/components";
10 | import settings from "@lib/settings";
11 | import Developer from "@ui/settings/pages/Developer";
12 | import Secret from "@ui/settings/pages/Secret";
13 |
14 | const { Stack, TableRow, TableRowIcon, TableSwitchRow, TableRowGroup } = Tabs;
15 |
16 | export default function General() {
17 | const navigation = NavigationNative.useNavigation();
18 |
19 | useProxy(settings);
20 | useProxy(plugins);
21 | useProxy(themes);
22 |
23 | return (
24 |
25 |
26 |
27 |
28 | }
32 | value={settings.safeMode?.enabled}
33 | onValueChange={(v: boolean) => {
34 | setSafeMode(v);
35 | // hack
36 | settings.safeMode!.enabled = v;
37 | }}
38 | />
39 |
40 |
41 | }
44 | onPress={() => navigation.push("VendettaCustomPage", {
45 | render: Secret,
46 | })}
47 | arrow
48 | />
49 | }
52 | onPress={() => navigation.push("VendettaCustomPage", { title: "Development Settings", render: Developer })}
53 | arrow
54 | />
55 |
56 |
57 | }
60 | trailing={}
61 | />
62 | }
65 | trailing={}
66 | />
67 |
68 |
69 | }
72 | onPress={() => url.openDeeplink(DISCORD_SERVER)}
73 | arrow
74 | />
75 | }
78 | onPress={() => url.openURL(GITHUB)}
79 | arrow
80 | />
81 | }
84 | onPress={() => showToast("nuh uh")}
85 | arrow
86 | />
87 |
88 |
89 |
90 |
91 | )
92 | }
93 |
--------------------------------------------------------------------------------
/src/ui/settings/pages/Plugins.tsx:
--------------------------------------------------------------------------------
1 | import { Plugin } from "@types";
2 | import { useProxy } from "@lib/storage";
3 | import { plugins } from "@lib/plugins";
4 | import settings from "@lib/settings";
5 | import AddonPage from "@ui/settings/components/AddonPage";
6 | import PluginCard from "@ui/settings/components/PluginCard";
7 |
8 | export default function Plugins() {
9 | useProxy(settings)
10 |
11 | return (
12 |
13 | items={plugins}
14 | safeModeMessage="You are in Recovery Mode, so plugins cannot be loaded. Disable any misbehaving plugins, then return to Normal Mode from the General settings page."
15 | card={PluginCard}
16 | />
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/ui/settings/pages/Secret.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNative as RN } from "@metro/common";
2 | import { ErrorBoundary } from "@ui/components";
3 |
4 | export default function General() {
5 | return (
6 |
7 |
8 |
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/src/ui/settings/pages/Themes.tsx:
--------------------------------------------------------------------------------
1 | import { Theme, ButtonColors } from "@types";
2 | import { useProxy } from "@lib/storage";
3 | import { themes } from "@lib/themes";
4 | import { Button, TabulatedScreen } from "@ui/components";
5 | import settings from "@lib/settings";
6 | import AddonPage from "@ui/settings/components/AddonPage";
7 | import ThemeCard from "@ui/settings/components/ThemeCard";
8 | import Secret from "@ui/settings/pages/Secret";
9 |
10 | export default function Themes() {
11 | useProxy(settings);
12 |
13 | return (
14 |
19 | items={themes}
20 | safeModeMessage={`You are in Recovery Mode, meaning themes have been temporarily disabled.${settings.safeMode?.currentThemeId ? " If a theme appears to be causing the issue, you can press below to disable it persistently." : ""}`}
21 | safeModeExtras={settings.safeMode?.currentThemeId ?