├── .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 ?