Rename Kettu to ShiggyCord and update branding

- Replace all references to "Kettu" with "ShiggyCord" in code, UI, and
docs - Update icons and assets to use shiggy.png - Change repo URLs to
GitHub ShiggyCord repository - Update build output to shiggycord.js -
Remove Codeberg and legacy workflow/config files - Add new GitHub
release workflow and semantic-release config - Update i18n strings and
debug info to reflect ShiggyCord branding - Refactor settings, plugin,
and theme pages for new branding and improved UX - Add FixEmbed and
MessageFix core plugins - Refactor NoTrack plugin for improved telemetry
blocking - Remove unused PluginBrowser page - Miscellaneous code style
and formatting improvements
This commit is contained in:
kmmiio99o 2025-10-11 02:26:41 +02:00
parent a9c340541d
commit bc3abf5a6d
56 changed files with 4776 additions and 3393 deletions

View file

@ -1,18 +0,0 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: cocobo1
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View file

@ -1,161 +0,0 @@
name: 🐞 Bug report
description: Report a bug or an issue.
title: 'bug: '
labels: ['Bug report']
body:
- type: markdown
attributes:
value: |
> [!IMPORTANT]
> ## Before you submit this issue...
> We might reject any issues that can't be solved within the repository, or reports that are too general. For example:
>
> <details>
> <summary><b>"Kettu crashes upon starting..."</b></summary>
> > There are lots of different things that could be causing this, but it's probably not related to this repository.
> </details>
> <details>
> <summary><b>"Kettu freezes on splash screen..."</b></summary>
> > The issue is too general and should be reported only when it occurs after an update to a specific Discord version or after a specific action or configuration has been made within Kettu.
> </details>
> <details>
> <summary><b>"Kettu refuses to install/stuck on downloading..."</b></summary>
> > This is an external issue (sideloading) that does not have a place in this repository. If it's a problem with Kettu Manager, report it to the <a href="https://github.com/C0C0B01/KettuManager">appropriate repository</a>.
> </details>
>
> If you think that your problem belongs to one of these, you should avoid submitting this report unless you are told to do so.
>
> Please report the problem or ask for help in the appropriate place. For example, if the problem occurs in Kettu Manager, report it in the Kettu Manager repository.
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: You should ensure the completion of the task before proceeding to check it off the checklist. Neglecting to do so may impede the efficiency of the issue resolution process. The developer has the right to delete the issue directly if you check the list blindly.
options:
- label: I am able to reproduce the bug with the latest version of Kettu and Discord.
required: true
- label: I've checked that there is no open or closed issue about this bug.
required: true
- label: The title of this issue accurately describes the bug.
required: true
- label: This is not a feature request or a general question.
required: true
- label: This bug can be reproduced consistently and is not caused by a plugin.
required: true
- label: I have read the notes above and ensured that my issue is clear and within this repository.
required: true
- type: textarea
attributes:
label: Bug description
description: |
- Describe your bug in detail
- Add steps to reproduce the bug if possible (Step 1. ... Step 2. ...)
- Add images and videos if possible
validations:
required: true
- type: textarea
attributes:
label: Stack trace
description: If this bug causes a JS crash, please paste the stack trace here.
render: shell
- type: textarea
attributes:
label: Component stack trace
description: If this bug causes a JS crash, please paste the component stack trace here.
render: shell
- type: textarea
attributes:
label: Native crash trace
description: If this bug causes a native crash, please paste the crash trace here. On Android, this can be accessed by doing `logcat | grep AndroidRuntime`.
render: shell
- type: input
id: discord-version
attributes:
label: Discord version
description: |
The version of Discord you are using. You can find this in the settings. You may leave both the version number and the build number.
placeholder: |
Example:
"253.2 (66074)"
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: |
The version of Kettu the issue is occuring on. You can find this in the settings. Kettu version is in formatted like the following: `v0.0.5` and NOT like this: `beaf689-main`.
placeholder: |
Example:
"v0.0.4"
- type: input
id: loader-version
attributes:
label: Loader version
description: |
The version of the loader you are using. You can find this in the settings.
placeholder: |
Example:
"KettuXposed (v0.0.2)"
validations:
required: true
- type: input
id: device
attributes:
label: Device
description: |
The device model you are using.
placeholder: |
Example:
"Samsung A35 5G"
validations:
required: true
- type: input
id: os-version
attributes:
label: OS version
description: |
The version of the operating system you are using.
placeholder: |
Example:
"Android 12/Oneui 4"
validations:
required: true
- type: textarea
attributes:
label: Debug information
description: |
Debug information is important for the developer to understand the environment in which the bug occurred. You can get this information by sending `/debug` in the chat.
placeholder: |
Example:
Kettu: v0.6.7 (KettuXposed v0.2.4)
Discord: 255.2 (255202)
React: 18.3.1 (RN 0.74.5-discord-11)
Hermes: for RN 0.74.5-discord-11 (bcv96)
System: Android 13 (SDK 33)
Device: M2101K7BG (secret)
- type: textarea
attributes:
label: Additional context
description: Add additional context here.
- type: checkboxes
id: acknowledgements
attributes:
label: Acknowledgements
description: Your bug report will be closed if you don't follow the checklist below.
options:
- label: I have checked all open and closed bug reports and this is not a duplicate.
required: true
- label: I have chosen an appropriate title.
required: true
- label: All requested information has been provided properly.
required: true

View file

@ -1,61 +0,0 @@
name: Node.js CI / CD (Bun + Forgejo)
on:
push:
branches: [ "main" ]
jobs:
buildAndRelease:
runs-on: codeberg-tiny
steps:
- uses: actions/checkout@v3
- name: Install Bun
run: |
curl -fsSL https://bun.sh/install | bash
export BUN_INSTALL="$HOME/.bun"
export PATH="$BUN_INSTALL/bin:$PATH"
echo "$BUN_INSTALL/bin" >> $GITHUB_PATH
- name: Install dependencies
run: |
export BUN_INSTALL="$HOME/.bun"
export PATH="$BUN_INSTALL/bin:$PATH"
bun install
- name: Build project
run: |
export BUN_INSTALL="$HOME/.bun"
export PATH="$BUN_INSTALL/bin:$PATH"
bun run build --release-branch=${GITHUB_REF_NAME:-${CI_COMMIT_REF_NAME}} --build-minify
- name: Push JS files to dist branch
run: |
git config --local user.email "actions@forgejo.local"
git config --local user.name "Forgejo Actions"
mkdir -p temp_dist
find dist -name "*.js" -exec cp {} temp_dist/ \;
git checkout --orphan dist-temp
git rm -rf --cached .
cp -r temp_dist/* .
rm -rf temp_dist
git add *.js
git commit -m "Update build $GITHUB_SHA" || exit 0
git push -f origin HEAD:dist
git checkout -f ${GITHUB_REF_NAME:-${CI_COMMIT_REF_NAME}}
- name: Merge main into github
if: github.ref == 'refs/heads/main'
run: |
git config --local user.email "actions@forgejo.local"
git config --local user.name "Forgejo Actions"
git fetch origin
git checkout github || git checkout -b github
git merge origin/main --no-edit --allow-unrelated-histories
git push --force origin github

51
.github/workflows/auto-release.yml vendored Normal file
View file

@ -0,0 +1,51 @@
name: Automatic Release
on:
push:
branches:
- main
paths:
- "dist/shiggycord.js"
- "dist/shiggycord.min.js"
jobs:
release:
name: Create Release
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Extract bunny version
id: bunny_version
run: |
version=$(grep 'const version:' src/modules.d.ts | head -n1 | sed 's/[^0-9.]//g')
echo "version=$version" >> $GITHUB_OUTPUT
- name: Build (if needed)
run: bun run build
- name: Create Release
uses: softprops/action-gh-release@v1
with:
tag_name: v${{ steps.bunny_version.outputs.version }}
name: Release v${{ steps.bunny_version.outputs.version }}
files: |
dist/shiggycord.js
dist/shiggycord.min.js
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

49
.releaserc.json Normal file
View file

@ -0,0 +1,49 @@
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
[
"@semantic-release/changelog",
{
"changelogFile": "CHANGELOG.md"
}
],
[
"@semantic-release/npm",
{
"npmPublish": false
}
],
[
"@semantic-release/github",
{
"assets": [
{
"path": "dist/shiggycord.js",
"label": "ShiggyCord Bundle"
},
{
"path": "dist/shiggycord.min.js",
"label": "ShiggyCord Bundle (minified)"
}
]
}
],
[
"@semantic-release/git",
{
"assets": ["package.json", "CHANGELOG.md"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
],
[
"@saithodev/semantic-release-backmerge",
{
"branches": ["dev"],
"backmergeStrategy": "merge",
"backmergeMessage": "chore(release): merge release ${nextRelease.version} into dev [skip ci]"
}
]
]
}

18
.vscode/settings.json vendored
View file

@ -1,18 +0,0 @@
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[typescriptreact]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[json]": {
"editor.formatOnSave": false
},
"javascript.format.semicolons": "insert",
"typescript.format.semicolons": "insert",
"typescript.preferences.quoteStyle": "double",
"javascript.preferences.quoteStyle": "double"
}

View file

@ -1,31 +1,31 @@
# ShiggyCord # ShiggyCord
An unofficial fork of Kettu, made just for fun An unofficial fork of ShiggyCord, made just for fun
Idea taken from Rosiecord Idea taken from Rosiecord
## Installing ## Installing
### Android/iOS ### Android/iOS
- **Injecting bundle:** ``https://github.com/kmmiio99o/ShiggyCord/releases/<VERSION>/kettu.js`` - **Injecting bundle:** ``https://github.com/kmmiio99o/ShiggyCord/releases/<VERSION>/shiggycord.js``
## Building ## Building
1. Install a Kettu loader with loader config support (any mentioned in the [Installing](#installing) section). 1. Install a ShiggyCord loader with loader config support (any mentioned in the [Installing](#installing) section).
1. Go to Settings > General and enable Developer Settings. 1. Go to Settings > General and enable Developer Settings.
1. Clone the repo: 1. Clone the repo:
``` ```
git clone https://codeberg.org/kmmiio99o/Shiggycord.git git clone https://github.com/kmmiio99o/Shiggycord.git
``` ```
1. Install dependencies: 1. Install dependencies:
``` ```
bun i bun i
``` ```
1. Build Kettu's code: 1. Build ShiggyCord's code:
``` ```
bun run build bun run build
``` ```
1. In the newly created `dist` directory, run a HTTP server. I recommend [http-server](https://www.npmjs.com/package/http-server). 1. In the newly created `dist` directory, run a HTTP server. I recommend [http-server](https://www.npmjs.com/package/http-server).
1. Go to Settings > Developer enabled earlier. Enable `Load from custom url` and input the IP address and port of the server (e.g. `http://192.168.1.236:4040/kettu.js`) in the new input box labelled `Kettu URL`. 1. Go to Settings > Developer enabled earlier. Enable `Load from custom url` and input the IP address and port of the server (e.g. `http://192.168.1.236:4040/shiggycord.js`) in the new input box labelled `ShiggyCord URL`.
1. Restart Discord. Upon reload, you should notice that your device will download Kettu's bundled code from your server, rather than GitHub. 1. Restart Discord. Upon reload, you should notice that your device will download ShiggyCord's bundled code from your server, rather than GitHub.
1. Make your changes, rebuild, reload, go wild! 1. Make your changes, rebuild, reload, go wild!
Alternatively, you can directly *serve* the bundled code by running `bun run serve`. `kettu.js` will be served on your local address under the port 4040. You will then insert `http://<local ip address>:4040/kettu.js` as a custom url and reload. Whenever you restart your mobile client, the script will rebuild the bundle as your client fetches it. Alternatively, you can directly *serve* the bundled code by running `bun run serve`. `shiggycord.js` will be served on your local address under the port 4040. You will then insert `http://<local ip address>:4040/shiggycord.js` as a custom url and reload. Whenever you restart your mobile client, the script will rebuild the bundle as your client fetches it.

View file

@ -1,9 +1,9 @@
{ {
"name": "Kettu", "name": "ShiggyCord",
"private": true, "private": true,
"author": { "author": {
"name": "cocobo1", "name": "kmmiio99o",
"url": "https://github.com/C0C0B01" "url": "https://github.com/kmmiio99o"
}, },
"scripts": { "scripts": {
"build": "node scripts/build.mjs", "build": "node scripts/build.mjs",

View file

@ -14,14 +14,14 @@ import { printBuildSuccess } from "./util.mjs";
/** @type string[] */ /** @type string[] */
const metroDeps = await (async () => { const metroDeps = await (async () => {
const ast = await swc.parseFile(path.resolve("./shims/depsModule.ts")); const ast = await swc.parseFile(path.resolve("./shims/depsModule.ts"));
return ast.body.at(-1).expression.right.properties.map(p => p.key.value); return ast.body.at(-1).expression.right.properties.map((p) => p.key.value);
})(); })();
const args = yargs(process.argv.slice(2)); const args = yargs(process.argv.slice(2));
const { const {
"release-branch": releaseBranch, "release-branch": releaseBranch,
"build-minify": buildMinify, "build-minify": buildMinify,
"dev": dev dev: dev,
} = args; } = args;
let context = null; let context = null;
@ -30,43 +30,43 @@ let context = null;
const config = { const config = {
entryPoints: ["src/entry.ts"], entryPoints: ["src/entry.ts"],
bundle: true, bundle: true,
outfile: "dist/kettu.js", outfile: "dist/shiggycord.js",
format: "iife", format: "iife",
splitting: false, splitting: false,
external: [], external: [],
supported: { supported: {
// Hermes does not actually support const and let, even though it syntactically // Hermes does not actually support const and let, even though it syntactically
// accepts it, but it's treated just like 'var' and causes issues // accepts it, but it's treated just like 'var' and causes issues
"const-and-let": false "const-and-let": false,
}, },
footer: { footer: {
js: "//# sourceURL=kettu" js: "//# sourceURL=shiggycord",
}, },
loader: { loader: {
".png": "dataurl" ".png": "dataurl",
}, },
define: { define: {
window: "globalThis", window: "globalThis",
__DEV__: JSON.stringify(releaseBranch !== "main") __DEV__: JSON.stringify(releaseBranch !== "main"),
}, },
inject: ["./shims/asyncIteratorSymbol.js", "./shims/promiseAllSettled.js"], inject: ["./shims/asyncIteratorSymbol.js", "./shims/promiseAllSettled.js"],
legalComments: "none", legalComments: "none",
alias: { alias: {
"!kettu-deps-shim!": "./shims/depsModule.ts", "!kettu-deps-shim!": "./shims/depsModule.ts",
"spitroast": "./node_modules/spitroast", spitroast: "./node_modules/spitroast",
"react/jsx-runtime": "./shims/jsxRuntime" "react/jsx-runtime": "./shims/jsxRuntime",
}, },
plugins: [ plugins: [
globalPlugin({ globalPlugin({
...metroDeps.reduce((obj, key) => { ...metroDeps.reduce((obj, key) => {
obj[key] = `require("!kettu-deps-shim!")[${JSON.stringify(key)}]`; obj[key] = `require("!kettu-deps-shim!")[${JSON.stringify(key)}]`;
return obj; return obj;
}, {}) }, {}),
}), }),
{ {
name: "swc", name: "swc",
setup(build) { setup(build) {
build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async args => { build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async (args) => {
const result = await swc.transformFile(args.path, { const result = await swc.transformFile(args.path, {
jsc: { jsc: {
externalHelpers: true, externalHelpers: true,
@ -74,13 +74,13 @@ const config = {
constModules: { constModules: {
globals: { globals: {
"bunny-build-info": { "bunny-build-info": {
version: `"1.3.0"` version: `"1.0.0"`,
} },
} },
}, },
react: { react: {
runtime: "automatic" runtime: "automatic",
} },
}, },
}, },
// https://github.com/facebook/hermes/blob/3815fec63d1a6667ca3195160d6e12fee6a0d8d5/doc/Features.md // https://github.com/facebook/hermes/blob/3815fec63d1a6667ca3195160d6e12fee6a0d8d5/doc/Features.md
@ -91,7 +91,7 @@ const config = {
"transform-block-scoping", "transform-block-scoping",
"transform-classes", "transform-classes",
"transform-async-to-generator", "transform-async-to-generator",
"transform-async-generator-functions" "transform-async-generator-functions",
], ],
exclude: [ exclude: [
"transform-parameters", "transform-parameters",
@ -101,21 +101,23 @@ const config = {
"transform-nullish-coalescing-operator", "transform-nullish-coalescing-operator",
"transform-object-rest-spread", "transform-object-rest-spread",
"transform-optional-chaining", "transform-optional-chaining",
"transform-logical-assignment-operators" "transform-logical-assignment-operators",
] ],
}, },
}); });
return { contents: result.code }; return { contents: result.code };
}); });
} },
} },
] ],
}; };
export async function buildBundle(overrideConfig = {}) { export async function buildBundle(overrideConfig = {}) {
context = { context = {
hash: releaseBranch ? execSync("git rev-parse --short HEAD").toString().trim() : crypto.randomBytes(8).toString("hex").slice(0, 7) hash: releaseBranch
? execSync("git rev-parse --short HEAD").toString().trim()
: crypto.randomBytes(8).toString("hex").slice(0, 7),
}; };
const initialStartTime = performance.now(); const initialStartTime = performance.now();
@ -124,7 +126,7 @@ export async function buildBundle(overrideConfig = {}) {
return { return {
config, config,
context, context,
timeTook: performance.now() - initialStartTime timeTook: performance.now() - initialStartTime,
}; };
} }
@ -135,23 +137,14 @@ const isThisFileBeingRunViaCLI = pathToThisFile.includes(pathPassedToNode);
if (isThisFileBeingRunViaCLI) { if (isThisFileBeingRunViaCLI) {
const { timeTook } = await buildBundle(); const { timeTook } = await buildBundle();
printBuildSuccess( printBuildSuccess(context.hash, releaseBranch, timeTook);
context.hash,
releaseBranch,
timeTook
);
if (buildMinify) { if (buildMinify) {
const { timeTook } = await buildBundle({ const { timeTook } = await buildBundle({
minify: true, minify: true,
outfile: config.outfile.replace(/\.js$/, ".min.js") outfile: config.outfile.replace(/\.js$/, ".min.js"),
}); });
printBuildSuccess( printBuildSuccess(context.hash, releaseBranch, timeTook, true);
context.hash,
releaseBranch,
timeTook,
true
);
} }
} }

View file

@ -20,11 +20,7 @@ export function serve(options) {
try { try {
const { config, context, timeTook } = await buildBundle(); const { config, context, timeTook } = await buildBundle();
printBuildSuccess( printBuildSuccess(context.hash, args.production, timeTook);
context.hash,
args.production,
timeTook
);
res.writeHead(200, { "Content-Type": "application/javascript" }); res.writeHead(200, { "Content-Type": "application/javascript" });
res.end(await readFile(config.outfile, "utf-8")); res.end(await readFile(config.outfile, "utf-8"));
@ -40,14 +36,14 @@ export function serve(options) {
server.listen(args.port ?? 4040); server.listen(args.port ?? 4040);
console.info(chalk.bold.yellowBright("Serving Kettu on:")); console.info(chalk.bold.yellowBright("Serving ShiggyCord on:"));
const netInterfaces = os.networkInterfaces(); const netInterfaces = os.networkInterfaces();
for (const netinterfaces of Object.values(netInterfaces)) { for (const netinterfaces of Object.values(netInterfaces)) {
for (const details of netinterfaces || []) { for (const details of netinterfaces || []) {
if (details.family !== "IPv4") continue; if (details.family !== "IPv4") continue;
const port = chalk.green(server.address()?.port.toString()); const port = chalk.green(server.address()?.port.toString());
console.info(` http://${details.address}:${port}/bundle.js`); console.info(` http://${details.address}:${port}/shiggycord.js`);
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

BIN
src/assets/icons/shiggy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1,9 +1,13 @@
import { Strings } from "@core/i18n"; import { Strings } from "@core/i18n";
import { ApplicationCommand, ApplicationCommandOptionType } from "@lib/api/commands/types"; import {
ApplicationCommand,
ApplicationCommandOptionType,
} from "@lib/api/commands/types";
import { getDebugInfo } from "@lib/api/debug"; import { getDebugInfo } from "@lib/api/debug";
import { messageUtil } from "@metro/common"; import { messageUtil } from "@metro/common";
export default () => <ApplicationCommand>{ export default () =>
<ApplicationCommand>{
name: "debug", name: "debug",
description: Strings.COMMAND_DEBUG_DESC, description: Strings.COMMAND_DEBUG_DESC,
options: [ options: [
@ -11,25 +15,27 @@ export default () => <ApplicationCommand>{
name: "ephemeral", name: "ephemeral",
type: ApplicationCommandOptionType.BOOLEAN, type: ApplicationCommandOptionType.BOOLEAN,
description: Strings.COMMAND_DEBUG_OPT_EPHEMERALLY, description: Strings.COMMAND_DEBUG_OPT_EPHEMERALLY,
} },
], ],
execute([ephemeral], ctx) { execute([ephemeral], ctx) {
const info = getDebugInfo(); const info = getDebugInfo();
const content = [ const content = [
"**Kettu Debug Info**", "**ShiggyCord Debug Info**",
`> Kettu: ${info.bunny.version} (${info.bunny.loader.name} ${info.bunny.loader.version})`, `> ShiggyCord: ${info.bunny.version} (${info.bunny.loader.name} ${info.bunny.loader.version})`,
`> Discord: ${info.discord.version} (${info.discord.build})`, `> Discord: ${info.discord.version} (${info.discord.build})`,
`> React: ${info.react.version} (RN ${info.react.nativeVersion})`, `> React: ${info.react.version} (RN ${info.react.nativeVersion})`,
`> Hermes: ${info.hermes.version} (bcv${info.hermes.bytecodeVersion})`, `> Hermes: ${info.hermes.version} (bcv${info.hermes.bytecodeVersion})`,
`> System: ${info.os.name} ${info.os.version} ${info.os.sdk ? `(SDK ${info.os.sdk})` : ""}`.trimEnd(), `> System: ${info.os.name} ${info.os.version} ${info.os.sdk ? `(SDK ${info.os.sdk})` : ""}`.trimEnd(),
`> Device: ${info.device.model} (${info.device.codename})`, `> Device: ${info.device.model}`,
].join("\n"); ].join("\n");
if (ephemeral?.value) { if (ephemeral?.value) {
messageUtil.sendBotMessage(ctx.channel.id, content); messageUtil.sendBotMessage(ctx.channel.id, content);
} else { } else {
const fixNonce = Date.now().toString(); const fixNonce = Date.now().toString();
messageUtil.sendMessage(ctx.channel.id, { content }, void 0, {nonce:fixNonce}); messageUtil.sendMessage(ctx.channel.id, { content }, void 0, {
} nonce: fixNonce,
});
} }
},
}; };

View file

@ -6,8 +6,8 @@
"ARE_YOU_SURE_TO_DELETE_THEME": "Are you sure you wish to delete {name}?", "ARE_YOU_SURE_TO_DELETE_THEME": "Are you sure you wish to delete {name}?",
"ASSET_BROWSER": "Asset Browser", "ASSET_BROWSER": "Asset Browser",
"BRAND": "Brand", "BRAND": "Brand",
"PUPU": "Kettu", "PUPU": "ShiggyCord",
"PUPU_URL": "Kettu URL", "PUPU_URL": "ShiggyCord URL",
"BROWSER": "Browser", "BROWSER": "Browser",
"BYTECODE": "Bytecode", "BYTECODE": "Bytecode",
"CANCEL": "Cancel", "CANCEL": "Cancel",
@ -19,7 +19,7 @@
"CLEAR_DATA_SUCCESSFUL": "Cleared data for {name}", "CLEAR_DATA_SUCCESSFUL": "Cleared data for {name}",
"CODEBERG": "Codeberg", "CODEBERG": "Codeberg",
"CODENAME": "Codename", "CODENAME": "Codename",
"COMMAND_DEBUG_DESC": "Send Kettu debug info.", "COMMAND_DEBUG_DESC": "Send ShiggyCord debug info.",
"COMMAND_DEBUG_OPT_EPHEMERALLY": "Send debug info ephemerally.", "COMMAND_DEBUG_OPT_EPHEMERALLY": "Send debug info ephemerally.",
"COMMAND_EVAL_DESC": "Evaluate JavaScript code.", "COMMAND_EVAL_DESC": "Evaluate JavaScript code.",
"COMMAND_EVAL_OPT_ASYNC": "Whether to support 'await' in code. Must explicitly return for result (default: false)", "COMMAND_EVAL_OPT_ASYNC": "Whether to support 'await' in code. Must explicitly return for result (default: false)",
@ -63,7 +63,7 @@
"LABEL_EXTRACT_FONTS_FROM_THEME": "Extract font from theme", "LABEL_EXTRACT_FONTS_FROM_THEME": "Extract font from theme",
"LINKS": "Links", "LINKS": "Links",
"LOAD_FROM_CUSTOM_URL": "Load from custom URL", "LOAD_FROM_CUSTOM_URL": "Load from custom URL",
"LOAD_FROM_CUSTOM_URL_DEC": "Load Kettu from a custom endpoint.", "LOAD_FROM_CUSTOM_URL_DEC": "Load ShiggyCord from a custom endpoint.",
"LOAD_REACT_DEVTOOLS": "Load React DevTools", "LOAD_REACT_DEVTOOLS": "Load React DevTools",
"LOADER": "Loader", "LOADER": "Loader",
"MACHINE_ID": "Machine ID", "MACHINE_ID": "Machine ID",
@ -86,7 +86,7 @@
"PLUGINS": "Plugins", "PLUGINS": "Plugins",
"REFETCH": "Refetch", "REFETCH": "Refetch",
"RELOAD": "Reload", "RELOAD": "Reload",
"RELOAD_DISCORD": "Reload Discord", "RELOAD_DISCORD": "Reshiggy your Discord",
"RELOAD_IN_NORMAL_MODE": "Reload in Normal Mode", "RELOAD_IN_NORMAL_MODE": "Reload in Normal Mode",
"RELOAD_IN_NORMAL_MODE_DESC": "Safe mode currently enabled, tap to reload in normal mode", "RELOAD_IN_NORMAL_MODE_DESC": "Safe mode currently enabled, tap to reload in normal mode",
"RELOAD_IN_SAFE_MODE": "Reload in Safe Mode", "RELOAD_IN_SAFE_MODE": "Reload in Safe Mode",
@ -115,7 +115,7 @@
"UH_OH": "Uh Oh", "UH_OH": "Uh Oh",
"UNINSTALL": "Uninstall", "UNINSTALL": "Uninstall",
"UNINSTALL_TITLE": "Uninstall {title}", "UNINSTALL_TITLE": "Uninstall {title}",
"URL_PLACEHOLDER": "https://codeberg.org/cocobo1/Kettu", "URL_PLACEHOLDER": "https://github.com/kmmiio99o/ShiggyCord",
"VERSION": "Version", "VERSION": "Version",
"VERSIONS": "Versions" "VERSIONS": "Versions"
} }

View file

@ -4,7 +4,9 @@ import { PrimitiveType } from "intl-messageformat";
import langDefault from "./default.json"; import langDefault from "./default.json";
const IntlMessageFormat = findByNameLazy("MessageFormat") as typeof import("intl-messageformat").default; const IntlMessageFormat = findByNameLazy(
"MessageFormat",
) as typeof import("intl-messageformat").default;
type I18nKey = keyof typeof langDefault; type I18nKey = keyof typeof langDefault;
@ -14,17 +16,20 @@ let _lastSetLocale: string | null = null;
const _loadedLocale = new Set<string>(); const _loadedLocale = new Set<string>();
const _loadedStrings = {} as Record<string, typeof langDefault>; const _loadedStrings = {} as Record<string, typeof langDefault>;
export const Strings = new Proxy({}, { export const Strings = new Proxy(
{},
{
get: (_t, prop: keyof typeof langDefault) => { get: (_t, prop: keyof typeof langDefault) => {
if (_currentLocale && _loadedStrings[_currentLocale]?.[prop]) { if (_currentLocale && _loadedStrings[_currentLocale]?.[prop]) {
return _loadedStrings[_currentLocale]?.[prop]; return _loadedStrings[_currentLocale]?.[prop];
} }
return langDefault[prop]; return langDefault[prop];
} },
}) as Record<I18nKey, string>; },
) as Record<I18nKey, string>;
export function initFetchI18nStrings() { export function initFetchI18nStrings() {
const cb = ({ locale }: { locale: string; }) => { const cb = ({ locale }: { locale: string }) => {
const languageMap = { const languageMap = {
"es-ES": "es", "es-ES": "es",
"es-419": "es_419", "es-419": "es_419",
@ -32,10 +37,10 @@ export function initFetchI18nStrings() {
"zh-CN": "zh-Hans", "zh-CN": "zh-Hans",
"pt-PT": "pt", "pt-PT": "pt",
"pt-BR": "pt_BR", "pt-BR": "pt_BR",
"sv-SE": "sv" "sv-SE": "sv",
} as Record<string, string>; } as Record<string, string>;
const resolvedLocale = _lastSetLocale = languageMap[locale] ?? locale; const resolvedLocale = (_lastSetLocale = languageMap[locale] ?? locale);
if (resolvedLocale.startsWith("en-")) { if (resolvedLocale.startsWith("en-")) {
_currentLocale = null; _currentLocale = null;
@ -45,11 +50,21 @@ export function initFetchI18nStrings() {
if (!_loadedLocale.has(resolvedLocale)) { if (!_loadedLocale.has(resolvedLocale)) {
_loadedLocale.add(resolvedLocale); _loadedLocale.add(resolvedLocale);
fetch(`https://raw.githubusercontent.com/C0C0B01/i18n/main/resources/${resolvedLocale}/kettu.json`) fetch(
.then(r => r.json()) `https://raw.githubusercontent.com/C0C0B01/i18n/main/resources/${resolvedLocale}/shiggycord.json`,
.then(strings => _loadedStrings[resolvedLocale] = strings) )
.then(() => resolvedLocale === _lastSetLocale && (_currentLocale = resolvedLocale)) .then((r) => r.json())
.catch(e => console.error(`An error occured while fetching strings for ${resolvedLocale}: ${e}`)); .then((strings) => (_loadedStrings[resolvedLocale] = strings))
.then(
() =>
resolvedLocale === _lastSetLocale &&
(_currentLocale = resolvedLocale),
)
.catch((e) =>
console.error(
`An error occured while fetching strings for ${resolvedLocale}: ${e}`,
),
);
} else { } else {
_currentLocale = resolvedLocale; _currentLocale = resolvedLocale;
} }
@ -59,9 +74,14 @@ export function initFetchI18nStrings() {
return () => FluxDispatcher.unsubscribe("I18N_LOAD_SUCCESS", cb); return () => FluxDispatcher.unsubscribe("I18N_LOAD_SUCCESS", cb);
} }
type FormatStringRet<T> = T extends PrimitiveType ? string : string | T | (string | T)[]; type FormatStringRet<T> = T extends PrimitiveType
? string
: string | T | (string | T)[];
export function formatString<T = void>(key: I18nKey, val: Record<string, T>): FormatStringRet<T> { export function formatString<T = void>(
key: I18nKey,
val: Record<string, T>,
): FormatStringRet<T> {
const str = Strings[key]; const str = Strings[key];
// @ts-ignore // @ts-ignore
return new IntlMessageFormat(str).format(val); return new IntlMessageFormat(str).format(val);

View file

@ -0,0 +1,194 @@
import { defineCorePlugin } from "..";
import { findByProps } from "@metro";
import { after } from "@lib/api/patcher";
import { logger } from "@lib/utils/logger";
import { settings } from "@lib/api/settings";
import { React } from "@metro/common";
const { FormSection, FormRow, FormSwitch, FormText } = findByProps(
"FormSection",
"FormRow",
"FormSwitch",
"FormText",
);
const { ScrollView } = require("react-native");
type FixEmbedSettings = {
enabled: boolean;
twitter: boolean;
instagram: boolean;
tiktok: boolean;
reddit: boolean;
};
declare module "@lib/api/settings" {
interface Settings {
fixembed?: FixEmbedSettings;
}
}
const MessageActions = findByProps("sendMessage");
let unpatch: (() => void) | null = null;
function transformLinks(content: string, config: FixEmbedSettings): string {
let result = content;
// Twitter/X: Replace domain, keep path
if (config.twitter) {
result = result.replace(
/https?:\/\/(?:www\.)?(twitter\.com|x\.com)(\/[^\s]+)/gi,
"https://fxtwitter.com$2",
);
}
// Instagram: Replace domain, keep path
if (config.instagram) {
result = result.replace(
/https?:\/\/(?:www\.)?instagram\.com(\/[^\s]+)/gi,
"https://ddinstagram.com$1",
);
}
// TikTok: Replace domain, keep path
if (config.tiktok) {
result = result.replace(
/https?:\/\/(?:www\.)?tiktok\.com(\/[^\s]+)/gi,
"https://tnktok.com$1",
);
// TikTok short links (vm.tiktok.com)
result = result.replace(
/https?:\/\/vm\.tiktok\.com\/([A-Za-z0-9]+)\/?/gi,
"https://vm.tnktok.com/$1",
);
}
// Reddit: Replace domain, keep path
if (config.reddit) {
result = result.replace(
/https?:\/\/(?:www\.)?reddit\.com(\/[^\s]+)/gi,
"https://rxddit.com$1",
);
}
return result;
}
export default defineCorePlugin({
manifest: {
id: "bunny.fixembed",
version: "1.0.0",
type: "plugin",
spec: 3,
main: "",
display: {
name: "FixEmbed",
description:
"Improves social media embeds by using privacy-friendly alternative frontends.",
authors: [{ name: "ShiggyCord Team" }],
},
},
SettingsComponent() {
const { useState, useEffect } = React;
const [config, setConfig] = useState<FixEmbedSettings>({
enabled: settings.fixembed?.enabled ?? true,
twitter: settings.fixembed?.twitter ?? true,
instagram: settings.fixembed?.instagram ?? true,
tiktok: settings.fixembed?.tiktok ?? true,
reddit: settings.fixembed?.reddit ?? true,
});
useEffect(() => {
settings.fixembed = config;
}, [config]);
const updateConfig = (key: keyof FixEmbedSettings, value: boolean) => {
setConfig((prev) => ({ ...prev, [key]: value }));
};
return React.createElement(ScrollView, { style: { flex: 1 } }, [
React.createElement(
FormSection,
{ title: "General Settings" },
React.createElement(FormRow, {
label: "Enable FixEmbed",
subLabel: "Master switch for all link conversions",
trailing: React.createElement(FormSwitch, {
value: config.enabled,
onValueChange: (v: boolean) => updateConfig("enabled", v),
}),
}),
),
React.createElement(FormSection, { title: "Platforms" }, [
React.createElement(FormRow, {
label: "Twitter/X",
trailing: React.createElement(FormSwitch, {
value: config.twitter,
disabled: !config.enabled,
onValueChange: (v: boolean) => updateConfig("twitter", v),
}),
}),
React.createElement(FormRow, {
label: "Instagram",
trailing: React.createElement(FormSwitch, {
value: config.instagram,
disabled: !config.enabled,
onValueChange: (v: boolean) => updateConfig("instagram", v),
}),
}),
React.createElement(FormRow, {
label: "TikTok",
trailing: React.createElement(FormSwitch, {
value: config.tiktok,
disabled: !config.enabled,
onValueChange: (v: boolean) => updateConfig("tiktok", v),
}),
}),
React.createElement(FormRow, {
label: "Reddit",
trailing: React.createElement(FormSwitch, {
value: config.reddit,
disabled: !config.enabled,
onValueChange: (v: boolean) => updateConfig("reddit", v),
}),
}),
]),
React.createElement(
FormSection,
{ title: "About" },
React.createElement(
FormText,
{ style: { padding: 12 } },
"This plugin automatically converts social media links to privacy-friendly alternative frontends for better embeds.",
),
),
]);
},
start() {
logger.log("FixEmbed: Starting plugin");
settings.fixembed = settings.fixembed || {
enabled: true,
twitter: true,
instagram: true,
tiktok: true,
reddit: true,
};
if (!unpatch) {
unpatch = after("sendMessage", MessageActions, (args) => {
const config = settings.fixembed!;
if (!config.enabled) return;
if (args[1]?.content) {
args[1].content = transformLinks(args[1].content, config);
args[1].nonce = args[1].nonce || Math.random().toString(36).slice(2);
}
});
}
logger.log("FixEmbed: Patched outgoing messages");
},
stop() {
logger.log("FixEmbed: Stopping plugin");
if (unpatch) {
unpatch();
unpatch = null;
}
logger.log("FixEmbed: Unpatched outgoing messages");
},
});

View file

@ -7,15 +7,19 @@ interface CorePlugin {
// Called from @lib/plugins // Called from @lib/plugins
export const getCorePlugins = (): Record<string, CorePlugin> => ({ export const getCorePlugins = (): Record<string, CorePlugin> => ({
"bunny.quickinstall": require("./quickinstall"), "bunny.quickinstall": { ...require("./quickinstall"), preenabled: true },
"bunny.badges": require("./badges"), "bunny.badges": require("./badges"),
"vendetta.notrack": require("./notrack") "bunny.notrack": { ...require("./notrack"), preenabled: true },
"bunny.messagefix": { ...require("./messagefix") },
"bunny.fixembed": { ...require("./fixembed"), preenabled: true },
}); });
/** /**
* @internal * @internal
*/ */
export function defineCorePlugin(instance: PluginInstanceInternal): PluginInstanceInternal { export function defineCorePlugin(
instance: PluginInstanceInternal,
): PluginInstanceInternal {
// @ts-expect-error // @ts-expect-error
instance[Symbol.for("bunny.core.plugin")] = true; instance[Symbol.for("bunny.core.plugin")] = true;
return instance; return instance;

View file

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

View file

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

View file

@ -1,16 +0,0 @@
import { logger } from "@lib/utils/logger";
export default () => {
const sentrified = {};
try {
Object.keys(console).forEach((x) => {
sentrified[x] = console[x];
console[x] = console[x].__sentry_original__ ?? console[x];
});
} catch(e) {
logger.log("Failed to de-sentrify console functions!", e);
};
return () => Object.keys(console).forEach((x) => console[x] = sentrified[x]);
}

View file

@ -1,33 +0,0 @@
import { findByProps } from "@metro";
import { noop } from "./utils";
// Stuff in here is non-specific tracking scattered around the app
const AnalyticsUtils = findByProps("AnalyticsActionHandlers");
const SuperPropUtils = findByProps("encodeProperties", "track");
const VoiceStateUtils = findByProps("getVoiceStateMetadata");
const CrashReportUtils = findByProps("submitLiveCrashReport");
const MetricsUtils = findByProps("_metrics");
export default () => {
const patches = [
// Global analytics utilities
...(["handleTrack", "handleFingerprint"].map((i) => noop(i, AnalyticsUtils.AnalyticsActionHandlers))),
// TODO: This is a getter - look into patching it
// noop("analyticsTrackingStoreMaker", AnalyticsUtils),
// Super properties tracking
noop("track", SuperPropUtils),
// Voice state metadata tracking
noop("trackWithMetadata", VoiceStateUtils),
// Crash reporter
noop("submitLiveCrashReport", CrashReportUtils),
// Whatever "metrics" implies
noop("push", MetricsUtils._metrics),
];
return () => patches.forEach((p) => p());
}

View file

@ -1,12 +0,0 @@
import { instead } from "@lib/api/patcher";
export default () => {
// https://github.com/yellowsink/shelter-plugins/blob/3a62d1557b704f4617f72882edba826218eb8c6e/plugins/antitrack/index.js#L10-L25
const analyticsTest = /client-analytics\.braintreegateway\.com|discord\.com\/api\/v9\/(science|track)|app\.adjust\..*|.*\.ingest\.sentry\.io/;
return instead(
"send",
XMLHttpRequest.prototype,
function (args, orig) { if (!analyticsTest.test(this.__sentry_xhr__?.url)) return orig.apply(this, args) },
);
}

View file

@ -1,26 +0,0 @@
import { findByProps } from "@metro";
import { noop } from "./utils";
const Sentry = {
initializer: findByProps("initSentry"),
main: globalThis.__SENTRY__?.hub,
client: globalThis.__SENTRY__?.hub?.getClient(),
};
export default () => {
const patches = [
noop("initSentry", Sentry.initializer),
noop("addBreadcrumb", Sentry.main),
];
Sentry.client.getOptions().enabled = false;
Sentry.client.close();
Sentry.main.getStackTop().scope.clear();
Sentry.main.getScope().clear();
return () => {
patches.forEach((p) => p());
Sentry.client.getOptions().enabled = true;
Sentry.client.open();
};
}

View file

@ -1,3 +0,0 @@
import { instead } from "@lib/api/patcher";
export const noop = (prop: string, parent: any) => instead(prop, parent, () => {});

View file

@ -5,7 +5,13 @@ import { installTheme, removeTheme, themes } from "@lib/addons/themes";
import { findAssetId } from "@lib/api/assets"; import { findAssetId } from "@lib/api/assets";
import { isThemeSupported } from "@lib/api/native/loader"; import { isThemeSupported } from "@lib/api/native/loader";
import { after } from "@lib/api/patcher"; import { after } from "@lib/api/patcher";
import { HTTP_REGEX_MULTI, VD_DISCORD_SERVER_ID, VD_PLUGINS_CHANNEL_ID, VD_PROXY_PREFIX, VD_THEMES_CHANNEL_ID } from "@lib/utils/constants"; import {
HTTP_REGEX_MULTI,
VD_DISCORD_SERVER_ID,
VD_PLUGINS_CHANNEL_ID,
VD_PROXY_PREFIX,
VD_THEMES_CHANNEL_ID,
} from "@lib/utils/constants";
import { lazyDestructure } from "@lib/utils/lazy"; import { lazyDestructure } from "@lib/utils/lazy";
import { Button } from "@metro/common/components"; import { Button } from "@metro/common/components";
import { findByProps, findByPropsLazy } from "@metro/wrappers"; import { findByProps, findByPropsLazy } from "@metro/wrappers";
@ -16,7 +22,9 @@ type PostType = "Plugin" | "Theme";
// const ForumPostLongPressActionSheet = findByNameProxy("ForumPostLongPressActionSheet", false); // const ForumPostLongPressActionSheet = findByNameProxy("ForumPostLongPressActionSheet", false);
// const { ActionSheetRow } = findByPropsProxy("ActionSheetRow"); // const { ActionSheetRow } = findByPropsProxy("ActionSheetRow");
const { useFirstForumPostMessage } = lazyDestructure(() => findByProps("useFirstForumPostMessage")); const { useFirstForumPostMessage } = lazyDestructure(() =>
findByProps("useFirstForumPostMessage"),
);
const forumReactions = findByPropsLazy("MostCommonForumPostReaction"); const forumReactions = findByPropsLazy("MostCommonForumPostReaction");
const postMap = { const postMap = {
@ -25,8 +33,10 @@ const postMap = {
urlsFilter: (url: string) => url.startsWith(VD_PROXY_PREFIX), urlsFilter: (url: string) => url.startsWith(VD_PROXY_PREFIX),
installOrRemove: (url: string) => { installOrRemove: (url: string) => {
const isInstalled = postMap.Plugin.storage[url]; const isInstalled = postMap.Plugin.storage[url];
return isInstalled ? VdPluginManager.removePlugin(url) : VdPluginManager.installPlugin(url); return isInstalled
} ? VdPluginManager.removePlugin(url)
: VdPluginManager.installPlugin(url);
},
}, },
Theme: { Theme: {
storage: themes, storage: themes,
@ -35,10 +45,14 @@ const postMap = {
const isInstalled = postMap.Theme.storage[url]; const isInstalled = postMap.Theme.storage[url];
return isInstalled ? removeTheme(url) : installTheme(url); return isInstalled ? removeTheme(url) : installTheme(url);
}, },
} },
}; };
function useExtractThreadContent(thread: any, _firstMessage = null, actionSheet = false): ([PostType, string]) | void { function useExtractThreadContent(
thread: any,
_firstMessage = null,
actionSheet = false,
): [PostType, string] | void {
if (thread.guild_id !== VD_DISCORD_SERVER_ID) return; if (thread.guild_id !== VD_DISCORD_SERVER_ID) return;
// Determine what type of addon this is. // Determine what type of addon this is.
@ -49,9 +63,13 @@ function useExtractThreadContent(thread: any, _firstMessage = null, actionSheet
postType = "Theme"; postType = "Theme";
} else return; } else return;
const { firstMessage } = actionSheet ? useFirstForumPostMessage(thread) : { firstMessage: _firstMessage }; const { firstMessage } = actionSheet
? useFirstForumPostMessage(thread)
: { firstMessage: _firstMessage };
const urls = firstMessage?.content?.match(HTTP_REGEX_MULTI)?.filter(postMap[postType].urlsFilter); const urls = firstMessage?.content
?.match(HTTP_REGEX_MULTI)
?.filter(postMap[postType].urlsFilter);
if (!urls || !urls[0]) return; if (!urls || !urls[0]) return;
// Sync with lib/managers/plugins // Sync with lib/managers/plugins
@ -60,8 +78,13 @@ function useExtractThreadContent(thread: any, _firstMessage = null, actionSheet
return [postType, urls[0]]; return [postType, urls[0]];
} }
function useInstaller(thread: any, firstMessage = null, actionSheet = false): [true] | [false, PostType, boolean, boolean, () => Promise<void>] { function useInstaller(
const [postType, url] = useExtractThreadContent(thread, firstMessage, actionSheet) ?? []; thread: any,
firstMessage = null,
actionSheet = false,
): [true] | [false, PostType, boolean, boolean, () => Promise<void>] {
const [postType, url] =
useExtractThreadContent(thread, firstMessage, actionSheet) ?? [];
useProxy(VdPluginManager.plugins); useProxy(VdPluginManager.plugins);
useProxy(themes); useProxy(themes);
@ -104,11 +127,17 @@ function useInstaller(thread: any, firstMessage = null, actionSheet = false): [t
// </ActionsSection>); // </ActionsSection>);
// }); // });
const installButtonPatch = () => after("MostCommonForumPostReaction", forumReactions, ([{ thread, firstMessage }], res) => { const installButtonPatch = () =>
const [shouldReturn, _, installed, loading, installOrRemove] = useInstaller(thread, firstMessage, true); after(
"MostCommonForumPostReaction",
forumReactions,
([{ thread, firstMessage }], res) => {
const [shouldReturn, _, installed, loading, installOrRemove] =
useInstaller(thread, firstMessage, true);
if (shouldReturn) return; if (shouldReturn) return;
return <> return (
<>
{res} {res}
<ErrorBoundary> <ErrorBoundary>
<Button <Button
@ -119,18 +148,22 @@ const installButtonPatch = () => after("MostCommonForumPostReaction", forumReact
variant={installed ? "secondary" : "primary"} variant={installed ? "secondary" : "primary"}
text={installed ? Strings.UNINSTALL : Strings.INSTALL} text={installed ? Strings.UNINSTALL : Strings.INSTALL}
onPress={installOrRemove} onPress={installOrRemove}
icon={findAssetId(installed ? "ic_message_delete" : "DownloadIcon")} icon={findAssetId(
installed ? "ic_message_delete" : "DownloadIcon",
)}
style={{ marginLeft: 8 }} style={{ marginLeft: 8 }}
/> />
</ErrorBoundary> </ErrorBoundary>
</>; </>
}); );
},
);
export default () => { export default () => {
const patches = [ const patches = [
// actionSheetPatch(), // actionSheetPatch(),
installButtonPatch() installButtonPatch(),
]; ];
return () => patches.map(p => p()); return () => patches.map((p) => p());
}; };

View file

@ -14,13 +14,13 @@ export default defineCorePlugin({
display: { display: {
name: "QuickInstall", name: "QuickInstall",
description: "Quickly install Vendetta plugins and themes", description: "Quickly install Vendetta plugins and themes",
authors: [{ name: "Vendetta Team" }] authors: [{ name: "Vendetta Team" }],
} },
}, },
start() { start() {
patches = [patchForumPost(), patchUrl()]; patches = [patchForumPost(), patchUrl()];
}, },
stop() { stop() {
patches.forEach(p => p()); patches.forEach((p) => p());
} },
}); });

View file

@ -14,7 +14,9 @@ import { findByProps, findByPropsLazy } from "@metro/wrappers";
import { showToast } from "@ui/toasts"; import { showToast } from "@ui/toasts";
import { Linking } from "react-native"; import { Linking } from "react-native";
const showSimpleActionSheet = findExports(byMutableProp("showSimpleActionSheet")); const showSimpleActionSheet = findExports(
byMutableProp("showSimpleActionSheet"),
);
const handleClick = findByPropsLazy("handleClick"); const handleClick = findByPropsLazy("handleClick");
const { getChannelId } = lazyDestructure(() => channels); const { getChannelId } = lazyDestructure(() => channels);
const { getChannel } = lazyDestructure(() => findByProps("getChannel")); const { getChannel } = lazyDestructure(() => findByProps("getChannel"));
@ -28,7 +30,9 @@ function typeFromUrl(url: string) {
} }
function installWithToast(type: "plugin" | "theme", url: string) { function installWithToast(type: "plugin" | "theme", url: string) {
(type === "plugin" ? VdPluginManager.installPlugin.bind(VdPluginManager) : installTheme)(url) (type === "plugin"
? VdPluginManager.installPlugin.bind(VdPluginManager)
: installTheme)(url)
.then(() => { .then(() => {
showToast(Strings.SUCCESSFULLY_INSTALLED, findAssetId("Check")); showToast(Strings.SUCCESSFULLY_INSTALLED, findAssetId("Check"));
}) })
@ -41,7 +45,7 @@ export default () => {
const patches = new Array<Function>(); const patches = new Array<Function>();
patches.push( patches.push(
after("showSimpleActionSheet", showSimpleActionSheet, args => { after("showSimpleActionSheet", showSimpleActionSheet, (args) => {
if (args[0].key !== "LongPressUrl") return; if (args[0].key !== "LongPressUrl") return;
const { const {
header: { title: url }, header: { title: url },
@ -55,7 +59,7 @@ export default () => {
label: Strings.INSTALL_ADDON, label: Strings.INSTALL_ADDON,
onPress: () => installWithToast(urlType, url), onPress: () => installWithToast(urlType, url),
}); });
}) }),
); );
patches.push( patches.push(
@ -66,7 +70,11 @@ export default () => {
if (!urlType) return orig.apply(this, args); if (!urlType) return orig.apply(this, args);
// Make clicking on theme links only work in #themes, should there be a theme proxy in the future, this can be removed. // Make clicking on theme links only work in #themes, should there be a theme proxy in the future, this can be removed.
if (urlType === "theme" && getChannel(getChannelId())?.parent_id !== VD_THEMES_CHANNEL_ID) return orig.apply(this, args); if (
urlType === "theme" &&
getChannel(getChannelId())?.parent_id !== VD_THEMES_CHANNEL_ID
)
return orig.apply(this, args);
showConfirmationAlert({ showConfirmationAlert({
title: Strings.HOLD_UP, title: Strings.HOLD_UP,
@ -77,8 +85,8 @@ export default () => {
secondaryConfirmText: Strings.OPEN_IN_BROWSER, secondaryConfirmText: Strings.OPEN_IN_BROWSER,
onConfirmSecondary: () => Linking.openURL(url), onConfirmSecondary: () => Linking.openURL(url),
}); });
}) }),
); );
return () => patches.forEach(p => p()); return () => patches.forEach((p) => p());
}; };

View file

@ -1,20 +1,32 @@
import { findAssetId } from "@lib/api/assets"; import { findAssetId } from "@lib/api/assets";
import { lazyDestructure } from "@lib/utils/lazy"; import { lazyDestructure } from "@lib/utils/lazy";
import { Card, FormRadio, FormSwitch, IconButton, LegacyFormRow, Stack, Text } from "@metro/common/components"; import {
Card,
FormRadio,
FormSwitch,
IconButton,
LegacyFormRow,
Stack,
Text,
} from "@metro/common/components";
import { findByProps } from "@metro/wrappers"; import { findByProps } from "@metro/wrappers";
import { semanticColors } from "@ui/color"; import { semanticColors } from "@ui/color";
import { createStyles, TextStyleSheet } from "@ui/styles"; import { createStyles, TextStyleSheet } from "@ui/styles";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
const { hideActionSheet } = lazyDestructure(() => findByProps("openLazy", "hideActionSheet")); const { hideActionSheet } = lazyDestructure(() =>
const { showSimpleActionSheet } = lazyDestructure(() => findByProps("showSimpleActionSheet")); findByProps("openLazy", "hideActionSheet"),
);
const { showSimpleActionSheet } = lazyDestructure(() =>
findByProps("showSimpleActionSheet"),
);
// TODO: These styles work weirdly. Low DPI Has cramped text. Fix? // TODO: These styles work weirdly. Low DPI Has cramped text. Fix?
const useStyles = createStyles({ const useStyles = createStyles({
card: { card: {
backgroundColor: semanticColors?.CARD_SECONDARY_BG, backgroundColor: semanticColors?.CARD_SECONDARY_BG,
borderRadius: 12, borderRadius: 12,
overflow: "hidden" overflow: "hidden",
}, },
header: { header: {
padding: 0, padding: 0,
@ -22,13 +34,13 @@ const useStyles = createStyles({
headerLeading: { headerLeading: {
flexDirection: "column", flexDirection: "column",
justifyContent: "center", justifyContent: "center",
scale: 1.2 scale: 1.2,
}, },
headerTrailing: { headerTrailing: {
display: "flex", display: "flex",
flexDirection: "row", flexDirection: "row",
gap: 15, gap: 15,
alignItems: "center" alignItems: "center",
}, },
headerLabel: { headerLabel: {
...TextStyleSheet["heading-md/semibold"], ...TextStyleSheet["heading-md/semibold"],
@ -45,7 +57,7 @@ const useStyles = createStyles({
actions: { actions: {
flexDirection: "row-reverse", flexDirection: "row-reverse",
alignItems: "center", alignItems: "center",
gap: 5 gap: 5,
}, },
iconStyle: { iconStyle: {
tintColor: semanticColors.LOGO_PRIMARY, tintColor: semanticColors.LOGO_PRIMARY,
@ -54,8 +66,8 @@ const useStyles = createStyles({
width: 64, width: 64,
left: void 0, left: void 0,
right: "30%", right: "30%",
top: "-10%" top: "-10%",
} },
}); });
interface Action { interface Action {
@ -103,24 +115,31 @@ export default function AddonCard(props: CardProps) {
</View> </View>
<View style={[styles.headerTrailing, { marginLeft: "auto" }]}> <View style={[styles.headerTrailing, { marginLeft: "auto" }]}>
<View style={styles.actions}> <View style={styles.actions}>
{props.overflowActions && {props.overflowActions && (
<IconButton <IconButton
onPress={() => showSimpleActionSheet({ onPress={() =>
showSimpleActionSheet({
key: "CardOverflow", key: "CardOverflow",
header: { header: {
title: props.overflowTitle, title: props.overflowTitle,
icon: props.headerIcon && <LegacyFormRow.Icon style={{ marginRight: 8 }} source={findAssetId(props.headerIcon)} />, icon: props.headerIcon && (
onClose: () => hideActionSheet(), <LegacyFormRow.Icon
style={{ marginRight: 8 }}
source={findAssetId(props.headerIcon)}
/>
),
}, },
options: props.overflowActions?.map(i => ({ options: props.overflowActions?.map((i) => ({
...i, ...i,
icon: findAssetId(i.icon) icon: findAssetId(i.icon),
})), })),
})} })
}
size="sm" size="sm"
variant="secondary" variant="secondary"
icon={findAssetId("CircleInformationIcon-primary")} icon={findAssetId("CircleInformationIcon-primary")}
/>} />
)}
{props.actions?.map(({ icon, onPress, disabled }) => ( {props.actions?.map(({ icon, onPress, disabled }) => (
<IconButton <IconButton
onPress={onPress} onPress={onPress}
@ -131,23 +150,26 @@ export default function AddonCard(props: CardProps) {
/> />
))} ))}
</View> </View>
{props.toggleType && (props.toggleType === "switch" ? {props.toggleType &&
(props.toggleType === "switch" ? (
<FormSwitch <FormSwitch
value={props.toggleValue()} value={props.toggleValue()}
onValueChange={props.onToggleChange} onValueChange={props.onToggleChange}
/> />
: ) : (
<TouchableOpacity onPress={() => { <TouchableOpacity
onPress={() => {
props.onToggleChange?.(!props.toggleValue()); props.onToggleChange?.(!props.toggleValue());
}}> }}
>
<FormRadio selected={props.toggleValue()} /> <FormRadio selected={props.toggleValue()} />
</TouchableOpacity> </TouchableOpacity>
))}
</View>
</View>
{props.descriptionLabel && (
<Text variant="text-md/medium">{props.descriptionLabel}</Text>
)} )}
</View>
</View>
{props.descriptionLabel && <Text variant="text-md/medium">
{props.descriptionLabel}
</Text>}
</Stack> </Stack>
</Card> </Card>
); );

View file

@ -7,14 +7,34 @@ import isValidHttpUrl from "@lib/utils/isValidHttpUrl";
import { lazyDestructure, proxyLazy } from "@lib/utils/lazy"; import { lazyDestructure, proxyLazy } from "@lib/utils/lazy";
import { findByProps } from "@metro"; import { findByProps } from "@metro";
import { clipboard, NavigationNative } from "@metro/common"; import { clipboard, NavigationNative } from "@metro/common";
import { AlertActionButton, AlertModal, Button, FlashList, FloatingActionButton, HelpMessage, IconButton, Stack, Text, TextInput, useSafeAreaInsets } from "@metro/common/components"; import {
AlertActionButton,
AlertModal,
Button,
FlashList,
FloatingActionButton,
HelpMessage,
IconButton,
Stack,
Text,
TextInput,
useSafeAreaInsets,
} from "@metro/common/components";
import { ErrorBoundary, Search } from "@ui/components"; import { ErrorBoundary, Search } from "@ui/components";
import { isNotNil } from "es-toolkit"; import { isNotNil } from "es-toolkit";
import fuzzysort from "fuzzysort"; import fuzzysort from "fuzzysort";
import { ComponentType, ReactNode, useCallback, useEffect, useMemo } from "react"; import {
ComponentType,
ReactNode,
useCallback,
useEffect,
useMemo,
} from "react";
import { Image, ScrollView, View } from "react-native"; import { Image, ScrollView, View } from "react-native";
const { showSimpleActionSheet, hideActionSheet } = lazyDestructure(() => findByProps("showSimpleActionSheet")); const { showSimpleActionSheet, hideActionSheet } = lazyDestructure(() =>
findByProps("showSimpleActionSheet"),
);
type SearchKeywords<T> = Array<string | ((obj: T & {}) => string)>; type SearchKeywords<T> = Array<string | ((obj: T & {}) => string)>;
@ -42,7 +62,10 @@ interface AddonPageProps<T extends object, I = any> {
ListFooterComponent?: ComponentType<any>; ListFooterComponent?: ComponentType<any>;
} }
function InputAlert(props: { label: string, fetchFn: (url: string) => Promise<void>; }) { function InputAlert(props: {
label: string;
fetchFn: (url: string) => Promise<void>;
}) {
const [value, setValue] = React.useState(""); const [value, setValue] = React.useState("");
const [error, setError] = React.useState(""); const [error, setError] = React.useState("");
const [isFetching, setIsFetching] = React.useState(false); const [isFetching, setIsFetching] = React.useState(false);
@ -50,13 +73,17 @@ function InputAlert(props: { label: string, fetchFn: (url: string) => Promise<vo
function onConfirmWrapper() { function onConfirmWrapper() {
setIsFetching(true); setIsFetching(true);
props.fetchFn(value) props
.fetchFn(value)
.then(() => dismissAlert("AddonInputAlert")) .then(() => dismissAlert("AddonInputAlert"))
.catch((e: unknown) => e instanceof Error ? setError(e.message) : String(e)) .catch((e: unknown) =>
e instanceof Error ? setError(e.message) : String(e),
)
.finally(() => setIsFetching(false)); .finally(() => setIsFetching(false));
} }
return <AlertModal return (
<AlertModal
title={props.label} title={props.label}
content="Type in the source URL you want to install from:" content="Type in the source URL you want to install from:"
extraContent={ extraContent={
@ -84,7 +111,9 @@ function InputAlert(props: { label: string, fetchFn: (url: string) => Promise<vo
variant="tertiary" variant="tertiary"
text="Import from clipboard" text="Import from clipboard"
icon={findAssetId("ClipboardListIcon")} icon={findAssetId("ClipboardListIcon")}
onPress={() => clipboard.getString().then((str: string) => setValue(str))} onPress={() =>
clipboard.getString().then((str: string) => setValue(str))
}
/> />
</ScrollView> </ScrollView>
</Stack> </Stack>
@ -106,49 +135,74 @@ function InputAlert(props: { label: string, fetchFn: (url: string) => Promise<vo
/> />
</Stack> </Stack>
} }
/>; />
);
} }
export default function AddonPage<T extends object>({ CardComponent, ...props }: AddonPageProps<T>) { export default function AddonPage<T extends object>({
CardComponent,
...props
}: AddonPageProps<T>) {
const [search, setSearch] = React.useState(""); const [search, setSearch] = React.useState("");
const [sortFn, setSortFn] = React.useState<((a: T, b: T) => number) | null>(() => null); const [sortFn, setSortFn] = React.useState<((a: T, b: T) => number) | null>(
() => null,
);
const { bottom: bottomInset } = useSafeAreaInsets(); const { bottom: bottomInset } = useSafeAreaInsets();
const navigation = NavigationNative.useNavigation(); const navigation = NavigationNative.useNavigation();
useEffect(() => { useEffect(() => {
if (props.OptionsActionSheetComponent) { if (props.OptionsActionSheetComponent) {
navigation.setOptions({ navigation.setOptions({
headerRight: () => <IconButton headerRight: () => (
<IconButton
size="sm" size="sm"
variant="secondary" variant="secondary"
icon={findAssetId("MoreHorizontalIcon")} icon={findAssetId("MoreHorizontalIcon")}
onPress={() => showSheet("AddonMoreSheet", props.OptionsActionSheetComponent!)} onPress={() =>
showSheet("AddonMoreSheet", props.OptionsActionSheetComponent!)
}
/> />
),
}); });
} }
}, [navigation]); }, [navigation]);
const results = useMemo(() => { const results = useMemo(() => {
let values = props.items; let values = props.items;
if (props.resolveItem) values = values.map(props.resolveItem).filter(isNotNil); if (props.resolveItem)
const items = values.filter(i => isNotNil(i) && typeof i === "object"); values = values.map(props.resolveItem).filter(isNotNil);
const items = values.filter((i) => isNotNil(i) && typeof i === "object");
if (!search && sortFn) items.sort(sortFn); if (!search && sortFn) items.sort(sortFn);
return fuzzysort.go(search, items, { keys: props.searchKeywords, all: true }); return fuzzysort.go(search, items, {
keys: props.searchKeywords,
all: true,
});
}, [props.items, sortFn, search]); }, [props.items, sortFn, search]);
const onInstallPress = useCallback(() => { const onInstallPress = useCallback(() => {
if (!props.installAction) return () => {}; if (!props.installAction) return () => {};
const { label, onPress, fetchFn } = props.installAction; const { label, onPress, fetchFn } = props.installAction;
if (fetchFn) { if (fetchFn) {
openAlert("AddonInputAlert", <InputAlert label={label ?? "Install"} fetchFn={fetchFn} />); openAlert(
"AddonInputAlert",
<InputAlert label={label ?? "Install"} fetchFn={fetchFn} />,
);
} else { } else {
onPress?.(); onPress?.();
} }
}, []); }, []);
if (results.length === 0 && !search) { if (results.length === 0 && !search) {
return <View style={{ gap: 32, flexGrow: 1, justifyContent: "center", alignItems: "center" }}> return (
<View
style={{
gap: 32,
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<View style={{ gap: 8, alignItems: "center" }}> <View style={{ gap: 8, alignItems: "center" }}>
<Image source={findAssetId("empty_quick_switcher")!} /> <Image source={findAssetId("empty_quick_switcher")!} />
<Text variant="text-lg/semibold" color="text-normal"> <Text variant="text-lg/semibold" color="text-normal">
@ -161,35 +215,48 @@ export default function AddonPage<T extends object>({ CardComponent, ...props }:
text={props.installAction?.label ?? "Install"} text={props.installAction?.label ?? "Install"}
onPress={onInstallPress} onPress={onInstallPress}
/> />
</View>; </View>
);
} }
const headerElement = ( const headerElement = (
<View style={{ paddingBottom: 8 }}> <View style={{ paddingBottom: 8 }}>
{settings.safeMode?.enabled && <View style={{ marginBottom: 10 }}> {settings.safeMode?.enabled && (
<View style={{ marginBottom: 10 }}>
<HelpMessage messageType={0}> <HelpMessage messageType={0}>
{props.safeModeHint?.message} {props.safeModeHint?.message}
</HelpMessage> </HelpMessage>
{props.safeModeHint?.footer} {props.safeModeHint?.footer}
</View>} </View>
)}
<View style={{ flexDirection: "row", gap: 8 }}> <View style={{ flexDirection: "row", gap: 8 }}>
<Search style={{ flexGrow: 1 }} isRound={!!props.sortOptions} onChangeText={v => setSearch(v)} /> <Search
{props.sortOptions && <IconButton style={{ flexGrow: 1 }}
isRound={!!props.sortOptions}
onChangeText={(v) => setSearch(v)}
/>
{props.sortOptions && (
<IconButton
icon={findAssetId("MoreVerticalIcon")} icon={findAssetId("MoreVerticalIcon")}
variant="tertiary" variant="tertiary"
disabled={!!search} disabled={!!search}
onPress={() => showSimpleActionSheet({ onPress={() =>
showSimpleActionSheet({
key: "AddonListSortOptions", key: "AddonListSortOptions",
header: { header: {
title: "Sort Options", title: "Sort Options",
onClose: () => hideActionSheet("AddonListSortOptions"),
}, },
options: Object.entries(props.sortOptions!).map(([name, fn]) => ({ options: Object.entries(props.sortOptions!).map(
([name, fn]) => ({
label: name, label: name,
onPress: () => setSortFn(() => fn) onPress: () => setSortFn(() => fn),
})) }),
})} ),
/>} })
}
/>
)}
</View> </View>
{props.ListHeaderComponent && <props.ListHeaderComponent />} {props.ListHeaderComponent && <props.ListHeaderComponent />}
</View> </View>
@ -202,22 +269,32 @@ export default function AddonPage<T extends object>({ CardComponent, ...props }:
extraData={search} extraData={search}
estimatedItemSize={136} estimatedItemSize={136}
ListHeaderComponent={headerElement} ListHeaderComponent={headerElement}
ListEmptyComponent={() => <View style={{ gap: 12, padding: 12, alignItems: "center" }}> ListEmptyComponent={() => (
<View style={{ gap: 12, padding: 12, alignItems: "center" }}>
<Image source={findAssetId("devices_not_found")!} /> <Image source={findAssetId("devices_not_found")!} />
<Text variant="text-lg/semibold" color="text-normal"> <Text variant="text-lg/semibold" color="text-normal">
Hmmm... could not find that! Hmmm... could not find that!
</Text> </Text>
</View>} </View>
contentContainerStyle={{ padding: 8, paddingHorizontal: 12, paddingBottom: 90 }} )}
contentContainerStyle={{
padding: 8,
paddingHorizontal: 12,
paddingBottom: 90,
}}
ItemSeparatorComponent={() => <View style={{ height: 8 }} />} ItemSeparatorComponent={() => <View style={{ height: 8 }} />}
ListFooterComponent={props.ListFooterComponent} ListFooterComponent={props.ListFooterComponent}
renderItem={({ item }: any) => <CardComponent item={item.obj} result={item} />} renderItem={({ item }: any) => (
<CardComponent item={item.obj} result={item} />
)}
/> />
{props.installAction && <FloatingActionButton {props.installAction && (
<FloatingActionButton
positionBottom={bottomInset + 8} positionBottom={bottomInset + 8}
icon={findAssetId("PlusLargeIcon")} icon={findAssetId("PlusLargeIcon")}
onPress={onInstallPress} onPress={onInstallPress}
/>} />
)}
</ErrorBoundary> </ErrorBoundary>
); );
} }

View file

@ -5,7 +5,13 @@ import { settings } from "@lib/api/settings";
import { Codeblock, ErrorBoundary } from "@lib/ui/components"; import { Codeblock, ErrorBoundary } from "@lib/ui/components";
import { createStyles } from "@lib/ui/styles"; import { createStyles } from "@lib/ui/styles";
import { tokens } from "@metro/common"; import { tokens } from "@metro/common";
import { Button, Card, SafeAreaProvider, SafeAreaView, Text } from "@metro/common/components"; import {
Button,
Card,
SafeAreaProvider,
SafeAreaView,
Text,
} from "@metro/common/components";
import { ScrollView, View } from "react-native"; import { ScrollView, View } from "react-native";
import ErrorComponentStackCard from "./ErrorComponentStackCard"; import ErrorComponentStackCard from "./ErrorComponentStackCard";
@ -17,8 +23,8 @@ const useStyles = createStyles({
backgroundColor: tokens.colors.BG_BASE_SECONDARY, backgroundColor: tokens.colors.BG_BASE_SECONDARY,
paddingHorizontal: 16, paddingHorizontal: 16,
height: "100%", height: "100%",
gap: 12 gap: 12,
} },
}); });
export default function ErrorBoundaryScreen(props: { export default function ErrorBoundaryScreen(props: {
@ -28,25 +34,49 @@ export default function ErrorBoundaryScreen(props: {
const styles = useStyles(); const styles = useStyles();
const debugInfo = getDebugInfo(); const debugInfo = getDebugInfo();
return <ErrorBoundary> return (
<ErrorBoundary>
<SafeAreaProvider> <SafeAreaProvider>
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<View style={{ gap: 4 }}> <View style={{ gap: 4 }}>
<Text variant="display-lg">Uh oh.</Text> <Text variant="display-lg">Uh oh.</Text>
<Text variant="text-md/normal">A crash occurred while rendering a component. This could be caused by a plugin, Kettu, or Discord itself.</Text> <Text variant="text-md/normal">
<Text variant="text-sm/normal" color="text-muted">{debugInfo.os.name}; {debugInfo.discord.build} ({debugInfo.discord.version}); {debugInfo.bunny.version}</Text> A crash occurred while rendering a component. This could be caused
by a plugin, ShiggyCord, or Discord itself.
</Text>
<Text variant="text-sm/normal" color="text-muted">
{debugInfo.os.name}; {debugInfo.discord.build} (
{debugInfo.discord.version}); {debugInfo.bunny.version}
</Text>
</View> </View>
<ScrollView fadingEdgeLength={64} contentContainerStyle={{ gap: 12 }}> <ScrollView fadingEdgeLength={64} contentContainerStyle={{ gap: 12 }}>
<Codeblock selectable={true}>{props.error.message}</Codeblock> <Codeblock selectable={true}>{props.error.message}</Codeblock>
{hasStack(props.error) && <ErrorStackCard error={props.error} />} {hasStack(props.error) && <ErrorStackCard error={props.error} />}
{isComponentStack(props.error) ? <ErrorComponentStackCard componentStack={props.error.componentStack} /> : null} {isComponentStack(props.error) ? (
<ErrorComponentStackCard
componentStack={props.error.componentStack}
/>
) : null}
</ScrollView> </ScrollView>
<Card style={{ gap: 6 }}> <Card style={{ gap: 6 }}>
<Button text="Reload Discord" onPress={() => BundleUpdaterManager.reload()} /> <Button
{!settings.safeMode?.enabled && <Button text="Reload in Safe Mode" onPress={() => toggleSafeMode()} />} text="Reload Discord"
<Button variant="destructive" text="Retry Render" onPress={() => props.rerender()} /> onPress={() => BundleUpdaterManager.reload()}
/>
{!settings.safeMode?.enabled && (
<Button
text="Reload in Safe Mode"
onPress={() => toggleSafeMode()}
/>
)}
<Button
variant="destructive"
text="Retry Render"
onPress={() => props.rerender()}
/>
</Card> </Card>
</SafeAreaView> </SafeAreaView>
</SafeAreaProvider> </SafeAreaProvider>
</ErrorBoundary>; </ErrorBoundary>
);
} }

View file

@ -1,4 +1,4 @@
import PupuIcon from "@assets/icons/kettu.png"; import PupuIcon from "@assets/icons/shiggy.png";
import { Strings } from "@core/i18n"; import { Strings } from "@core/i18n";
import { useProxy } from "@core/vendetta/storage"; import { useProxy } from "@core/vendetta/storage";
import { findAssetId } from "@lib/api/assets"; import { findAssetId } from "@lib/api/assets";
@ -10,68 +10,62 @@ import { version } from "bunny-build-info";
export { PupuIcon }; export { PupuIcon };
export default function initSettings() { export default function initSettings() {
registerSection({ registerSection({
name: "Kettu", name: "ShiggyCord",
items: [ items: [
{ {
key: "KETTU", key: "SHIGGYCORD",
title: () => Strings.PUPU, title: () => Strings.PUPU,
icon: { uri: PupuIcon }, icon: { uri: PupuIcon },
render: () => import("@core/ui/settings/pages/General"), render: () => import("@core/ui/settings/pages/General"),
useTrailing: () => `(${version})` useTrailing: () => `(${version})`,
}, },
{ {
key: "BUNNY_PLUGINS", key: "BUNNY_PLUGINS",
title: () => Strings.PLUGINS, title: () => Strings.PLUGINS,
icon: findAssetId("ActivitiesIcon"), icon: findAssetId("ActivitiesIcon"),
render: () => import("@core/ui/settings/pages/Plugins") render: () => import("@core/ui/settings/pages/Plugins"),
}, },
{ {
key: "BUNNY_THEMES", key: "BUNNY_THEMES",
title: () => Strings.THEMES, title: () => Strings.THEMES,
icon: findAssetId("PaintPaletteIcon"), icon: findAssetId("PaintPaletteIcon"),
render: () => import("@core/ui/settings/pages/Themes"), render: () => import("@core/ui/settings/pages/Themes"),
usePredicate: () => isThemeSupported() usePredicate: () => isThemeSupported(),
}, },
{ {
key: "BUNNY_FONTS", key: "BUNNY_FONTS",
title: () => Strings.FONTS, title: () => Strings.FONTS,
icon: findAssetId("ic_add_text"), icon: findAssetId("ic_add_text"),
render: () => import("@core/ui/settings/pages/Fonts"), render: () => import("@core/ui/settings/pages/Fonts"),
usePredicate: () => isFontSupported() usePredicate: () => isFontSupported(),
},
{
key: "KETTU_BROWSER",
title: () => Strings.BROWSER,
icon: findAssetId("UploadIcon"),
render: () => import("@core/ui/settings/pages/PluginBrowser"),
}, },
{ {
key: "BUNNY_DEVELOPER", key: "BUNNY_DEVELOPER",
title: () => Strings.DEVELOPER, title: () => Strings.DEVELOPER,
icon: findAssetId("WrenchIcon"), icon: findAssetId("WrenchIcon"),
render: () => import("@core/ui/settings/pages/Developer"), render: () => import("@core/ui/settings/pages/Developer"),
usePredicate: () => useProxy(settings).developerSettings ?? false usePredicate: () => useProxy(settings).developerSettings ?? false,
} },
] ],
}); });
// Compat with Bunny Plugins that use configs in settings // Compat with Bunny Plugins that use configs in settings
registerSection({ registerSection({
name: "Bunny", name: "Bunny",
items: [] items: [],
}); });
// Compat with Revenge Plugins that use configs in settings // Compat with Revenge Plugins that use configs in settings
registerSection({ registerSection({
name: "Revenge", name: "Revenge",
items: [] items: [],
}); });
// Compat with Vendetta Plugins that use configs in settings // Compat with Vendetta Plugins that use configs in settings
registerSection({ registerSection({
name: "Vendetta", name: "Vendetta",
items: [] items: [],
}); });
} }

View file

@ -4,11 +4,25 @@ import AssetBrowser from "@core/ui/settings/pages/Developer/AssetBrowser";
import { useProxy } from "@core/vendetta/storage"; import { useProxy } from "@core/vendetta/storage";
import { findAssetId } from "@lib/api/assets"; import { findAssetId } from "@lib/api/assets";
import { connectToDebugger } from "@lib/api/debug"; import { connectToDebugger } from "@lib/api/debug";
import { getReactDevToolsProp, getReactDevToolsVersion, isLoaderConfigSupported, isReactDevToolsPreloaded, isVendettaLoader } from "@lib/api/native/loader"; import {
getReactDevToolsProp,
getReactDevToolsVersion,
isLoaderConfigSupported,
isReactDevToolsPreloaded,
isVendettaLoader,
} from "@lib/api/native/loader";
import { loaderConfig, settings } from "@lib/api/settings"; import { loaderConfig, settings } from "@lib/api/settings";
import { lazyDestructure } from "@lib/utils/lazy"; import { lazyDestructure } from "@lib/utils/lazy";
import { NavigationNative } from "@metro/common"; import { NavigationNative } from "@metro/common";
import { Button, LegacyFormText, Stack, TableRow, TableRowGroup, TableSwitchRow, TextInput } from "@metro/common/components"; import {
Button,
LegacyFormText,
Stack,
TableRow,
TableRowGroup,
TableSwitchRow,
TextInput,
} from "@metro/common/components";
import { findByProps } from "@metro/wrappers"; import { findByProps } from "@metro/wrappers";
import { semanticColors } from "@ui/color"; import { semanticColors } from "@ui/color";
import { ErrorBoundary } from "@ui/components"; import { ErrorBoundary } from "@ui/components";
@ -16,10 +30,18 @@ import { createStyles, TextStyleSheet } from "@ui/styles";
import { NativeModules } from "react-native"; import { NativeModules } from "react-native";
import { ScrollView, StyleSheet } from "react-native"; import { ScrollView, StyleSheet } from "react-native";
const { hideActionSheet } = lazyDestructure(() => findByProps("openLazy", "hideActionSheet")); const { hideActionSheet } = lazyDestructure(() =>
const { showSimpleActionSheet } = lazyDestructure(() => findByProps("showSimpleActionSheet")); findByProps("openLazy", "hideActionSheet"),
const { openAlert } = lazyDestructure(() => findByProps("openAlert", "dismissAlert")); );
const { AlertModal, AlertActionButton } = lazyDestructure(() => findByProps("AlertModal", "AlertActions")); const { showSimpleActionSheet } = lazyDestructure(() =>
findByProps("showSimpleActionSheet"),
);
const { openAlert } = lazyDestructure(() =>
findByProps("openAlert", "dismissAlert"),
);
const { AlertModal, AlertActionButton } = lazyDestructure(() =>
findByProps("AlertModal", "AlertActions"),
);
const RDT_EMBED_LINK = ""; const RDT_EMBED_LINK = "";
@ -27,7 +49,7 @@ const useStyles = createStyles({
leadingText: { leadingText: {
...TextStyleSheet["heading-md/semibold"], ...TextStyleSheet["heading-md/semibold"],
color: semanticColors.TEXT_MUTED, color: semanticColors.TEXT_MUTED,
marginRight: -4 marginRight: -4,
}, },
}); });
@ -42,133 +64,31 @@ export default function Developer() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 38 }}> <ScrollView
<Stack style={{ paddingVertical: 24, paddingHorizontal: 12 }} spacing={24}> style={{ flex: 1 }}
contentContainerStyle={{ paddingBottom: 38 }}
>
<Stack
style={{ paddingVertical: 24, paddingHorizontal: 12 }}
spacing={24}
>
<TextInput <TextInput
label={Strings.DEBUGGER_URL} label={Strings.DEBUGGER_URL}
placeholder="127.0.0.1:9090" placeholder="127.0.0.1:9090"
size="md" size="md"
leadingIcon={() => <LegacyFormText style={styles.leadingText}>ws://</LegacyFormText>} leadingIcon={() => (
<LegacyFormText style={styles.leadingText}>ws://</LegacyFormText>
)}
defaultValue={settings.debuggerUrl} defaultValue={settings.debuggerUrl}
onChange={(v: string) => settings.debuggerUrl = v} onChange={(v: string) => (settings.debuggerUrl = v)}
/> />
<TableRowGroup title={Strings.DEBUG}> <TableRowGroup title="Debugging & Connection">
<TableRow <TableRow
label={Strings.CONNECT_TO_DEBUG_WEBSOCKET} label={Strings.CONNECT_TO_DEBUG_WEBSOCKET}
icon={<TableRow.Icon source={findAssetId("copy")} />} subLabel="Connect to Chrome DevTools for debugging"
icon={<TableRow.Icon source={findAssetId("WrenchIcon")} />}
onPress={() => connectToDebugger(settings.debuggerUrl)} onPress={() => connectToDebugger(settings.debuggerUrl)}
/> />
{isReactDevToolsPreloaded() && <>
<TableRow
label={Strings.CONNECT_TO_REACT_DEVTOOLS}
icon={<TableRow.Icon source={findAssetId("ic_badge_staff")} />}
onPress={() => window[getReactDevToolsProp() || "__vendetta_rdc"]?.connectToDevTools({
host: settings.debuggerUrl.split(":")?.[0],
resolveRNStyle: StyleSheet.flatten,
})}
/>
</>}
</TableRowGroup>
{isLoaderConfigSupported() && <>
<TableRowGroup title="Loader config">
<TableSwitchRow
label={Strings.LOAD_FROM_CUSTOM_URL}
subLabel={Strings.LOAD_FROM_CUSTOM_URL_DEC}
icon={<TableRow.Icon source={findAssetId("copy")} />}
value={loaderConfig.customLoadUrl.enabled}
onValueChange={(v: boolean) => {
loaderConfig.customLoadUrl.enabled = v;
}}
/>
{loaderConfig.customLoadUrl.enabled && <TableRow label={<TextInput
defaultValue={loaderConfig.customLoadUrl.url}
size="md"
onChange={(v: string) => loaderConfig.customLoadUrl.url = v}
placeholder="http://localhost:4040/kettu.js"
label={Strings.PUPU_URL}
/>} />}
{isReactDevToolsPreloaded() && isVendettaLoader() && <TableSwitchRow
label={Strings.LOAD_REACT_DEVTOOLS}
subLabel={`${Strings.VERSION}: ${getReactDevToolsVersion()}`}
icon={<TableRow.Icon source={findAssetId("ic_badge_staff")} />}
value={loaderConfig.loadReactDevTools}
onValueChange={(v: boolean) => {
loaderConfig.loadReactDevTools = v;
}}
/>}
</TableRowGroup>
</>}
<TableRowGroup title="Other">
<TableRow
// Thanks for this Revenge team!
label={Strings.CLEAR_BUNDLE}
icon={<TableRow.Icon source={findAssetId("TrashIcon")} />}
onPress={() => {
openAlert("pupu-clear-bundle-reload-confirmation", <AlertModal
title={Strings.MODAL_RELOAD_REQUIRED}
content={Strings.MODAL_RELOAD_REQUIRED_DESC}
actions={
<Stack>
<AlertActionButton text={Strings.RELOAD} variant="destructive" onPress={() => NativeModules.BundleUpdaterManager.reload()} />
<AlertActionButton text={Strings.CANCEL} variant="secondary" />
</Stack>
}
/>);
}}
/>
<TableRow
arrow
label={Strings.ASSET_BROWSER}
icon={<TableRow.Icon source={findAssetId("ic_image")} />}
trailing={TableRow.Arrow}
onPress={() => navigation.push("PUPU_CUSTOM_PAGE", {
title: Strings.ASSET_BROWSER,
render: AssetBrowser,
})}
/>
<TableRow
arrow
label={Strings.ERROR_BOUNDARY_TOOLS_LABEL}
icon={<TableRow.Icon source={findAssetId("ic_warning_24px")} />}
onPress={() => showSimpleActionSheet({
key: "ErrorBoundaryTools",
header: {
title: "Which ErrorBoundary do you want to trip?",
icon: <TableRow.Icon style={{ marginRight: 8 }} source={findAssetId("ic_warning_24px")} />,
onClose: () => hideActionSheet(),
},
options: [
// @ts-expect-error
// Of course, to trigger an error, we need to do something incorrectly. The below will do!
{ label: Strings.PUPU, onPress: () => navigation.push("PUPU_CUSTOM_PAGE", { render: () => <undefined /> }) },
{ label: "Discord", isDestructive: true, onPress: () => navigation.push("PUPU_CUSTOM_PAGE", { noErrorBoundary: true }) },
],
})}
/>
<TableRow
label={Strings.INSTALL_REACT_DEVTOOLS}
subLabel={Strings.RESTART_REQUIRED_TO_TAKE_EFFECT}
icon={<TableRow.Icon source={findAssetId("DownloadIcon")} />}
trailing={
<Button
size="sm"
loading={rdtFileExists === CheckState.LOADING}
disabled={rdtFileExists === CheckState.LOADING}
variant={rdtFileExists === CheckState.TRUE ? "secondary" : "primary"}
text={rdtFileExists === CheckState.TRUE ? Strings.UNINSTALL : Strings.INSTALL}
onPress={async () => {
if (rdtFileExists === CheckState.FALSE) {
fs.downloadFile(RDT_EMBED_LINK, "preloads/reactDevtools.js");
} else if (rdtFileExists === CheckState.TRUE) {
fs.removeFile("preloads/reactDevtools.js");
}
}}
icon={findAssetId(rdtFileExists === CheckState.TRUE ? "ic_message_delete" : "DownloadIcon")}
style={{ marginLeft: 8 }}
/>
}
/>
<TableSwitchRow <TableSwitchRow
label={Strings.ENABLE_EVAL_COMMAND} label={Strings.ENABLE_EVAL_COMMAND}
subLabel={Strings.ENABLE_EVAL_COMMAND_DESC} subLabel={Strings.ENABLE_EVAL_COMMAND_DESC}
@ -179,6 +99,190 @@ export default function Developer() {
}} }}
/> />
</TableRowGroup> </TableRowGroup>
{isReactDevToolsPreloaded() && (
<TableRowGroup title="React Development">
<TableRow
label={Strings.CONNECT_TO_REACT_DEVTOOLS}
subLabel="Connect React DevTools for component debugging"
icon={<TableRow.Icon source={findAssetId("ic_badge_staff")} />}
onPress={() =>
window[
getReactDevToolsProp() || "__vendetta_rdc"
]?.connectToDevTools({
host: settings.debuggerUrl.split(":")?.[0],
resolveRNStyle: StyleSheet.flatten,
})
}
/>
<TableRow
label={Strings.INSTALL_REACT_DEVTOOLS}
subLabel={Strings.RESTART_REQUIRED_TO_TAKE_EFFECT}
icon={<TableRow.Icon source={findAssetId("DownloadIcon")} />}
trailing={
<Button
size="sm"
loading={rdtFileExists === CheckState.LOADING}
disabled={rdtFileExists === CheckState.LOADING}
variant={
rdtFileExists === CheckState.TRUE
? "secondary"
: "primary"
}
text={
rdtFileExists === CheckState.TRUE
? Strings.UNINSTALL
: Strings.INSTALL
}
onPress={async () => {
if (rdtFileExists === CheckState.FALSE) {
fs.downloadFile(
RDT_EMBED_LINK,
"preloads/reactDevtools.js",
);
} else if (rdtFileExists === CheckState.TRUE) {
fs.removeFile("preloads/reactDevtools.js");
}
}}
icon={findAssetId(
rdtFileExists === CheckState.TRUE
? "ic_message_delete"
: "DownloadIcon",
)}
style={{ marginLeft: 8 }}
/>
}
/>
{isLoaderConfigSupported() && isVendettaLoader() && (
<TableSwitchRow
label={Strings.LOAD_REACT_DEVTOOLS}
subLabel={`${Strings.VERSION}: ${getReactDevToolsVersion()}`}
icon={
<TableRow.Icon source={findAssetId("ic_badge_staff")} />
}
value={loaderConfig.loadReactDevTools}
onValueChange={(v: boolean) => {
loaderConfig.loadReactDevTools = v;
}}
/>
)}
</TableRowGroup>
)}
{isLoaderConfigSupported() && (
<TableRowGroup title="Loader Configuration">
<TableSwitchRow
label={Strings.LOAD_FROM_CUSTOM_URL}
subLabel={Strings.LOAD_FROM_CUSTOM_URL_DEC}
icon={<TableRow.Icon source={findAssetId("LinkIcon")} />}
value={loaderConfig.customLoadUrl.enabled}
onValueChange={(v: boolean) => {
loaderConfig.customLoadUrl.enabled = v;
}}
/>
{loaderConfig.customLoadUrl.enabled && (
<TableRow
label={
<TextInput
defaultValue={loaderConfig.customLoadUrl.url}
size="md"
onChange={(v: string) =>
(loaderConfig.customLoadUrl.url = v)
}
placeholder="http://localhost:4040/shiggycord.js"
label={Strings.PUPU_URL}
/>
}
/>
)}
</TableRowGroup>
)}
<TableRowGroup title="Inspection & Testing">
<TableRow
arrow
label={Strings.ASSET_BROWSER}
subLabel="Browse and inspect Discord's assets"
icon={<TableRow.Icon source={findAssetId("ic_image")} />}
trailing={TableRow.Arrow}
onPress={() =>
navigation.push("PUPU_CUSTOM_PAGE", {
title: Strings.ASSET_BROWSER,
render: AssetBrowser,
})
}
/>
<TableRow
arrow
label={Strings.ERROR_BOUNDARY_TOOLS_LABEL}
subLabel="Test error boundaries and crash handling"
icon={<TableRow.Icon source={findAssetId("ic_warning_24px")} />}
onPress={() =>
showSimpleActionSheet({
key: "ErrorBoundaryTools",
header: {
title: "Which ErrorBoundary do you want to trip?",
icon: (
<TableRow.Icon
style={{ marginRight: 8 }}
source={findAssetId("ic_warning_24px")}
/>
),
},
options: [
// @ts-expect-error
// Of course, to trigger an error, we need to do something incorrectly. The below will do!
{
label: Strings.PUPU,
onPress: () =>
navigation.push("SHIGGYCORD_CUSTOM_PAGE", {
render: () => <undefined />,
}),
},
{
label: "Discord",
isDestructive: true,
onPress: () =>
navigation.push("PUPU_CUSTOM_PAGE", {
noErrorBoundary: true,
}),
},
],
})
}
/>
</TableRowGroup>
<TableRowGroup title="Dangerous Actions">
<TableRow
// Thanks for this Revenge team!
label={Strings.CLEAR_BUNDLE}
subLabel="Clear cached bundle and force reload"
icon={<TableRow.Icon source={findAssetId("TrashIcon")} />}
onPress={() => {
openAlert(
"pupu-clear-bundle-reload-confirmation",
<AlertModal
title={Strings.MODAL_RELOAD_REQUIRED}
content={Strings.MODAL_RELOAD_REQUIRED_DESC}
actions={
<Stack>
<AlertActionButton
text={Strings.RELOAD}
variant="destructive"
onPress={() =>
NativeModules.BundleUpdaterManager.reload()
}
/>
<AlertActionButton
text={Strings.CANCEL}
variant="secondary"
/>
</Stack>
}
/>,
);
}}
/>
</TableRowGroup>
</Stack> </Stack>
</ScrollView> </ScrollView>
</ErrorBoundary> </ErrorBoundary>

View file

@ -4,14 +4,15 @@ import Version from "@core/ui/settings/pages/General/Version";
import { useProxy } from "@core/vendetta/storage"; import { useProxy } from "@core/vendetta/storage";
import { getDebugInfo } from "@lib/api/debug"; import { getDebugInfo } from "@lib/api/debug";
import { settings } from "@lib/api/settings"; import { settings } from "@lib/api/settings";
import { Stack, TableRowGroup } from "@metro/common/components"; import { Stack, TableRowGroup, TableRow, Text } from "@metro/common/components";
import { Platform, ScrollView } from "react-native"; import { Platform, ScrollView, View } from "react-native";
import { findAssetId } from "@lib/api/assets";
export default function About() { export default function About() {
const debugInfo = getDebugInfo(); const debugInfo = getDebugInfo();
useProxy(settings); useProxy(settings);
const versions = [ const coreVersions = [
{ {
label: Strings.PUPU, label: Strings.PUPU,
version: debugInfo.bunny.version, version: debugInfo.bunny.version,
@ -22,6 +23,14 @@ export default function About() {
version: `${debugInfo.discord.version} (${debugInfo.discord.build})`, version: `${debugInfo.discord.version} (${debugInfo.discord.build})`,
icon: "Discord", icon: "Discord",
}, },
{
label: Strings.LOADER,
version: `${debugInfo.bunny.loader.name} ${debugInfo.bunny.loader.version}`,
icon: "DownloadIcon",
},
];
const runtimeVersions = [
{ {
label: "React", label: "React",
version: debugInfo.react.version, version: debugInfo.react.version,
@ -39,52 +48,113 @@ export default function About() {
}, },
]; ];
const platformInfo = [ const deviceInfo = [
{
label: Strings.LOADER,
version: `${debugInfo.bunny.loader.name} (${debugInfo.bunny.loader.version})`,
icon: "DownloadIcon",
},
{ {
label: Strings.OPERATING_SYSTEM, label: Strings.OPERATING_SYSTEM,
version: `${debugInfo.os.name} ${debugInfo.os.version}`, version: `${debugInfo.os.name} ${debugInfo.os.version}`,
icon: "ScreenIcon" icon: "ScreenIcon",
}, },
...(debugInfo.os.sdk ? [{ ...(debugInfo.os.sdk
label: "SDK", ? [
{
label: "SDK Version",
version: debugInfo.os.sdk, version: debugInfo.os.sdk,
icon: "StaffBadgeIcon" icon: "StaffBadgeIcon",
}] : []), },
]
: []),
{
label: "Device",
version: `${debugInfo.device.brand} ${debugInfo.device.model}`,
icon: "MobilePhoneIcon",
},
{ {
label: Strings.MANUFACTURER, label: Strings.MANUFACTURER,
version: debugInfo.device.manufacturer, version: debugInfo.device.manufacturer,
icon: "WrenchIcon" icon: "WrenchIcon",
}, },
{
label: Strings.BRAND,
version: debugInfo.device.brand,
icon: "MagicWandIcon"
},
{
label: Strings.MODEL,
version: debugInfo.device.model,
icon: "MobilePhoneIcon"
},
{
label: Platform.select({ android: Strings.CODENAME })!,
version: debugInfo.device.codename,
icon: "TagIcon"
}
]; ];
return ( return (
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 38 }}> <ScrollView
<Stack style={{ paddingVertical: 24, paddingHorizontal: 12 }} spacing={24}> style={{ flex: 1 }}
<TableRowGroup title={Strings.VERSIONS}> contentContainerStyle={{ paddingBottom: 38 }}
{versions.map(v => <Version label={v.label} version={v.version} icon={v.icon} />)} >
<Stack
style={{ paddingVertical: 24, paddingHorizontal: 12 }}
spacing={24}
>
<View style={{ alignItems: "center", paddingVertical: 16 }}>
<Text variant="heading-xl/bold" style={{ textAlign: "center" }}>
{Strings.PUPU}
</Text>
<Text
variant="text-md/medium"
style={{ textAlign: "center", marginTop: 4 }}
>
Shiggy your discord client!
</Text>
</View>
<TableRowGroup title="Core Components">
{coreVersions.map((v, i) => (
<Version
key={i}
label={v.label}
version={v.version}
icon={v.icon}
/>
))}
</TableRowGroup> </TableRowGroup>
<TableRowGroup title={Strings.PLATFORM}>
{platformInfo.map(p => <Version label={p.label} version={p.version} icon={p.icon} />)} <TableRowGroup title="Runtime Environment">
{runtimeVersions.map((v, i) => (
<Version
key={i}
label={v.label}
version={v.version}
icon={v.icon}
/>
))}
</TableRowGroup>
<TableRowGroup title="Device Information">
{deviceInfo.map((p, i) => (
<Version
key={i}
label={p.label}
version={p.version}
icon={p.icon}
/>
))}
</TableRowGroup>
<TableRowGroup title="Additional Details">
<TableRow
label="Architecture"
trailing={
<TableRow.TrailingText
text={Platform.OS === "android" ? "Android ARM64" : "iOS ARM64"}
/>
}
icon={<TableRow.Icon source={findAssetId("ScreenIcon")} />}
/>
<TableRow
label="Debug Mode"
trailing={
<TableRow.TrailingText text={__DEV__ ? "Enabled" : "Disabled"} />
}
icon={<TableRow.Icon source={findAssetId("WrenchIcon")} />}
/>
<TableRow
label="Build Type"
trailing={
<TableRow.TrailingText
text={settings.developerSettings ? "Development" : "Production"}
/>
}
icon={<TableRow.Icon source={findAssetId("HammerIcon")} />}
/>
</TableRowGroup> </TableRowGroup>
</Stack> </Stack>
</ScrollView> </ScrollView>

View file

@ -8,9 +8,17 @@ import { getDebugInfo } from "@lib/api/debug";
import { BundleUpdaterManager } from "@lib/api/native/modules"; import { BundleUpdaterManager } from "@lib/api/native/modules";
import { settings } from "@lib/api/settings"; import { settings } from "@lib/api/settings";
import { openAlert } from "@lib/ui/alerts"; import { openAlert } from "@lib/ui/alerts";
import { DISCORD_SERVER, GITHUB, CODEBERG } from "@lib/utils/constants"; import { DISCORD_SERVER, GITHUB } from "@lib/utils/constants";
import { NavigationNative } from "@metro/common"; import { NavigationNative } from "@metro/common";
import { AlertActionButton, AlertActions, AlertModal, Stack, TableRow, TableRowGroup, TableSwitchRow } from "@metro/common/components"; import {
AlertActionButton,
AlertActions,
AlertModal,
Stack,
TableRow,
TableRowGroup,
TableSwitchRow,
} from "@metro/common/components";
import { Linking, ScrollView } from "react-native"; import { Linking, ScrollView } from "react-native";
import CodebergIcon from "@assets/icons/codeberg-logo_icon_white.png"; import CodebergIcon from "@assets/icons/codeberg-logo_icon_white.png";
@ -21,58 +29,66 @@ export default function General() {
const navigation = NavigationNative.useNavigation(); const navigation = NavigationNative.useNavigation();
return ( return (
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 38 }}> <ScrollView
<Stack style={{ paddingVertical: 24, paddingHorizontal: 12 }} spacing={24}> style={{ flex: 1 }}
<TableRowGroup title={Strings.INFO}> contentContainerStyle={{ paddingBottom: 38 }}
>
<Stack
style={{ paddingVertical: 24, paddingHorizontal: 12 }}
spacing={24}
>
<TableRowGroup title="App Information">
<TableRow <TableRow
label={Strings.PUPU} label={Strings.PUPU}
subLabel={`Build ${debugInfo.bunny.version.split("-")[0] || debugInfo.bunny.version}`}
icon={<TableRow.Icon source={{ uri: PupuIcon }} />} icon={<TableRow.Icon source={{ uri: PupuIcon }} />}
trailing={<TableRow.TrailingText text={debugInfo.bunny.version} />} trailing={
<TableRow.TrailingText
text={
debugInfo.bunny.version.includes("-")
? `#${debugInfo.bunny.version.split("-").slice(1).join("-")}`
: "ShiggyCord"
}
/>
}
/> />
<TableRow <TableRow
label={"Discord"} label="Discord"
subLabel={`Version ${debugInfo.discord.version}`}
icon={<TableRow.Icon source={findAssetId("Discord")!} />} icon={<TableRow.Icon source={findAssetId("Discord")!} />}
trailing={<TableRow.TrailingText text={`${debugInfo.discord.version} (${debugInfo.discord.build})`} />} trailing={
<TableRow.TrailingText
text={`Build ${debugInfo.discord.build}`}
/>
}
/> />
<TableRow <TableRow
arrow label="Loader"
label={Strings.ABOUT} subLabel="ShiggyCord loader information"
icon={<TableRow.Icon source={findAssetId("CircleInformationIcon-primary")!} />} icon={<TableRow.Icon source={findAssetId("DownloadIcon")!} />}
onPress={() => navigation.push("PUPU_CUSTOM_PAGE", { trailing={
title: Strings.ABOUT, <TableRow.TrailingText
render: () => <About />, text={`${debugInfo.bunny.loader.name} ${debugInfo.bunny.loader.version}`}
})} />
}
/>
<TableRow
label="Platform"
subLabel={`${debugInfo.device.brand} ${debugInfo.device.model}`}
icon={<TableRow.Icon source={findAssetId("ScreenIcon")!} />}
trailing={<TableRow.TrailingText text={debugInfo.os.name} />}
/> />
</TableRowGroup> </TableRowGroup>
<TableRowGroup title={Strings.LINKS}> <TableRowGroup title="Quick Actions">
<TableRow
arrow={true}
label={Strings.DISCORD_SERVER}
icon={<TableRow.Icon source={findAssetId("Discord")!} />}
onPress={() => Linking.openURL(DISCORD_SERVER)}
/>
<TableRow
arrow={true}
label={Strings.CODEBERG}
icon={<TableRow.Icon source={{ uri: CodebergIcon}} />}
onPress={() => Linking.openURL(CODEBERG)}
/>
<TableRow
arrow={true}
label={Strings.GITHUB}
icon={<TableRow.Icon source={findAssetId("img_account_sync_github_white")!} />}
onPress={() => Linking.openURL(GITHUB)}
/>
</TableRowGroup>
<TableRowGroup title={Strings.ACTIONS}>
<TableRow <TableRow
label={Strings.RELOAD_DISCORD} label={Strings.RELOAD_DISCORD}
subLabel="Restart the application"
icon={<TableRow.Icon source={findAssetId("RetryIcon")!} />} icon={<TableRow.Icon source={findAssetId("RetryIcon")!} />}
onPress={() => BundleUpdaterManager.reload()} onPress={() => BundleUpdaterManager.reload()}
/> />
<TableSwitchRow <TableSwitchRow
label={"Safe Mode"} label="Safe Mode"
subLabel={"Load Kettu without loading add-ons"} subLabel="Temporarily disable all add-ons"
icon={<TableRow.Icon source={findAssetId("ShieldIcon")!} />} icon={<TableRow.Icon source={findAssetId("ShieldIcon")!} />}
value={isSafeMode()} value={isSafeMode()}
onValueChange={(to: boolean) => { onValueChange={(to: boolean) => {
@ -81,39 +97,84 @@ export default function General() {
"bunny-reload-safe-mode", "bunny-reload-safe-mode",
<AlertModal <AlertModal
title="Reload now?" title="Reload now?"
content={!to ? "All add-ons will load normally." : "All add-ons will be temporarily disabled upon reload."} content={
actions={<AlertActions> !to
? "All add-ons will load normally."
: "All add-ons will be temporarily disabled upon reload."
}
actions={
<AlertActions>
<AlertActionButton <AlertActionButton
text="Reload Now" text="Reload Now"
variant="destructive" variant="destructive"
onPress={() => BundleUpdaterManager.reload()} onPress={() => BundleUpdaterManager.reload()}
/> />
<AlertActionButton text="Later" variant="secondary" /> <AlertActionButton text="Later" variant="secondary" />
</AlertActions>} </AlertActions>
/> }
/>,
); );
}} }}
/> />
</TableRowGroup>
<TableRowGroup title="Developer">
<TableSwitchRow <TableSwitchRow
label={Strings.DEVELOPER_SETTINGS} label={Strings.DEVELOPER_SETTINGS}
subLabel="Enable developer tools and settings"
icon={<TableRow.Icon source={findAssetId("WrenchIcon")!} />} icon={<TableRow.Icon source={findAssetId("WrenchIcon")!} />}
value={settings.developerSettings} value={settings.developerSettings}
onValueChange={(v: boolean) => { onValueChange={(v: boolean) => {
settings.developerSettings = v; settings.developerSettings = v;
}} }}
/> />
</TableRowGroup>
<TableRowGroup title={Strings.MISCELLANEOUS}>
<TableSwitchRow <TableSwitchRow
label={Strings.SETTINGS_ACTIVATE_DISCORD_EXPERIMENTS} label={Strings.SETTINGS_ACTIVATE_DISCORD_EXPERIMENTS}
subLabel={Strings.SETTINGS_ACTIVATE_DISCORD_EXPERIMENTS_DESC} subLabel={Strings.SETTINGS_ACTIVATE_DISCORD_EXPERIMENTS_DESC}
icon={<TableRow.Icon source={findAssetId("WrenchIcon")!} />} icon={<TableRow.Icon source={findAssetId("FlaskIcon")!} />}
value={settings.enableDiscordDeveloperSettings} value={settings.enableDiscordDeveloperSettings}
onValueChange={(v: boolean) => { onValueChange={(v: boolean) => {
settings.enableDiscordDeveloperSettings = v; settings.enableDiscordDeveloperSettings = v;
}} }}
/> />
</TableRowGroup> </TableRowGroup>
<TableRowGroup title="Community & Support">
<TableRow
arrow={true}
label={Strings.DISCORD_SERVER}
subLabel="Join our community server"
icon={<TableRow.Icon source={findAssetId("Discord")!} />}
onPress={() => Linking.openURL(DISCORD_SERVER)}
/>
<TableRow
arrow={true}
label={Strings.GITHUB}
subLabel="Source code and issues"
icon={
<TableRow.Icon
source={findAssetId("img_account_sync_github_white")!}
/>
}
onPress={() => Linking.openURL(GITHUB)}
/>
</TableRowGroup>
<TableRowGroup title="System Information">
<TableRow
arrow
label={Strings.ABOUT}
subLabel="Detailed technical information"
icon={
<TableRow.Icon
source={findAssetId("CircleInformationIcon-primary")!}
/>
}
onPress={() =>
navigation.push("PUPU_CUSTOM_PAGE", {
title: Strings.ABOUT,
render: () => <About />,
})
}
/>
</TableRowGroup>
</Stack> </Stack>
</ScrollView> </ScrollView>
); );

View file

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

View file

@ -3,13 +3,22 @@ import { UnifiedPluginModel } from "@core/ui/settings/pages/Plugins/models";
import { usePluginCardStyles } from "@core/ui/settings/pages/Plugins/usePluginCardStyles"; import { usePluginCardStyles } from "@core/ui/settings/pages/Plugins/usePluginCardStyles";
import { findAssetId } from "@lib/api/assets"; import { findAssetId } from "@lib/api/assets";
import { NavigationNative, tokens } from "@metro/common"; import { NavigationNative, tokens } from "@metro/common";
import { Card, IconButton, Stack, TableSwitch, Text } from "@metro/common/components"; import {
Card,
IconButton,
Stack,
TableSwitch,
Text,
} from "@metro/common/components";
import { showSheet } from "@ui/sheets"; import { showSheet } from "@ui/sheets";
import chroma from "chroma-js"; import chroma from "chroma-js";
import { createContext, useContext, useMemo } from "react"; import { createContext, useContext, useMemo } from "react";
import { Image, View } from "react-native"; import { Image, View } from "react-native";
const CardContext = createContext<{ plugin: UnifiedPluginModel, result: Fuzzysort.KeysResult<UnifiedPluginModel>; }>(null!); const CardContext = createContext<{
plugin: UnifiedPluginModel;
result: Fuzzysort.KeysResult<UnifiedPluginModel>;
}>(null!);
const useCardContext = () => useContext(CardContext); const useCardContext = () => useContext(CardContext);
function getHighlightColor(): import("react-native").ColorValue { function getHighlightColor(): import("react-native").ColorValue {
@ -21,30 +30,26 @@ function Title() {
const { plugin, result } = useCardContext(); const { plugin, result } = useCardContext();
// could be empty if the plugin name is irrelevant! // could be empty if the plugin name is irrelevant!
const highlightedNode = result[0].highlight((m, i) => const highlightedNode = result[0].highlight((m, i) => (
<Text key={i} style={{ backgroundColor: getHighlightColor() }}> <Text key={i} style={{ backgroundColor: getHighlightColor() }}>
{m} {m}
</Text> </Text>
); ));
const icon = plugin.icon && findAssetId(plugin.icon); const icon = plugin.icon && findAssetId(plugin.icon);
const textNode = ( const textNode = (
<Text <Text numberOfLines={1} variant="heading-lg/semibold">
numberOfLines={1}
variant="heading-lg/semibold"
>
{highlightedNode.length ? highlightedNode : plugin.name} {highlightedNode.length ? highlightedNode : plugin.name}
</Text> </Text>
); );
return <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}> return (
{icon && <Image <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
style={styles.smallIcon} {icon && <Image style={styles.smallIcon} source={icon} />}
source={icon}
/>}
{textNode} {textNode}
</View>; </View>
);
} }
function Authors() { function Authors() {
@ -54,27 +59,32 @@ function Authors() {
if (!plugin.authors) return null; if (!plugin.authors) return null;
// could be empty if the author(s) are irrelevant with the search! // could be empty if the author(s) are irrelevant with the search!
const highlightedNode = result[2].highlight((m, i) => const highlightedNode = result[2].highlight((m, i) => (
<Text key={i} style={{ backgroundColor: getHighlightColor() }}> <Text key={i} style={{ backgroundColor: getHighlightColor() }}>
{m} {m}
</Text> </Text>
); ));
const badges = plugin.getBadges(); const badges = plugin.getBadges();
const authorText = highlightedNode.length > 0 ? highlightedNode : plugin.authors.map(a => a.name).join(", "); const authorText =
highlightedNode.length > 0
? highlightedNode
: plugin.authors.map((a) => a.name).join(", ");
return ( return (
<View style={{ flexDirection: "row", flexWrap: "wrap", flexShrink: 1, gap: 4 }}> <View
style={{ flexDirection: "row", flexWrap: "wrap", flexShrink: 1, gap: 4 }}
>
<Text variant="text-sm/semibold" color="text-muted"> <Text variant="text-sm/semibold" color="text-muted">
by {authorText} by {authorText}
</Text> </Text>
{badges.length > 0 && <View style={styles.badgesContainer}> {badges.length > 0 && (
{badges.map((b, i) => <Image <View style={styles.badgesContainer}>
key={i} {badges.map((b, i) => (
source={b.source} <Image key={i} source={b.source} style={styles.badgeIcon} />
style={styles.badgeIcon} ))}
/>)} </View>
</View>} )}
</View> </View>
); );
} }
@ -83,54 +93,72 @@ function Description() {
const { plugin, result } = useCardContext(); const { plugin, result } = useCardContext();
// could be empty if the description is irrelevant with the search! // could be empty if the description is irrelevant with the search!
const highlightedNode = result[1].highlight((m, i) => const highlightedNode = result[1].highlight((m, i) => (
<Text key={i} style={{ backgroundColor: getHighlightColor() }}>{m}</Text> <Text key={i} style={{ backgroundColor: getHighlightColor() }}>
); {m}
</Text>
));
return <Text variant="text-md/medium"> return (
<Text variant="text-md/medium">
{highlightedNode.length ? highlightedNode : plugin.description} {highlightedNode.length ? highlightedNode : plugin.description}
</Text>; </Text>
);
} }
const Actions = () => { const Actions = () => {
const { plugin } = useCardContext(); const { plugin } = useCardContext();
const navigation = NavigationNative.useNavigation(); const navigation = NavigationNative.useNavigation();
return <View style={{ flexDirection: "row", gap: 6 }}> return (
<View style={{ flexDirection: "row", gap: 6 }}>
<IconButton <IconButton
size="sm" size="sm"
variant="secondary" variant="secondary"
icon={findAssetId("WrenchIcon")} icon={findAssetId("WrenchIcon")}
disabled={!plugin.getPluginSettingsComponent()} disabled={!plugin.getPluginSettingsComponent()}
onPress={() => navigation.push("PUPU_CUSTOM_PAGE", { onPress={() =>
navigation.push("PUPU_CUSTOM_PAGE", {
title: plugin.name, title: plugin.name,
render: plugin.getPluginSettingsComponent(), render: plugin.getPluginSettingsComponent(),
})} })
}
/> />
<IconButton <IconButton
size="sm" size="sm"
variant="secondary" variant="secondary"
icon={findAssetId("CircleInformationIcon-primary")} icon={findAssetId("CircleInformationIcon-primary")}
onPress={() => void showSheet( onPress={() =>
void showSheet(
"PluginInfoActionSheet", "PluginInfoActionSheet",
plugin.resolveSheetComponent(), plugin.resolveSheetComponent(),
{ plugin, navigation } { plugin, navigation },
)} )
}
/> />
</View>; </View>
);
}; };
export default function PluginCard({ result, item: plugin }: CardWrapper<UnifiedPluginModel>) { export default function PluginCard({
result,
item: plugin,
}: CardWrapper<UnifiedPluginModel>) {
plugin.usePluginState(); plugin.usePluginState();
const [, forceUpdate] = React.useReducer(() => ({}), 0); const [, forceUpdate] = React.useReducer(() => ({}), 0);
const cardContextValue = useMemo(() => ({ plugin, result }), [plugin, result]); const cardContextValue = useMemo(
() => ({ plugin, result }),
[plugin, result],
);
return ( return (
<CardContext.Provider value={cardContextValue}> <CardContext.Provider value={cardContextValue}>
<Card> <Card>
<Stack spacing={16}> <Stack spacing={16}>
<View style={{ flexDirection: "row", justifyContent: "space-between" }}> <View
style={{ flexDirection: "row", justifyContent: "space-between" }}
>
<View style={{ flexShrink: 1 }}> <View style={{ flexShrink: 1 }}>
<Title /> <Title />
<Authors /> <Authors />

View file

@ -1,9 +1,26 @@
import { React, NavigationNative } from "@metro/common";
const { useReducer } = React;
import { View, Image } from "react-native";
import {
Stack,
Button,
IconButton,
Text,
Card,
FlashList,
} from "@metro/common/components";
import { Strings } from "@core/i18n"; import { Strings } from "@core/i18n";
import AddonPage from "@core/ui/components/AddonPage"; import AddonPage from "@core/ui/components/AddonPage";
import PluginCard from "@core/ui/settings/pages/Plugins/components/PluginCard"; import PluginCard from "@core/ui/settings/pages/Plugins/components/PluginCard";
import { VdPluginManager } from "@core/vendetta/plugins"; import { VdPluginManager } from "@core/vendetta/plugins";
import { useProxy } from "@core/vendetta/storage"; import { useProxy } from "@core/vendetta/storage";
import { isCorePlugin, isPluginInstalled, pluginSettings, registeredPlugins } from "@lib/addons/plugins"; import {
corePluginInstances,
isCorePlugin,
isPluginInstalled,
pluginSettings,
registeredPlugins,
} from "@lib/addons/plugins";
import { Author } from "@lib/addons/types"; import { Author } from "@lib/addons/types";
import { findAssetId } from "@lib/api/assets"; import { findAssetId } from "@lib/api/assets";
import { settings } from "@lib/api/settings"; import { settings } from "@lib/api/settings";
@ -12,125 +29,539 @@ import { showToast } from "@lib/ui/toasts";
import { BUNNY_PROXY_PREFIX, VD_PROXY_PREFIX } from "@lib/utils/constants"; import { BUNNY_PROXY_PREFIX, VD_PROXY_PREFIX } from "@lib/utils/constants";
import { lazyDestructure } from "@lib/utils/lazy"; import { lazyDestructure } from "@lib/utils/lazy";
import { findByProps } from "@metro"; import { findByProps } from "@metro";
import { NavigationNative } from "@metro/common";
import { Button, Card, FlashList, IconButton, Text } from "@metro/common/components";
import { ComponentProps } from "react"; import { ComponentProps } from "react";
import { View } from "react-native"; import safeFetch from "@lib/utils/safeFetch";
import Search from "@ui/components/Search";
import { clipboard } from "@metro/common";
import {
ActionSheet,
AlertModal,
AlertActions,
TableRow,
TableRowGroup,
} from "@metro/common/components";
import { UnifiedPluginModel } from "./models"; import { UnifiedPluginModel } from "./models";
import unifyBunnyPlugin from "./models/bunny"; import unifyBunnyPlugin from "./models/bunny";
import unifyVdPlugin from "./models/vendetta"; import unifyVdPlugin from "./models/vendetta";
const { openAlert } = lazyDestructure(() => findByProps("openAlert", "dismissAlert")); const { openAlert } = lazyDestructure(() =>
const { AlertModal, AlertActions, AlertActionButton } = lazyDestructure(() => findByProps("AlertModal", "AlertActions")); findByProps("openAlert", "dismissAlert"),
);
const {
AlertModal: AlertModalComponent,
AlertActions: AlertActionsComponent,
AlertActionButton,
} = lazyDestructure(() => findByProps("AlertModal", "AlertActions"));
const { showSimpleActionSheet, hideActionSheet } = lazyDestructure(() =>
findByProps("showSimpleActionSheet"),
);
interface PluginPageProps extends Partial<ComponentProps<typeof AddonPage<UnifiedPluginModel>>> { interface BaseAddonData {
name: string;
description: string;
authors: string[];
installUrl: string;
}
interface PluginData extends BaseAddonData {
status: "working" | "broken" | "warning" | string;
sourceUrl: string;
warningMessage?: string;
}
type AddonData = PluginData;
const PLUGIN_URL =
"https://raw.githubusercontent.com/Purple-EyeZ/Plugins-List/refs/heads/main/src/plugins-data.json";
enum Sort {
DateNewest = "Date Added (Newest First)",
DateOldest = "Date Added (Oldest First)",
NameAZ = "Name (A-Z)",
NameZA = "Name (Z-A)",
WorkingFirst = "Working First",
BrokenFirst = "Broken First",
}
function normalizeIdFromInstallUrl(url: string) {
return url.endsWith("/") ? url : url + "/";
}
function InstallButton({
addon,
installing,
setInstalling,
setRefreshTick,
}: any) {
const pluginId = normalizeIdFromInstallUrl(addon.installUrl);
const plugin = VdPluginManager.plugins[pluginId];
const installed = !!plugin;
const isInstalling = installing.has(pluginId);
// Determine if this is the QuickInstall plugin which can't be disabled
const isQuickInstall = pluginId.toLowerCase().includes("quickinstall");
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const [rotation, setRotation] = React.useState(0);
const [pulse, setPulse] = React.useState(1);
const handleInstall = async () => {
if (isInstalling) return;
// Check for broken or warning status
if (addon.status === "broken" || addon.status === "warning") {
const isBroken = addon.status === "broken";
openAlert(
"bunny-plugin-status-warning",
<AlertModalComponent
title={isBroken ? "Plugin is Broken" : "Plugin Warning"}
content={
isBroken
? "This plugin is marked as broken and may not work properly or could cause issues. Do you still want to install it?"
: "This plugin has warnings and might have issues. Do you want to continue with the installation?"
}
extraContent={
addon.warningMessage ? (
<Card>
<Text variant="text-md/normal">{addon.warningMessage}</Text>
</Card>
) : null
}
actions={
<AlertActionsComponent>
<AlertActionButton
text="Install Anyway"
variant={isBroken ? "destructive" : "primary"}
onPress={continueInstallation}
/>
<AlertActionButton text="Cancel" variant="secondary" />
</AlertActionsComponent>
}
/>,
);
return;
}
continueInstallation();
};
const performInstall = async () => {
setInstalling((prev: Set<string>) => new Set([...prev, pluginId]));
// Start animation
const animationInterval = setInterval(() => {
setRotation((prev) => (prev + 15) % 360);
setPulse((prev) => 0.8 + 0.2 * Math.sin(Date.now() / 200));
forceUpdate();
}, 100);
try {
await VdPluginManager.installPlugin(addon.installUrl);
setRefreshTick((prev: number) => prev + 1);
} catch (e) {
openAlert(
"bunny-plugin-install-failed",
<AlertModalComponent
title="Install Failed"
content={`Unable to install plugin from '${addon.installUrl}':`}
extraContent={
<Card>
<Text variant="text-md/normal">
{e instanceof Error ? e.message : String(e)}
</Text>
</Card>
}
actions={<AlertActionButton text="Okay" variant="primary" />}
/>,
);
} finally {
clearInterval(animationInterval);
setRotation(0);
setPulse(1);
setInstalling((prev: Set<string>) => {
const newSet = new Set(prev);
newSet.delete(pluginId);
return newSet;
});
}
};
const continueInstallation = async () => {
if (
!addon.installUrl.startsWith(VD_PROXY_PREFIX) &&
!addon.installUrl.startsWith(BUNNY_PROXY_PREFIX) &&
!settings.developerSettings
) {
openAlert(
"bunny-plugin-unproxied-confirmation",
<AlertModalComponent
title="Hold On!"
content="You're trying to install a plugin from an unproxied external source. This means you're trusting the creator to run their code in this app without your knowledge. Are you sure you want to continue?"
extraContent={
<Card>
<Text variant="text-md/bold">{addon.installUrl}</Text>
</Card>
}
actions={
<AlertActionsComponent>
<AlertActionButton
text="Continue"
variant="primary"
onPress={performInstall}
/>
<AlertActionButton text="Cancel" variant="secondary" />
</AlertActionsComponent>
}
/>,
);
} else {
performInstall();
}
};
const handleUninstall = () => {
VdPluginManager.removePlugin(pluginId);
setRefreshTick((prev: number) => prev + 1);
};
if (installed) {
return (
<Button
size="sm"
text="Uninstall"
variant="destructive"
icon={findAssetId("TrashIcon")}
onPress={handleUninstall}
disabled={isQuickInstall}
/>
);
}
return (
<View style={{ opacity: pulse }}>
<Button
size="sm"
text={isInstalling ? "Installing..." : "Install"}
variant="primary"
disabled={isInstalling}
loading={isInstalling}
icon={
isInstalling ? (
<View style={{ transform: [{ rotate: `${rotation}deg` }] }}>
<Image
source={findAssetId("RetryIcon")}
style={{ width: 16, height: 16 }}
/>
</View>
) : (
findAssetId("DownloadIcon")
)
}
onPress={handleInstall}
/>
</View>
);
}
function TrailingButtons({
addon,
installing,
setInstalling,
setRefreshTick,
}: any) {
return (
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<IconButton
size="sm"
variant="secondary"
icon={findAssetId("MoreHorizontalIcon")}
onPress={() => {
showSimpleActionSheet({
key: "AddonShareSheet",
header: {
title: "Share Plugin",
},
options: [
{
label: "Copy Install URL",
onPress: () => {
clipboard.setString(addon.installUrl);
// URL copied
},
},
{
label: "Copy Source URL",
onPress: () => {
if (addon.sourceUrl) {
clipboard.setString(addon.sourceUrl);
// Source URL copied
}
},
},
],
});
}}
/>
<InstallButton
addon={addon}
installing={installing}
setInstalling={setInstalling}
setRefreshTick={setRefreshTick}
/>
</View>
);
}
function BrowseAddonCard({
addon,
installing,
setInstalling,
setRefreshTick,
}: any) {
const { name, description, authors, status, warningMessage } = addon;
let statusColor = "text-normal";
if (status === "working") statusColor = "#4ADE80";
if (status === "broken") statusColor = "#EF4444";
if (status === "warning") statusColor = "#F59E0B";
return (
<Card>
<Stack spacing={16}>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
}}
>
<View style={{ flexShrink: 1 }}>
<Text numberOfLines={1} variant="heading-lg/semibold">
{name}
</Text>
<Text variant="text-md/semibold" color="text-muted">
by {authors?.join(", ") || "Unknown"}
</Text>
<Text variant="text-md/semibold" style={{ color: statusColor }}>
Status: {status}
</Text>
</View>
<View>
<TrailingButtons
addon={addon}
installing={installing}
setInstalling={setInstalling}
setRefreshTick={setRefreshTick}
/>
</View>
</View>
<Text variant="text-md/medium">{description}</Text>
{warningMessage && (
<Text variant="text-sm/medium" color="text-muted">
Warning: {warningMessage}
</Text>
)}
</Stack>
</Card>
);
}
interface PluginPageProps
extends Partial<ComponentProps<typeof AddonPage<UnifiedPluginModel>>> {
useItems: () => unknown[]; useItems: () => unknown[];
} }
function PluginPage(props: PluginPageProps) { function InstalledPluginPage(props: PluginPageProps) {
const items = props.useItems(); const items = props.useItems();
return <AddonPage<UnifiedPluginModel> return (
<AddonPage<UnifiedPluginModel>
CardComponent={PluginCard} CardComponent={PluginCard}
title={Strings.PLUGINS} title={Strings.PLUGINS}
searchKeywords={[ searchKeywords={[
"name", "name",
"description", "description",
p => p.authors?.map( (p) =>
(a: Author | string) => typeof a === "string" ? a : a.name p.authors
).join() || "" ?.map((a: Author | string) => (typeof a === "string" ? a : a.name))
.join() || "",
]} ]}
sortOptions={{ sortOptions={{
"Name (A-Z)": (a, b) => a.name.localeCompare(b.name), "Name (A-Z)": (a, b) => a.name.localeCompare(b.name),
"Name (Z-A)": (a, b) => b.name.localeCompare(a.name) "Name (Z-A)": (a, b) => b.name.localeCompare(a.name),
}} }}
safeModeHint={{ message: Strings.SAFE_MODE_NOTICE_PLUGINS }} safeModeHint={{ message: Strings.SAFE_MODE_NOTICE_PLUGINS }}
items={items} items={items}
{...props} {...props}
/>; />
);
}
interface BrowsePluginPageProps {
plugins: PluginData[];
installing: Set<string>;
setInstalling: React.Dispatch<React.SetStateAction<Set<string>>>;
setRefreshTick: React.Dispatch<React.SetStateAction<number>>;
}
function BrowsePluginPage(props: BrowsePluginPageProps) {
return (
<AddonPage<PluginData>
CardComponent={({ item }) => (
<BrowseAddonCard
addon={item}
installing={props.installing}
setInstalling={props.setInstalling}
setRefreshTick={props.setRefreshTick}
/>
)}
title={Strings.PLUGINS}
searchKeywords={[
"name",
"description",
(p) => (p.authors || []).join(", "),
]}
sortOptions={{
"Date (Newest)": (a, b) => 0,
"Date (Oldest)": (a, b) => 0,
"Name (A-Z)": (a, b) => a.name.localeCompare(b.name),
"Name (Z-A)": (a, b) => b.name.localeCompare(a.name),
}}
items={props.plugins}
/>
);
} }
export default function Plugins() { export default function Plugins() {
useProxy(settings); useProxy(settings);
const navigation = NavigationNative.useNavigation(); const navigation = NavigationNative.useNavigation();
return <PluginPage const [mode, setMode] = React.useState<"installed" | "browse">("installed");
const [plugins, setPlugins] = React.useState<PluginData[]>([]);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [installing, setInstalling] = React.useState<Set<string>>(new Set());
const [refreshTick, setRefreshTick] = React.useState(0);
React.useEffect(() => {
navigation.setOptions({
headerRight: () => (
<Button
size="sm"
variant="secondary"
text={mode === "installed" ? "Browse" : "Installed"}
icon={findAssetId(mode === "installed" ? "LinkIcon" : "DownloadIcon")}
onPress={() => setMode(mode === "installed" ? "browse" : "installed")}
/>
),
});
}, [navigation, mode]);
const fetchPlugins = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await safeFetch(PLUGIN_URL);
if (!response.ok)
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const data = await response.json();
let pluginList: PluginData[] = [];
if (Array.isArray(data)) {
pluginList = data;
} else if (data.OFFICIAL_PLUGINS) {
pluginList = data.OFFICIAL_PLUGINS;
}
setPlugins(pluginList);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setPlugins([]);
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
if (mode === "browse") {
fetchPlugins();
}
}, [mode, fetchPlugins, refreshTick]);
if (mode === "installed") {
return (
<InstalledPluginPage
useItems={() => { useItems={() => {
useProxy(VdPluginManager.plugins); useProxy(VdPluginManager.plugins);
useObservable([pluginSettings]); useObservable([pluginSettings]);
const vdPlugins = Object.values(VdPluginManager.plugins).map(unifyVdPlugin); const vdPlugins = Object.values(VdPluginManager.plugins).map(
const bnPlugins = [...registeredPlugins.values()].filter(p => isPluginInstalled(p.id) && !isCorePlugin(p.id)).map(unifyBunnyPlugin); unifyVdPlugin,
);
return [...vdPlugins, ...bnPlugins]; // Core plugins (always shown first)
}} const corePlugins = [...corePluginInstances.keys()]
ListHeaderComponent={() => { .map((id) => registeredPlugins.get(id))
const unproxiedPlugins = Object.values(VdPluginManager.plugins).filter(p => !p.id.startsWith(VD_PROXY_PREFIX) && !p.id.startsWith(BUNNY_PROXY_PREFIX)); .filter(Boolean)
if (!unproxiedPlugins.length) return null; .map(unifyBunnyPlugin);
return <View style={{ marginVertical: 12, marginHorizontal: 10 }}> // Regular installed plugins (non-core)
<Card border="strong"> const bnPlugins = [...registeredPlugins.values()]
<View style={{ flex: 1, justifyContent: "center", alignItems: "center", flexDirection: "row" }}> .filter((p) => isPluginInstalled(p.id) && !isCorePlugin(p.id))
<View style={{ gap: 6, flexShrink: 1 }}> .map(unifyBunnyPlugin);
<Text variant="heading-md/bold">Unproxied Plugins Found</Text>
<Text variant="text-sm/medium" color="text-muted"> return [...corePlugins, ...vdPlugins, ...bnPlugins];
Plugins installed from unproxied sources may run unverified code in this app without your awareness.
</Text>
</View>
<View style={{ marginLeft: "auto" }}>
<IconButton
size="sm"
variant="secondary"
icon={findAssetId("CircleInformationIcon-primary")}
style={{ marginLeft: 8 }}
onPress={() => {
navigation.push("PUPU_CUSTOM_PAGE", {
title: "Unproxied Plugins",
render: () => {
return <FlashList
data={unproxiedPlugins}
contentContainerStyle={{ padding: 8 }}
ItemSeparatorComponent={() => <View style={{ height: 8 }} />}
renderItem={({ item: p }: any) => <Card>
<Text variant="heading-md/semibold">{p.id}</Text>
</Card>}
/>;
}
});
}}
/>
</View>
</View>
</Card>
</View>;
}} }}
ListHeaderComponent={() => null}
installAction={{ installAction={{
label: "Install a plugin", label: "Install a plugin",
fetchFn: async (url: string) => { fetchFn: async (url: string) => {
if (!url.startsWith(VD_PROXY_PREFIX) && !url.startsWith(BUNNY_PROXY_PREFIX) && !settings.developerSettings) {
openAlert("bunny-plugin-unproxied-confirmation", <AlertModal
title="Hold On!"
content="You're trying to install a plugin from an unproxied external source. This means you're trusting the creator to run their code in this app without your knowledge. Are you sure you want to continue?"
extraContent={<Card><Text variant="text-md/bold">{url}</Text></Card>}
actions={<AlertActions>
<AlertActionButton text="Continue" variant="primary" onPress={() => {
VdPluginManager.installPlugin(url)
.then(() => showToast(Strings.TOASTS_INSTALLED_PLUGIN, findAssetId("Check")))
.catch(e => openAlert("bunny-plugin-install-failed", <AlertModal
title="Install Failed"
content={`Unable to install plugin from '${url}':`}
extraContent={<Card><Text variant="text-md/normal">{e instanceof Error ? e.message : String(e)}</Text></Card>}
actions={<AlertActionButton text="Okay" variant="primary" />}
/>));
}} />
<AlertActionButton text="Cancel" variant="secondary" />
</AlertActions>}
/>);
} else {
return await VdPluginManager.installPlugin(url); return await VdPluginManager.installPlugin(url);
} },
}
}} }}
/>; />
);
}
// Browse mode
if (error) {
return (
<View
style={{
flex: 1,
paddingHorizontal: 8,
justifyContent: "center",
alignItems: "center",
}}
>
<Card style={{ gap: 8 }}>
<Text style={{ textAlign: "center" }} variant="heading-lg/bold">
An error occurred while fetching the repository
</Text>
<Text
style={{ textAlign: "center" }}
variant="text-sm/medium"
color="text-muted"
>
{error}
</Text>
<Button
size="lg"
text="Refetch"
onPress={fetchPlugins}
icon={findAssetId("RetryIcon")}
/>
</Card>
</View>
);
}
return (
<BrowsePluginPage
plugins={plugins}
installing={installing}
setInstalling={setInstalling}
setRefreshTick={setRefreshTick}
/>
);
} }

View file

@ -5,7 +5,9 @@ import { useObservable } from "@lib/api/storage";
import { UnifiedPluginModel } from "."; import { UnifiedPluginModel } from ".";
export default function unifyBunnyPlugin(manifest: BunnyPluginManifest): UnifiedPluginModel { export default function unifyBunnyPlugin(
manifest: BunnyPluginManifest,
): UnifiedPluginModel {
return { return {
id: manifest.id, id: manifest.id,
name: manifest.display.name, name: manifest.display.name,

View file

@ -3,7 +3,9 @@ import { useProxy } from "@core/vendetta/storage";
import { UnifiedPluginModel } from "."; import { UnifiedPluginModel } from ".";
export default function unifyVdPlugin(vdPlugin: VendettaPlugin): UnifiedPluginModel { export default function unifyVdPlugin(
vdPlugin: VendettaPlugin,
): UnifiedPluginModel {
return { return {
id: vdPlugin.id, id: vdPlugin.id,
name: vdPlugin.manifest.name, name: vdPlugin.manifest.name,
@ -15,7 +17,8 @@ export default function unifyVdPlugin(vdPlugin: VendettaPlugin): UnifiedPluginMo
return []; return [];
}, },
isEnabled: () => vdPlugin.enabled, isEnabled: () => vdPlugin.enabled,
isInstalled: () => Boolean(vdPlugin && VdPluginManager.plugins[vdPlugin.id]), isInstalled: () =>
Boolean(vdPlugin && VdPluginManager.plugins[vdPlugin.id]),
usePluginState() { usePluginState() {
useProxy(VdPluginManager.plugins[vdPlugin.id]); useProxy(VdPluginManager.plugins[vdPlugin.id]);
}, },
@ -25,7 +28,7 @@ export default function unifyVdPlugin(vdPlugin: VendettaPlugin): UnifiedPluginMo
: VdPluginManager.stopPlugin(vdPlugin.id); : VdPluginManager.stopPlugin(vdPlugin.id);
}, },
resolveSheetComponent() { resolveSheetComponent() {
return import("../sheets/VdPluginInfoActionSheet"); return import("../sheets/PluginInfoActionSheet");
}, },
getPluginSettingsComponent() { getPluginSettingsComponent() {
return VdPluginManager.getSettings(vdPlugin.id); return VdPluginManager.getSettings(vdPlugin.id);

View file

@ -1,9 +1,21 @@
import { startPlugin } from "@lib/addons/plugins"; import { refreshPlugin, uninstallPlugin } from "@lib/addons/plugins";
import { findAssetId } from "@lib/api/assets"; import { findAssetId } from "@lib/api/assets";
import { purgeStorage } from "@lib/api/storage";
import { hideSheet } from "@lib/ui/sheets"; import { hideSheet } from "@lib/ui/sheets";
import { ActionSheet, Card, ContextMenu, IconButton, Text } from "@metro/common/components"; import {
import { ComponentProps } from "react"; ActionSheet,
Card,
ContextMenu,
IconButton,
Text,
} from "@metro/common/components";
import { clipboard } from "@metro/common";
import { ComponentProps, useState } from "react";
import { ScrollView, View } from "react-native"; import { ScrollView, View } from "react-native";
import { showToast } from "@ui/toasts";
import { showConfirmationAlert } from "@core/vendetta/alerts";
import { VdPluginManager } from "@core/vendetta/plugins";
import { purgeStorage as purgeVdStorage } from "@core/vendetta/storage";
import { PluginInfoActionSheetProps } from "./common"; import { PluginInfoActionSheetProps } from "./common";
import TitleComponent from "./TitleComponent"; import TitleComponent from "./TitleComponent";
@ -18,53 +30,166 @@ function PluginInfoIconButton(props: ComponentProps<typeof IconButton>) {
return <IconButton {...props} />; return <IconButton {...props} />;
} }
export default function PluginInfoActionSheet({ plugin, navigation }: PluginInfoActionSheetProps) { export default function PluginInfoActionSheet({
plugin,
navigation,
}: PluginInfoActionSheetProps) {
plugin.usePluginState(); plugin.usePluginState();
const [loading, setLoading] = useState(false);
return <ActionSheet> // Determine plugin type
const isVendettaPlugin = plugin.id.includes("/");
const isCorePlugin =
plugin.id.startsWith("bunny.") || plugin.id.startsWith("vendetta.");
const copyPluginUrl = () => {
// Get appropriate URL for plugin type
let url = plugin.id;
if (isVendettaPlugin) {
// Vendetta plugins use the full URL as ID
url = plugin.id;
} else {
try {
// Try to get repository URL for Bunny plugins
const pluginAny = plugin as any;
const repoUrl =
pluginAny._manifest?.parentRepository ||
pluginAny.manifest?.parentRepository;
url = repoUrl ? `${repoUrl}/builds/${plugin.id}` : plugin.id;
} catch (e) {
url = plugin.id;
}
}
clipboard.setString(url);
if (typeof showToast?.showCopyToClipboard === "function") {
showToast.showCopyToClipboard();
} else {
showToast("Copied to clipboard");
}
};
const refetchPlugin = async () => {
setLoading(true);
try {
if (isVendettaPlugin) {
// For Vendetta plugins
const vdPlugin = VdPluginManager.plugins[plugin.id];
if (vdPlugin.enabled) VdPluginManager.stopPlugin(plugin.id, false);
await VdPluginManager.fetchPlugin(plugin.id);
if (vdPlugin.enabled) await VdPluginManager.startPlugin(plugin.id);
showToast("Plugin refreshed successfully");
} else {
// For Bunny plugins
refreshPlugin(plugin.id);
showToast("Plugin refreshed successfully");
}
} catch (e) {
console.error("Failed to refresh plugin:", e);
showToast("Failed to refresh plugin");
} finally {
setLoading(false);
}
};
const clearPluginData = () => {
showConfirmationAlert({
title: "Clear Data",
content:
"Are you sure you want to clear all data for this plugin? This action cannot be undone.",
confirmText: "Clear",
confirmColor: "red",
cancelText: "Cancel",
onConfirm: async () => {
hideSheet("PluginInfoActionSheet");
try {
if (isVendettaPlugin) {
// For Vendetta plugins
const vdPlugin = VdPluginManager.plugins[plugin.id];
if (vdPlugin.enabled) VdPluginManager.stopPlugin(plugin.id, false);
await purgeVdStorage(plugin.id);
if (vdPlugin.enabled) await VdPluginManager.startPlugin(plugin.id);
} else {
// For Bunny plugins
await purgeStorage(`plugins/storage/${plugin.id}.json`);
}
showToast("Plugin data cleared successfully");
} catch (e) {
console.error("Failed to clear plugin data:", e);
showToast("Failed to clear plugin data");
}
},
});
};
const uninstallPluginHandler = () => {
if (isCorePlugin) {
showToast("Core plugins cannot be uninstalled");
return;
}
showConfirmationAlert({
title: "Uninstall Plugin",
content:
"Are you sure you want to uninstall this plugin? This action cannot be undone.",
confirmText: "Uninstall",
confirmColor: "red",
cancelText: "Cancel",
onConfirm: async () => {
hideSheet("PluginInfoActionSheet");
try {
if (isVendettaPlugin) {
// For Vendetta plugins
await VdPluginManager.removePlugin(plugin.id);
} else {
// For Bunny plugins
await uninstallPlugin(plugin.id);
}
showToast("Plugin uninstalled successfully");
} catch (e) {
console.error("Failed to uninstall plugin:", e);
showToast(
`Failed to uninstall plugin: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
},
});
};
return (
<ActionSheet>
<ScrollView contentContainerStyle={{ gap: 12, marginBottom: 12 }}> <ScrollView contentContainerStyle={{ gap: 12, marginBottom: 12 }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingVertical: 24, justifyContent: "space-between", width: "100%" }}> <View
<TitleComponent plugin={plugin} /> style={{
<ContextMenu flexDirection: "row",
items={[ alignItems: "center",
{ gap: 8,
label: "Details", paddingVertical: 24,
iconSource: findAssetId("CircleInformationIcon-primary"), justifyContent: "space-between",
action: () => { width: "100%",
} }}
},
// {
// label: true ? "Disable Updates" : "Enable Updates",
// iconSource: true ? findAssetId("ClockXIcon") : findAssetId("ClockIcon"),
// action: () => {
// }
// },
{
label: "Clear Data",
iconSource: findAssetId("FileIcon"),
variant: "destructive",
action: () => {
}
},
{
label: "Uninstall",
iconSource: findAssetId("TrashIcon"),
variant: "destructive",
action: () => {
}
}
]}
> >
{props => <IconButton <TitleComponent plugin={plugin} />
{...props} {/* Context menu removed as buttons are directly added to button row */}
icon={findAssetId("MoreHorizontalIcon")}
variant="secondary"
size="sm"
/>}
</ContextMenu>
</View> </View>
<View style={{ flexDirection: "row", justifyContent: "space-around", alignContent: "center" }}> <View
style={{
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
flexWrap: "wrap",
gap: 12,
paddingHorizontal: 4,
}}
>
<PluginInfoIconButton <PluginInfoIconButton
label="Configure" label="Configure"
variant="secondary" variant="secondary"
@ -81,26 +206,41 @@ export default function PluginInfoActionSheet({ plugin, navigation }: PluginInfo
label="Refetch" label="Refetch"
variant="secondary" variant="secondary"
icon={findAssetId("RetryIcon")} icon={findAssetId("RetryIcon")}
onPress={() => { onPress={refetchPlugin}
startPlugin(plugin.id); disabled={loading}
}}
/> />
<PluginInfoIconButton <PluginInfoIconButton
label="Copy URL" label="Copy URL"
variant="secondary" variant="secondary"
icon={findAssetId("LinkIcon")} icon={findAssetId("LinkIcon")}
onPress={() => { onPress={copyPluginUrl}
}}
/> />
<PluginInfoIconButton
label="Clear Data"
variant="secondary"
icon={findAssetId("FileIcon")}
onPress={clearPluginData}
/>
{!isCorePlugin && (
<PluginInfoIconButton
label="Uninstall"
variant="secondary"
icon={findAssetId("TrashIcon")}
onPress={uninstallPluginHandler}
/>
)}
</View> </View>
<Card> <Card>
<Text variant="text-md/semibold" color="text-primary" style={{ marginBottom: 4 }}> <Text
variant="text-md/semibold"
color="text-primary"
style={{ marginBottom: 4 }}
>
Description Description
</Text> </Text>
<Text variant="text-md/medium"> <Text variant="text-md/medium">{plugin.description}</Text>
{plugin.description}
</Text>
</Card> </Card>
</ScrollView> </ScrollView>
</ActionSheet>; </ActionSheet>
);
} }

View file

@ -4,15 +4,21 @@ import { findByNameLazy, findByProps } from "@metro";
import { FluxUtils } from "@metro/common"; import { FluxUtils } from "@metro/common";
import { Avatar, AvatarPile, Text } from "@metro/common/components"; import { Avatar, AvatarPile, Text } from "@metro/common/components";
import { UserStore } from "@metro/common/stores"; import { UserStore } from "@metro/common/stores";
import { View } from "react-native"; import { View, TouchableOpacity } from "react-native";
const showUserProfileActionSheet = findByNameLazy("showUserProfileActionSheet"); const showUserProfileActionSheet = findByNameLazy("showUserProfileActionSheet");
const { getUser: maybeFetchUser } = lazyDestructure(() => findByProps("getUser", "fetchProfile")); const { getUser: maybeFetchUser } = lazyDestructure(() =>
findByProps("getUser", "fetchProfile"),
);
export default function TitleComponent({ plugin }: { plugin: UnifiedPluginModel; }) { export default function TitleComponent({
plugin,
}: {
plugin: UnifiedPluginModel;
}) {
const users: any[] = FluxUtils.useStateFromStoresArray([UserStore], () => { const users: any[] = FluxUtils.useStateFromStoresArray([UserStore], () => {
plugin.authors?.forEach(a => a.id && maybeFetchUser(a.id)); plugin.authors?.forEach((a) => a.id && maybeFetchUser(a.id));
return plugin.authors?.map(a => UserStore.getUser(a.id)); return plugin.authors?.map((a) => UserStore.getUser(a.id));
}); });
const { authors } = plugin; const { authors } = plugin;
@ -20,12 +26,11 @@ export default function TitleComponent({ plugin }: { plugin: UnifiedPluginModel;
if (authors) { if (authors) {
for (const author of authors) { for (const author of authors) {
authorTextNode.push(<Text authorTextNode.push(
onPress={() => showUserProfileActionSheet({ userId: author.id })} <Text variant="text-md/medium" key={author.name}>
variant="text-md/medium"
>
{author.name} {author.name}
</Text>); </Text>,
);
authorTextNode.push(", "); authorTextNode.push(", ");
} }
@ -33,25 +38,51 @@ export default function TitleComponent({ plugin }: { plugin: UnifiedPluginModel;
authorTextNode.pop(); authorTextNode.pop();
} }
return <View style={{ gap: 4 }}> const openFirstAuthorProfile = () => {
// Find first author with ID
const authorWithId = authors?.find((a) => !!a.id);
if (authorWithId?.id) {
showUserProfileActionSheet({ userId: authorWithId.id });
}
};
return (
<View style={{ gap: 4 }}>
<View> <View>
<Text variant="heading-xl/semibold"> <Text variant="heading-xl/semibold">{plugin.name}</Text>
{plugin.name}
</Text>
</View> </View>
<View style={{ flexDirection: "row", flexShrink: 1 }}> <View style={{ flexDirection: "row", flexShrink: 1 }}>
{authors?.length && <View style={{ flexDirection: "row", gap: 8, alignItems: "center", paddingVertical: 4, paddingHorizontal: 8, backgroundColor: "#00000016", borderRadius: 32 }}> {authors?.length && (
{users.length && <AvatarPile <TouchableOpacity
style={{
flexDirection: "row",
gap: 8,
alignItems: "center",
paddingVertical: 4,
paddingHorizontal: 8,
backgroundColor: "#00000016",
borderRadius: 32,
}}
onPress={
authors.some((a) => !!a.id) ? openFirstAuthorProfile : undefined
}
disabled={!authors.some((a) => !!a.id)}
>
{users.length && (
<AvatarPile
size="xxsmall" size="xxsmall"
names={plugin.authors?.map(a => a.name)} names={plugin.authors?.map((a) => a.name)}
totalCount={plugin.authors?.length} totalCount={plugin.authors?.length}
> >
{users.map(a => <Avatar size="xxsmall" user={a} />)} {users.map((a) => (
</AvatarPile>} <Avatar size="xxsmall" user={a} />
<Text variant="text-md/medium"> ))}
{authorTextNode} </AvatarPile>
</Text> )}
</View>} <Text variant="text-md/medium">{authorTextNode}</Text>
</TouchableOpacity>
)}
</View> </View>
</View>; </View>
);
} }

View file

@ -17,6 +17,6 @@ export const usePluginCardStyles = createStyles({
flexDirection: "row", flexDirection: "row",
gap: 6, gap: 6,
borderRadius: 6, borderRadius: 6,
padding: 4 padding: 4,
} },
}); });

View file

@ -1,24 +1,13 @@
import { formatString, Strings } from "@core/i18n"; import { Strings } from "@core/i18n";
import AddonCard, { CardWrapper } from "@core/ui/components/AddonCard"; import AddonCard, { CardWrapper } from "@core/ui/components/AddonCard";
import { showConfirmationAlert } from "@core/vendetta/alerts"; import { VdThemeInfo, themes, selectTheme } from "@lib/addons/themes";
import { useProxy } from "@core/vendetta/storage";
import { fetchTheme, removeTheme, selectTheme, themes, VdThemeInfo } from "@lib/addons/themes";
import { findAssetId } from "@lib/api/assets"; import { findAssetId } from "@lib/api/assets";
import { settings } from "@lib/api/settings"; import { settings } from "@lib/api/settings";
import { clipboard } from "@metro/common"; import { showSheet } from "@lib/ui/sheets";
import { showToast } from "@ui/toasts"; import { NavigationNative, React } from "@metro/common";
function selectAndApply(value: boolean, theme: VdThemeInfo) {
try {
selectTheme(value ? theme : null);
} catch (e: any) {
console.error("Error while selectAndApply,", e);
}
}
export default function ThemeCard({ item: theme }: CardWrapper<VdThemeInfo>) { export default function ThemeCard({ item: theme }: CardWrapper<VdThemeInfo>) {
useProxy(theme); const navigation = NavigationNative.useNavigation();
const [removed, setRemoved] = React.useState(false); const [removed, setRemoved] = React.useState(false);
// This is needed because of React™ // This is needed because of React™
@ -29,54 +18,33 @@ export default function ThemeCard({ item: theme }: CardWrapper<VdThemeInfo>) {
return ( return (
<AddonCard <AddonCard
headerLabel={theme.data.name} headerLabel={theme.data.name}
headerSublabel={authors ? `by ${authors.map(i => i.name).join(", ")}` : ""} headerSublabel={
authors ? `by ${authors.map((i) => i.name).join(", ")}` : ""
}
descriptionLabel={theme.data.description ?? "No description."} descriptionLabel={theme.data.description ?? "No description."}
toggleType={!settings.safeMode?.enabled ? "radio" : undefined} toggleType={!settings.safeMode?.enabled ? "radio" : undefined}
toggleValue={() => themes[theme.id].selected} toggleValue={() => themes[theme.id].selected}
onToggleChange={(v: boolean) => { onToggleChange={(v: boolean) => {
selectAndApply(v, theme); try {
selectTheme(v ? theme : null);
} catch (e: any) {
console.error("Error while selecting theme:", e);
}
}} }}
overflowTitle={theme.data.name} overflowTitle={theme.data.name}
overflowActions={[ actions={[
{ {
icon: "ic_sync_24px", icon: "CircleInformationIcon-primary",
label: Strings.REFETCH,
onPress: () => { onPress: () => {
fetchTheme(theme.id, theme.selected).then(() => { // Using dynamic import directly (without wrapping in function)
showToast(Strings.THEME_REFETCH_SUCCESSFUL, findAssetId("toast_image_saved")); // This returns a Promise that resolves to the module
}).catch(() => { const importPromise = import("./sheets/ThemeInfoActionSheet");
showToast(Strings.THEME_REFETCH_FAILED, findAssetId("Small")); showSheet("ThemeInfoActionSheet", importPromise, {
theme,
navigation,
}); });
}, },
}, },
{
icon: "copy",
label: Strings.COPY_URL,
onPress: () => {
clipboard.setString(theme.id);
showToast.showCopyToClipboard();
}
},
{
icon: "ic_message_delete",
label: Strings.DELETE,
isDestructive: true,
onPress: () => showConfirmationAlert({
title: Strings.HOLD_UP,
content: formatString("ARE_YOU_SURE_TO_DELETE_THEME", { name: theme.data.name }),
confirmText: Strings.DELETE,
cancelText: Strings.CANCEL,
confirmColor: "red",
onConfirm: () => {
removeTheme(theme.id).then(wasSelected => {
setRemoved(true);
if (wasSelected) selectAndApply(false, theme);
}).catch((e: Error) => {
showToast(e.message, findAssetId("Small"));
});
}
})
},
]} ]}
/> />
); );

View file

@ -5,6 +5,7 @@ import { useProxy } from "@core/vendetta/storage";
import { import {
getCurrentTheme, getCurrentTheme,
installTheme, installTheme,
removeTheme,
themes, themes,
VdThemeInfo, VdThemeInfo,
} from "@lib/addons/themes"; } from "@lib/addons/themes";
@ -14,15 +15,237 @@ import { Author } from "@lib/addons/types";
import { findAssetId } from "@lib/api/assets"; import { findAssetId } from "@lib/api/assets";
import { settings } from "@lib/api/settings"; import { settings } from "@lib/api/settings";
import { useObservable } from "@lib/api/storage"; import { useObservable } from "@lib/api/storage";
import { lazyDestructure } from "@lib/utils/lazy";
import safeFetch from "@lib/utils/safeFetch";
import Search from "@ui/components/Search";
import { findByProps } from "@metro";
import { NavigationNative, React, clipboard } from "@metro/common";
import { showSheet } from "@lib/ui/sheets";
import { import {
ActionSheet, ActionSheet,
BottomSheetTitleHeader, BottomSheetTitleHeader,
Button, Button,
Card,
FlashList,
IconButton,
Stack,
TableRowGroup, TableRowGroup,
TableSwitchRow, TableSwitchRow,
TableRowIcon, TableRowIcon,
Text,
AlertModal,
AlertActions,
AlertActionButton,
} from "@metro/common/components"; } from "@metro/common/components";
import { View } from "react-native"; import { View, Image } from "react-native";
const { useReducer } = React;
const { showSimpleActionSheet, hideActionSheet } = lazyDestructure(() =>
findByProps("showSimpleActionSheet"),
);
interface BaseAddonData {
name: string;
description: string;
authors: string[];
installUrl: string;
}
interface ThemeData extends BaseAddonData {}
enum Sort {
DateNewest = "Date Added (Newest First)",
DateOldest = "Date Added (Oldest First)",
NameAZ = "Name (A-Z)",
NameZA = "Name (Z-A)",
}
const THEME_URL =
"https://raw.githubusercontent.com/kmmiio99o/theme-marketplace/refs/heads/main/themes.json";
function BrowserThemeCard({
theme,
installing,
setInstalling,
setRefreshTick,
}: {
theme: ThemeData;
installing: Set<string>;
setInstalling: React.Dispatch<React.SetStateAction<Set<string>>>;
setRefreshTick: React.Dispatch<React.SetStateAction<number>>;
}) {
const { name, description, authors, installUrl } = theme;
useProxy(themes);
// Use installUrl as the theme ID (themes are keyed by URL)
const themeId = installUrl;
const isInstalling = installing.has(themeId);
const isInstalled = themes[themeId];
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const [rotation, setRotation] = React.useState(0);
const [pulse, setPulse] = React.useState(1);
const handleInstall = async () => {
if (isInstalling) return;
setInstalling((prev) => new Set([...prev, themeId]));
// Start animation
const animationInterval = setInterval(() => {
setRotation((prev) => (prev + 15) % 360);
setPulse((prev) => 0.8 + 0.2 * Math.sin(Date.now() / 200));
forceUpdate();
}, 100);
try {
await installTheme(themeId);
setRefreshTick((prev) => prev + 1);
forceUpdate();
} catch (e) {
console.error("Failed to install theme:", e);
} finally {
clearInterval(animationInterval);
setRotation(0);
setPulse(1);
setInstalling((prev) => {
const newSet = new Set(prev);
newSet.delete(themeId);
return newSet;
});
}
};
const handleUninstall = async () => {
try {
await removeTheme(themeId);
setRefreshTick((prev) => prev + 1);
forceUpdate();
} catch (e) {
console.error("Failed to uninstall theme:", e);
}
};
return (
<Card>
<Stack spacing={16}>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
}}
>
<View style={{ flexShrink: 1 }}>
<Text numberOfLines={1} variant="heading-lg/semibold">
{name}
</Text>
<Text variant="text-md/semibold" color="text-muted">
by {authors?.join(", ") || "Unknown"}
</Text>
</View>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<IconButton
size="sm"
variant="secondary"
icon={findAssetId("MoreHorizontalIcon")}
onPress={() => {
showSimpleActionSheet({
key: "ThemeShareSheet",
header: {
title: "Share Theme",
},
options: [
{
label: "Copy Install URL",
onPress: () => {
clipboard.setString(themeId);
// URL copied
},
},
],
});
}}
/>
{isInstalled ? (
<Button
size="sm"
text="Uninstall"
variant="destructive"
icon={findAssetId("TrashIcon")}
onPress={handleUninstall}
/>
) : (
<View style={{ opacity: pulse }}>
<Button
size="sm"
text={isInstalling ? "Installing..." : "Install"}
variant="primary"
disabled={isInstalling}
loading={isInstalling}
icon={
isInstalling ? (
<View
style={{ transform: [{ rotate: `${rotation}deg` }] }}
>
<Image
source={findAssetId("RetryIcon")}
style={{ width: 16, height: 16 }}
/>
</View>
) : (
findAssetId("DownloadIcon")
)
}
onPress={handleInstall}
/>
</View>
)}
</View>
</View>
<Text variant="text-md/medium">{description}</Text>
</Stack>
</Card>
);
}
interface BrowseThemePageProps {
themes: ThemeData[];
installing: Set<string>;
setInstalling: React.Dispatch<React.SetStateAction<Set<string>>>;
setRefreshTick: React.Dispatch<React.SetStateAction<number>>;
}
function BrowseThemePage(props: BrowseThemePageProps) {
useProxy(themes);
return (
<AddonPage<ThemeData>
CardComponent={({ item }) => (
<BrowserThemeCard
theme={item}
installing={props.installing}
setInstalling={props.setInstalling}
setRefreshTick={props.setRefreshTick}
/>
)}
title={Strings.THEMES}
searchKeywords={[
"name",
"description",
(t) => (t.authors || []).join(", "),
]}
sortOptions={{
"Date (Newest)": (a, b) => 0,
"Date (Oldest)": (a, b) => 0,
"Name (A-Z)": (a, b) => a.name.localeCompare(b.name),
"Name (Z-A)": (a, b) => b.name.localeCompare(a.name),
}}
items={props.themes}
/>
);
}
/** /**
* Theme options have been changed from radio groups to individual switches * Theme options have been changed from radio groups to individual switches
@ -32,47 +255,45 @@ import { View } from "react-native";
export default function Themes() { export default function Themes() {
useProxy(settings); useProxy(settings);
useProxy(themes); useProxy(themes);
const navigation = NavigationNative.useNavigation();
return ( const [mode, setMode] = React.useState<"installed" | "browse">("installed");
<AddonPage<VdThemeInfo> const [themesList, setThemesList] = React.useState<ThemeData[]>([]);
title={Strings.THEMES} const [loading, setLoading] = React.useState(false);
searchKeywords={[ const [error, setError] = React.useState<string | null>(null);
"data.name", const [installing, setInstalling] = React.useState<Set<string>>(new Set());
"data.description", const [refreshTick, setRefreshTick] = React.useState(0);
(p) => p.data.authors?.map((a: Author) => a.name).join(", ") ?? "",
]} React.useEffect(() => {
sortOptions={{ const headerButtons = [];
"Name (A-Z)": (a, b) => a.data.name.localeCompare(b.data.name),
"Name (Z-A)": (a, b) => b.data.name.localeCompare(a.data.name), // Add mode toggle button
}} headerButtons.push(
installAction={{
label: "Install a theme",
fetchFn: installTheme,
}}
items={Object.values(themes)}
safeModeHint={{
message: formatString("SAFE_MODE_NOTICE_THEMES", {
enabled: Boolean(settings.safeMode?.currentThemeId),
}),
footer: settings.safeMode?.currentThemeId && (
<Button <Button
size="small" key="mode-toggle"
text={Strings.DISABLE_THEME} size="sm"
onPress={() => delete settings.safeMode?.currentThemeId} variant="secondary"
style={{ marginTop: 8 }} text={mode === "installed" ? "Browse" : "Installed"}
/> icon={findAssetId(mode === "installed" ? "LinkIcon" : "DownloadIcon")}
), onPress={() => setMode(mode === "installed" ? "browse" : "installed")}
}} />,
CardComponent={ThemeCard} );
OptionsActionSheetComponent={() => {
// Add options button (always visible)
headerButtons.push(
<IconButton
key="options"
size="sm"
variant="secondary"
icon={findAssetId("MoreHorizontalIcon")}
onPress={() =>
showSheet("ThemeOptionsSheet", () => {
useObservable([colorsPref]); useObservable([colorsPref]);
return ( return (
<ActionSheet> <ActionSheet>
<BottomSheetTitleHeader title="Options" /> <BottomSheetTitleHeader title="Options" />
<View style={{ paddingVertical: 20, gap: 12 }}> <View style={{ paddingVertical: 20, gap: 12 }}>
{/* Changed from TableRadioGroup to individual TableSwitchRow components
for better UX - users can now toggle options individually */}
<TableRowGroup title="Override Theme Type"> <TableRowGroup title="Override Theme Type">
<TableSwitchRow <TableSwitchRow
label="Auto" label="Auto"
@ -92,7 +313,9 @@ export default function Themes() {
/> />
<TableSwitchRow <TableSwitchRow
label="Dark" label="Dark"
icon={<TableRowIcon source={findAssetId("ThemeDarkIcon")} />} icon={
<TableRowIcon source={findAssetId("ThemeDarkIcon")} />
}
value={colorsPref.type === "dark"} value={colorsPref.type === "dark"}
onValueChange={(enabled: boolean) => { onValueChange={(enabled: boolean) => {
colorsPref.type = enabled ? "dark" : undefined; colorsPref.type = enabled ? "dark" : undefined;
@ -104,7 +327,9 @@ export default function Themes() {
/> />
<TableSwitchRow <TableSwitchRow
label="Light" label="Light"
icon={<TableRowIcon source={findAssetId("ThemeLightIcon")} />} icon={
<TableRowIcon source={findAssetId("ThemeLightIcon")} />
}
value={colorsPref.type === "light"} value={colorsPref.type === "light"}
onValueChange={(enabled: boolean) => { onValueChange={(enabled: boolean) => {
colorsPref.type = enabled ? "light" : undefined; colorsPref.type = enabled ? "light" : undefined;
@ -136,7 +361,130 @@ export default function Themes() {
</View> </View>
</ActionSheet> </ActionSheet>
); );
})
}
/>,
);
navigation.setOptions({
headerRight: () => (
<View style={{ flexDirection: "row", gap: 8 }}>{headerButtons}</View>
),
});
}, [navigation, mode]);
const fetchThemes = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await safeFetch(THEME_URL);
if (!response.ok)
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const data = await response.json();
let themeList: ThemeData[] = [];
if (Array.isArray(data)) {
themeList = data;
} else if (data.OFFICIAL_THEMES) {
themeList = data.OFFICIAL_THEMES;
} else if (data.themes) {
themeList = data.themes;
} else if (data.THEMES) {
themeList = data.THEMES;
} else if (data.items) {
themeList = data.items;
}
setThemesList(themeList);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setThemesList([]);
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
if (mode === "browse") {
fetchThemes();
}
}, [mode, fetchThemes, refreshTick]);
if (mode === "installed") {
return (
<AddonPage<VdThemeInfo>
title={Strings.THEMES}
searchKeywords={[
"data.name",
"data.description",
(p) => p.data.authors?.map((a: Author) => a.name).join(", ") ?? "",
]}
sortOptions={{
"Name (A-Z)": (a, b) => a.data.name.localeCompare(b.data.name),
"Name (Z-A)": (a, b) => b.data.name.localeCompare(a.data.name),
}} }}
installAction={{
label: "Install a theme",
fetchFn: installTheme,
}}
items={Object.values(themes)}
safeModeHint={{
message: formatString("SAFE_MODE_NOTICE_THEMES", {
enabled: Boolean(settings.safeMode?.currentThemeId),
}),
footer: settings.safeMode?.currentThemeId && (
<Button
size="small"
text={Strings.DISABLE_THEME}
onPress={() => delete settings.safeMode?.currentThemeId}
style={{ marginTop: 8 }}
/>
),
}}
CardComponent={ThemeCard}
/>
);
}
// Browse mode
if (error) {
return (
<View
style={{
flex: 1,
paddingHorizontal: 8,
justifyContent: "center",
alignItems: "center",
}}
>
<Card style={{ gap: 8 }}>
<Text style={{ textAlign: "center" }} variant="heading-lg/bold">
An error occurred while fetching the repository
</Text>
<Text
style={{ textAlign: "center" }}
variant="text-sm/medium"
color="text-muted"
>
{error}
</Text>
<Button
size="lg"
text="Refetch"
onPress={fetchThemes}
icon={findAssetId("RetryIcon")}
/>
</Card>
</View>
);
}
return (
<BrowseThemePage
themes={themesList}
installing={installing}
setInstalling={setInstalling}
setRefreshTick={setRefreshTick}
/> />
); );
} }

View file

@ -0,0 +1,206 @@
import { findAssetId } from "@lib/api/assets";
import { purgeStorage } from "@lib/api/storage";
import { hideSheet } from "@lib/ui/sheets";
import { ActionSheet, Card, IconButton, Text } from "@metro/common/components";
import { clipboard } from "@metro/common";
import React, { ComponentProps, useEffect, useState } from "react";
import { ScrollView, View, TouchableOpacity } from "react-native";
import { showToast } from "@ui/toasts";
import { showConfirmationAlert } from "@core/vendetta/alerts";
import {
fetchTheme,
removeTheme,
selectTheme,
VdThemeInfo,
} from "@lib/addons/themes";
import { formatString, Strings } from "@core/i18n";
interface ThemeInfoActionSheetProps {
theme: VdThemeInfo;
navigation: any;
}
function ThemeInfoIconButton(props: ComponentProps<typeof IconButton>) {
const { onPress } = props;
props.onPress &&= () => {
hideSheet("ThemeInfoActionSheet");
onPress?.();
};
return <IconButton {...props} label={props.label} />;
}
// Theme title component for header
function TitleComponent({ theme }: { theme: VdThemeInfo }) {
const { authors } = theme.data;
return (
<View style={{ gap: 4 }}>
<View>
<Text variant="heading-xl/semibold">{theme.data.name}</Text>
</View>
<View style={{ flexDirection: "row", flexShrink: 1 }}>
{authors && authors.length > 0 && (
<TouchableOpacity
style={{
flexDirection: "row",
gap: 8,
alignItems: "center",
paddingVertical: 4,
paddingHorizontal: 8,
backgroundColor: "#00000016",
borderRadius: 32,
}}
disabled={!authors.some((a) => a.id)}
>
<Text variant="text-md/medium">
by {authors.map((a) => a.name).join(", ")}
</Text>
</TouchableOpacity>
)}
</View>
</View>
);
}
export default function ThemeInfoActionSheet({
theme,
navigation,
}: ThemeInfoActionSheetProps) {
// Use component state to track theme data
const [themeState, setThemeState] = useState({ ...theme });
const [loading, setLoading] = useState(false);
// Periodic refresh to detect external changes to theme state
useEffect(() => {
const interval = setInterval(() => {
setThemeState({ ...theme });
}, 500);
return () => clearInterval(interval);
}, [theme]);
const copyThemeUrl = () => {
// Copy theme ID as URL
clipboard.setString(themeState.id);
if (typeof showToast?.showCopyToClipboard === "function") {
showToast.showCopyToClipboard();
} else {
showToast("Copied to clipboard");
}
};
const refetchTheme = async () => {
setLoading(true);
try {
await fetchTheme(themeState.id, themeState.selected);
showToast("Theme refreshed successfully");
} catch (e) {
console.error("Failed to refresh theme:", e);
showToast("Failed to refresh theme");
} finally {
setLoading(false);
}
};
const removeThemeHandler = () => {
showConfirmationAlert({
title: Strings.HOLD_UP,
content: formatString("ARE_YOU_SURE_TO_DELETE_THEME", {
name: themeState.data.name,
}),
confirmText: Strings.DELETE,
cancelText: Strings.CANCEL,
confirmColor: "red",
onConfirm: async () => {
hideSheet("ThemeInfoActionSheet");
try {
const wasSelected = await removeTheme(themeState.id);
if (wasSelected) selectTheme(null);
showToast("Theme removed successfully");
} catch (e) {
console.error("Failed to remove theme:", e);
showToast("Failed to remove theme");
}
},
});
};
const applyTheme = () => {
try {
selectTheme(themeState);
hideSheet("ThemeInfoActionSheet");
showToast(`Applied theme: ${themeState.data.name}`);
} catch (e) {
console.error("Failed to apply theme:", e);
showToast("Failed to apply theme");
}
};
return (
<ActionSheet>
<ScrollView contentContainerStyle={{ gap: 12, marginBottom: 12 }}>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingVertical: 24,
justifyContent: "space-between",
width: "100%",
}}
>
<TitleComponent theme={themeState} />
</View>
<View
style={{
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
flexWrap: "wrap",
gap: 16,
paddingHorizontal: 4,
}}
>
<ThemeInfoIconButton
label="Apply"
variant="secondary"
icon={findAssetId("CheckmarkLargeIcon")}
onPress={applyTheme}
/>
<ThemeInfoIconButton
label="Refetch"
variant="secondary"
icon={findAssetId("RetryIcon")}
onPress={refetchTheme}
disabled={loading}
/>
<ThemeInfoIconButton
label="Copy URL"
variant="secondary"
icon={findAssetId("LinkIcon")}
onPress={copyThemeUrl}
/>
<ThemeInfoIconButton
label="Uninstall"
variant="secondary"
icon={findAssetId("TrashIcon")}
onPress={removeThemeHandler}
/>
</View>
<Card>
<Text
variant="text-md/semibold"
color="text-primary"
style={{ marginBottom: 4 }}
>
Description
</Text>
<Text variant="text-md/medium">
{themeState.data.description || "No description provided."}
</Text>
</Card>
</ScrollView>
</ActionSheet>
);
}

View file

@ -5,7 +5,10 @@ import * as themes from "@lib/addons/themes";
import * as assets from "@lib/api/assets"; import * as assets from "@lib/api/assets";
import * as commands from "@lib/api/commands"; import * as commands from "@lib/api/commands";
import * as debug from "@lib/api/debug"; import * as debug from "@lib/api/debug";
import { getVendettaLoaderIdentity, isPyonLoader } from "@lib/api/native/loader"; import {
getVendettaLoaderIdentity,
isPyonLoader,
} from "@lib/api/native/loader";
import patcher from "@lib/api/patcher"; import patcher from "@lib/api/patcher";
import { loaderConfig, settings } from "@lib/api/settings"; import { loaderConfig, settings } from "@lib/api/settings";
import * as utils from "@lib/utils"; import * as utils from "@lib/utils";
@ -32,9 +35,11 @@ export async function createVdPluginObject(plugin: VendettaPlugin) {
id: plugin.id, id: plugin.id,
manifest: plugin.manifest, manifest: plugin.manifest,
// Wrapping this with wrapSync is NOT an option. // Wrapping this with wrapSync is NOT an option.
storage: await createStorage<Record<string, any>>(storage.createMMKVBackend(plugin.id)), storage: await createStorage<Record<string, any>>(
storage.createMMKVBackend(plugin.id),
),
}, },
logger: new LoggerClass(`Kettu » ${plugin.manifest.name}`), logger: new LoggerClass(`ShiggyCord » ${plugin.manifest.name}`),
}; };
} }
@ -44,15 +49,20 @@ export const initVendettaObject = (): any => {
// ["trackThis", "trackThat"].forEach(p => find(m => m[p])) - would not // ["trackThis", "trackThat"].forEach(p => find(m => m[p])) - would not
const createStackBasedFilter = (fn: any) => { const createStackBasedFilter = (fn: any) => {
return (filter: (m: any) => boolean) => { return (filter: (m: any) => boolean) => {
return fn(metro.factories.createSimpleFilter(filter, cyrb64Hash(new Error().stack!))); return fn(
metro.factories.createSimpleFilter(
filter,
cyrb64Hash(new Error().stack!),
),
);
}; };
}; };
const api = window.vendetta = { const api = (window.vendetta = {
patcher: { patcher: {
before: patcher.before, before: patcher.before,
after: patcher.after, after: patcher.after,
instead: patcher.instead instead: patcher.instead,
}, },
metro: { metro: {
modules: window.modules, modules: window.modules,
@ -75,9 +85,15 @@ export const initVendettaObject = (): any => {
...module, ...module,
ActionSheetTitleHeader: module.BottomSheetTitleHeader, ActionSheetTitleHeader: module.BottomSheetTitleHeader,
ActionSheetContentContainer: ({ children }: any) => { ActionSheetContentContainer: ({ children }: any) => {
useEffect(() => console.warn("Discord has removed 'ActionSheetContentContainer', please move into something else. This has been temporarily replaced with View"), []); useEffect(
() =>
console.warn(
"Discord has removed 'ActionSheetContentContainer', please move into something else. This has been temporarily replaced with View",
),
[],
);
return createElement(View, null, children); return createElement(View, null, children);
} },
}; };
} }
} }
@ -93,11 +109,16 @@ export const initVendettaObject = (): any => {
return metro.findByName(name, defaultExp ?? true); return metro.findByName(name, defaultExp ?? true);
}, },
findByNameAll: (name: string, defaultExp: boolean = true) => metro.findByNameAll(name, defaultExp), findByNameAll: (name: string, defaultExp: boolean = true) =>
findByDisplayName: (displayName: string, defaultExp: boolean = true) => metro.findByDisplayName(displayName, defaultExp), metro.findByNameAll(name, defaultExp),
findByDisplayNameAll: (displayName: string, defaultExp: boolean = true) => metro.findByDisplayNameAll(displayName, defaultExp), findByDisplayName: (displayName: string, defaultExp: boolean = true) =>
findByTypeName: (typeName: string, defaultExp: boolean = true) => metro.findByTypeName(typeName, defaultExp), metro.findByDisplayName(displayName, defaultExp),
findByTypeNameAll: (typeName: string, defaultExp: boolean = true) => metro.findByTypeNameAll(typeName, defaultExp), findByDisplayNameAll: (displayName: string, defaultExp: boolean = true) =>
metro.findByDisplayNameAll(displayName, defaultExp),
findByTypeName: (typeName: string, defaultExp: boolean = true) =>
metro.findByTypeName(typeName, defaultExp),
findByTypeNameAll: (typeName: string, defaultExp: boolean = true) =>
metro.findByTypeNameAll(typeName, defaultExp),
findByStoreName: (name: string) => metro.findByStoreName(name), findByStoreName: (name: string) => metro.findByStoreName(name),
common: { common: {
constants: common.constants, constants: common.constants,
@ -106,7 +127,7 @@ export const initVendettaObject = (): any => {
url: common.url, url: common.url,
toasts: common.toasts, toasts: common.toasts,
stylesheet: { stylesheet: {
createThemedStyleSheet createThemedStyleSheet,
}, },
clipboard: common.clipboard, clipboard: common.clipboard,
assets: common.assets, assets: common.assets,
@ -122,29 +143,37 @@ export const initVendettaObject = (): any => {
moment: require("moment"), moment: require("moment"),
chroma: require("chroma-js"), chroma: require("chroma-js"),
lodash: require("lodash"), lodash: require("lodash"),
util: require("util") util: require("util"),
} },
}, },
constants: { constants: {
DISCORD_SERVER: "https://discord.gg/n9QQ4XhhJP", DISCORD_SERVER: "https://discord.gg/n9QQ4XhhJP",
GITHUB: "https://github.com/vendetta-mod", GITHUB: "https://github.com/vendetta-mod",
PROXY_PREFIX: "https://vd-plugins.github.io/proxy", PROXY_PREFIX: "https://vd-plugins.github.io/proxy",
HTTP_REGEX: /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&/=]*)$/, HTTP_REGEX:
HTTP_REGEX_MULTI: /https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&//=]*)/g, /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&/=]*)$/,
HTTP_REGEX_MULTI:
/https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&//=]*)/g,
DISCORD_SERVER_ID: "1015931589865246730", DISCORD_SERVER_ID: "1015931589865246730",
PLUGINS_CHANNEL_ID: "1091880384561684561", PLUGINS_CHANNEL_ID: "1091880384561684561",
THEMES_CHANNEL_ID: "1091880434939482202", THEMES_CHANNEL_ID: "1091880434939482202",
}, },
utils: { utils: {
findInReactTree: (tree: { [key: string]: any; }, filter: any) => utils.findInReactTree(tree, filter), findInReactTree: (tree: { [key: string]: any }, filter: any) =>
findInTree: (tree: any, filter: any, options: any) => utils.findInTree(tree, filter, options), utils.findInReactTree(tree, filter),
safeFetch: (input: RequestInfo | URL, options?: RequestInit | undefined, timeout?: number | undefined) => utils.safeFetch(input, options, timeout), findInTree: (tree: any, filter: any, options: any) =>
unfreeze: (obj: object) => Object.isFrozen(obj) ? ({ ...obj }) : obj, utils.findInTree(tree, filter, options),
without: (object: any, ...keys: any) => omit(object, keys) safeFetch: (
input: RequestInfo | URL,
options?: RequestInit | undefined,
timeout?: number | undefined,
) => utils.safeFetch(input, options, timeout),
unfreeze: (obj: object) => (Object.isFrozen(obj) ? { ...obj } : obj),
without: (object: any, ...keys: any) => omit(object, keys),
}, },
debug: { debug: {
connectToDebugger: (url: string) => debug.connectToDebugger(url), connectToDebugger: (url: string) => debug.connectToDebugger(url),
getDebugInfo: () => debug.getDebugInfo() getDebugInfo: () => debug.getDebugInfo(),
}, },
ui: { ui: {
components: { components: {
@ -152,29 +181,38 @@ export const initVendettaObject = (): any => {
General: common.ReactNative, General: common.ReactNative,
Alert: commonComponents.LegacyAlert, Alert: commonComponents.LegacyAlert,
Button: commonComponents.CompatButton, Button: commonComponents.CompatButton,
HelpMessage: (...props: any[]) => <commonComponents.HelpMessage {...props} />, HelpMessage: (...props: any[]) => (
SafeAreaView: (...props: any[]) => <commonComponents.SafeAreaView {...props} />, <commonComponents.HelpMessage {...props} />
),
SafeAreaView: (...props: any[]) => (
<commonComponents.SafeAreaView {...props} />
),
Summary: components.Summary, Summary: components.Summary,
ErrorBoundary: components.ErrorBoundary, ErrorBoundary: components.ErrorBoundary,
Codeblock: components.Codeblock, Codeblock: components.Codeblock,
Search: components.Search Search: components.Search,
}, },
toasts: { toasts: {
showToast: (content: string, asset?: number) => toasts.showToast(content, asset) showToast: (content: string, asset?: number) =>
toasts.showToast(content, asset),
}, },
alerts: { alerts: {
showConfirmationAlert: (options: any) => alerts.showConfirmationAlert(options), showConfirmationAlert: (options: any) =>
showCustomAlert: (component: React.ComponentType<any>, props: any) => alerts.showCustomAlert(component, props), alerts.showConfirmationAlert(options),
showInputAlert: (options: any) => alerts.showInputAlert(options) showCustomAlert: (component: React.ComponentType<any>, props: any) =>
alerts.showCustomAlert(component, props),
showInputAlert: (options: any) => alerts.showInputAlert(options),
}, },
assets: { assets: {
all: new Proxy<any>({}, { all: new Proxy<any>(
{},
{
get(cache, p) { get(cache, p) {
if (typeof p !== "string") return undefined; if (typeof p !== "string") return undefined;
if (cache[p]) return cache[p]; if (cache[p]) return cache[p];
for (const asset of assets.iterateAssets()) { for (const asset of assets.iterateAssets()) {
if (asset.name) return cache[p] = asset; if (asset.name) return (cache[p] = asset);
} }
}, },
ownKeys(cache) { ownKeys(cache) {
@ -187,35 +225,40 @@ export const initVendettaObject = (): any => {
return [...keys]; return [...keys];
}, },
}), },
),
find: (filter: (a: any) => boolean) => assets.findAsset(filter), find: (filter: (a: any) => boolean) => assets.findAsset(filter),
getAssetByName: (name: string) => assets.findAsset(name), getAssetByName: (name: string) => assets.findAsset(name),
getAssetByID: (id: number) => assets.findAsset(id), getAssetByID: (id: number) => assets.findAsset(id),
getAssetIDByName: (name: string) => assets.findAssetId(name) getAssetIDByName: (name: string) => assets.findAssetId(name),
}, },
semanticColors: color.semanticColors, semanticColors: color.semanticColors,
rawColors: color.rawColors rawColors: color.rawColors,
}, },
plugins: { plugins: {
plugins: VdPluginManager.plugins, plugins: VdPluginManager.plugins,
fetchPlugin: (source: string) => VdPluginManager.fetchPlugin(source), fetchPlugin: (source: string) => VdPluginManager.fetchPlugin(source),
installPlugin: (source: string, enabled = true) => VdPluginManager.installPlugin(source, enabled), installPlugin: (source: string, enabled = true) =>
VdPluginManager.installPlugin(source, enabled),
startPlugin: (id: string) => VdPluginManager.startPlugin(id), startPlugin: (id: string) => VdPluginManager.startPlugin(id),
stopPlugin: (id: string, disable = true) => VdPluginManager.stopPlugin(id, disable), stopPlugin: (id: string, disable = true) =>
VdPluginManager.stopPlugin(id, disable),
removePlugin: (id: string) => VdPluginManager.removePlugin(id), removePlugin: (id: string) => VdPluginManager.removePlugin(id),
getSettings: (id: string) => VdPluginManager.getSettings(id) getSettings: (id: string) => VdPluginManager.getSettings(id),
}, },
themes: { themes: {
themes: themes.themes, themes: themes.themes,
fetchTheme: (id: string, selected?: boolean) => themes.fetchTheme(id, selected), fetchTheme: (id: string, selected?: boolean) =>
themes.fetchTheme(id, selected),
installTheme: (id: string) => themes.installTheme(id), installTheme: (id: string) => themes.installTheme(id),
selectTheme: (id: string) => themes.selectTheme(id === "default" ? null : themes.themes[id]), selectTheme: (id: string) =>
themes.selectTheme(id === "default" ? null : themes.themes[id]),
removeTheme: (id: string) => themes.removeTheme(id), removeTheme: (id: string) => themes.removeTheme(id),
getCurrentTheme: () => themes.getThemeFromLoader(), getCurrentTheme: () => themes.getThemeFromLoader(),
updateThemes: () => themes.updateThemes() updateThemes: () => themes.updateThemes(),
}, },
commands: { commands: {
registerCommand: commands.registerCommand registerCommand: commands.registerCommand,
}, },
storage: { storage: {
createProxy: (target: any) => storage.createProxy(target), createProxy: (target: any) => storage.createProxy(target),
@ -231,7 +274,7 @@ export const initVendettaObject = (): any => {
} }
return storage.createFileBackend(file); return storage.createFileBackend(file);
} },
}, },
settings, settings,
loader: { loader: {
@ -245,13 +288,13 @@ export const initVendettaObject = (): any => {
error: (...message: any) => console.error(...message), error: (...message: any) => console.error(...message),
time: (...message: any) => console.time(...message), time: (...message: any) => console.time(...message),
trace: (...message: any) => console.trace(...message), trace: (...message: any) => console.trace(...message),
verbose: (...message: any) => console.log(...message) verbose: (...message: any) => console.log(...message),
}, },
version: debug.versionHash, version: debug.versionHash,
unload: () => { unload: () => {
delete window.vendetta; delete window.vendetta;
}, },
}; });
return () => api.unload(); return () => api.unload();
}; };

View file

@ -1,4 +1,10 @@
import { awaitStorage, createMMKVBackend, createStorage, purgeStorage, wrapSync } from "@core/vendetta/storage"; import {
awaitStorage,
createMMKVBackend,
createStorage,
purgeStorage,
wrapSync,
} from "@core/vendetta/storage";
import { Author } from "@lib/addons/types"; import { Author } from "@lib/addons/types";
import { settings } from "@lib/api/settings"; import { settings } from "@lib/api/settings";
import { safeFetch } from "@lib/utils"; import { safeFetch } from "@lib/utils";
@ -32,7 +38,11 @@ export interface VendettaPlugin {
js: string; js: string;
} }
const plugins = wrapSync(createStorage<Record<string, VendettaPlugin>>(createMMKVBackend("VENDETTA_PLUGINS"))); const plugins = wrapSync(
createStorage<Record<string, VendettaPlugin>>(
createMMKVBackend("VENDETTA_PLUGINS"),
),
);
const pluginInstance: Record<string, EvaledPlugin> = {}; const pluginInstance: Record<string, EvaledPlugin> = {};
export const VdPluginManager = { export const VdPluginManager = {
@ -40,7 +50,10 @@ export const VdPluginManager = {
async pluginFetch(url: string) { async pluginFetch(url: string) {
if (url.startsWith(VD_PROXY_PREFIX)) { if (url.startsWith(VD_PROXY_PREFIX)) {
url = url url = url
.replace("https://bunny-mod.github.io/plugins-proxy", BUNNY_PROXY_PREFIX) .replace(
"https://bunny-mod.github.io/plugins-proxy",
BUNNY_PROXY_PREFIX,
)
.replace(VD_PROXY_PREFIX, BUNNY_PROXY_PREFIX); .replace(VD_PROXY_PREFIX, BUNNY_PROXY_PREFIX);
} }
@ -54,7 +67,9 @@ export const VdPluginManager = {
let pluginManifest: PluginManifest; let pluginManifest: PluginManifest;
try { try {
pluginManifest = await (await this.pluginFetch(id + "manifest.json")).json(); pluginManifest = await (
await this.pluginFetch(id + "manifest.json")
).json();
} catch { } catch {
throw new Error(`Failed to fetch manifest for ${id}`); throw new Error(`Failed to fetch manifest for ${id}`);
} }
@ -64,11 +79,14 @@ export const VdPluginManager = {
if (existingPlugin?.manifest.hash !== pluginManifest.hash) { if (existingPlugin?.manifest.hash !== pluginManifest.hash) {
try { try {
// by polymanifest spec, plugins should always specify their main file, but just in case // by polymanifest spec, plugins should always specify their main file, but just in case
pluginJs = await (await this.pluginFetch(id + (pluginManifest.main || "index.js"))).text(); pluginJs = await (
await this.pluginFetch(id + (pluginManifest.main || "index.js"))
).text();
} catch {} // Empty catch, checked below } catch {} // Empty catch, checked below
} }
if (!pluginJs && !existingPlugin) throw new Error(`Failed to fetch JS for ${id}`); if (!pluginJs && !existingPlugin)
throw new Error(`Failed to fetch JS for ${id}`);
plugins[id] = { plugins[id] = {
id: id, id: id,
@ -81,7 +99,8 @@ export const VdPluginManager = {
async installPlugin(id: string, enabled = true) { async installPlugin(id: string, enabled = true) {
if (!id.endsWith("/")) id += "/"; if (!id.endsWith("/")) id += "/";
if (typeof id !== "string" || id in plugins) throw new Error("Plugin already installed"); if (typeof id !== "string" || id in plugins)
throw new Error("Plugin already installed");
await this.fetchPlugin(id); await this.fetchPlugin(id);
if (enabled) await this.startPlugin(id); if (enabled) await this.startPlugin(id);
}, },
@ -96,9 +115,11 @@ export const VdPluginManager = {
id: plugin.id, id: plugin.id,
manifest: plugin.manifest, manifest: plugin.manifest,
// Wrapping this with wrapSync is NOT an option. // Wrapping this with wrapSync is NOT an option.
storage: await createStorage<Record<string, any>>(createMMKVBackend(plugin.id)), storage: await createStorage<Record<string, any>>(
createMMKVBackend(plugin.id),
),
}, },
logger: new LoggerClass(`Kettu » ${plugin.manifest.name}`), logger: new LoggerClass(`ShiggyCord » ${plugin.manifest.name}`),
}; };
const pluginString = `vendetta=>{return ${plugin.js}}\n//# sourceURL=${plugin.id}`; const pluginString = `vendetta=>{return ${plugin.js}}\n//# sourceURL=${plugin.id}`;
@ -120,7 +141,10 @@ export const VdPluginManager = {
} }
plugin.enabled = true; plugin.enabled = true;
} catch (e) { } catch (e) {
logger.error(`Plugin ${plugin.id} errored whilst loading, and will be unloaded`, e); logger.error(
`Plugin ${plugin.id} errored whilst loading, and will be unloaded`,
e,
);
try { try {
pluginInstance[plugin.id]?.onUnload?.(); pluginInstance[plugin.id]?.onUnload?.();
@ -169,17 +193,33 @@ export const VdPluginManager = {
if (!settings.safeMode?.enabled) { if (!settings.safeMode?.enabled) {
// Loop over any plugin that is enabled, update it if allowed, then start it. // Loop over any plugin that is enabled, update it if allowed, then start it.
await Promise.allSettled(allIds.filter(pl => plugins[pl].enabled).map(async pl => (plugins[pl].update && await this.fetchPlugin(pl).catch((e: Error) => logger.error(e.message)), await this.startPlugin(pl)))); await Promise.allSettled(
allIds
.filter((pl) => plugins[pl].enabled)
.map(
async (pl) => (
plugins[pl].update &&
(await this.fetchPlugin(pl).catch((e: Error) =>
logger.error(e.message),
)),
await this.startPlugin(pl)
),
),
);
// Wait for the above to finish, then update all disabled plugins that are allowed to. // Wait for the above to finish, then update all disabled plugins that are allowed to.
allIds.filter(pl => !plugins[pl].enabled && plugins[pl].update).forEach(pl => this.fetchPlugin(pl)); allIds
.filter((pl) => !plugins[pl].enabled && plugins[pl].update)
.forEach((pl) => this.fetchPlugin(pl));
} }
return () => this.stopAllPlugins(); return () => this.stopAllPlugins();
}, },
stopAllPlugins() { stopAllPlugins() {
return Object.keys(pluginInstance).forEach(p => this.stopPlugin(p, false)); return Object.keys(pluginInstance).forEach((p) =>
this.stopPlugin(p, false),
);
}, },
getSettings: (id: string) => pluginInstance[id]?.settings getSettings: (id: string) => pluginInstance[id]?.settings,
}; };

View file

@ -5,7 +5,7 @@ const { instead } = require("spitroast");
// @ts-ignore - window is defined later in the bundle, so we assign it early // @ts-ignore - window is defined later in the bundle, so we assign it early
globalThis.window = globalThis; globalThis.window = globalThis;
async function initializeKettu() { async function initializeShiggyCord() {
try { try {
// Make 'freeze' and 'seal' do nothing // Make 'freeze' and 'seal' do nothing
Object.freeze = Object.seal = Object; Object.freeze = Object.seal = Object;
@ -17,12 +17,14 @@ async function initializeKettu() {
const stack = e instanceof Error ? e.stack : undefined; const stack = e instanceof Error ? e.stack : undefined;
console.log(stack ?? e?.toString?.() ?? e); console.log(stack ?? e?.toString?.() ?? e);
alert([ alert(
"Failed to load Kettu!\n", [
"Failed to load ShiggyCord!\n",
`Build Number: ${ClientInfoManager.getConstants().Build}`, `Build Number: ${ClientInfoManager.getConstants().Build}`,
`Kettu: ${version}`, `ShiggyCord: ${version}`,
stack || e?.toString?.(), stack || e?.toString?.(),
].join("\n")); ].join("\n"),
);
} }
} }
@ -46,9 +48,12 @@ if (typeof window.__r === "undefined") {
method: string, method: string,
condition?: (...args: any[]) => boolean, condition?: (...args: any[]) => boolean,
resume?: (queue: DeferredQueue) => void, resume?: (queue: DeferredQueue) => void,
returnWith?: (queue: DeferredQueue) => any returnWith?: (queue: DeferredQueue) => any,
) => { ) => {
const restore = instead(method, object, function (this: any, args: any[], original: any) { const restore = instead(
method,
object,
function (this: any, args: any[], original: any) {
if (!condition || condition(...args)) { if (!condition || condition(...args)) {
const queue: DeferredQueue = { object, method, args, resume }; const queue: DeferredQueue = { object, method, args, resume };
deferredCalls.push(queue); deferredCalls.push(queue);
@ -57,10 +62,11 @@ if (typeof window.__r === "undefined") {
// If the condition is not met, we execute the original method immediately // If the condition is not met, we execute the original method immediately
return original.apply(this, args); return original.apply(this, args);
}); },
);
unpatches.push(restore); unpatches.push(restore);
} };
const resumeDeferred = () => { const resumeDeferred = () => {
for (const queue of deferredCalls) { for (const queue of deferredCalls) {
@ -74,7 +80,7 @@ if (typeof window.__r === "undefined") {
} }
deferredCalls.length = 0; deferredCalls.length = 0;
} };
const onceIndexRequired = (originalRequire: Metro.RequireFn) => { const onceIndexRequired = (originalRequire: Metro.RequireFn) => {
// We hold calls from the native side // We hold calls from the native side
@ -85,13 +91,15 @@ if (typeof window.__r === "undefined") {
"callFunctionReturnFlushedQueue", "callFunctionReturnFlushedQueue",
// If the call is to AppRegistry, we want to defer it because it is not yet registered (Revenge delays it) // If the call is to AppRegistry, we want to defer it because it is not yet registered (Revenge delays it)
// Same goes to the non-callable modules, which are not registered yet, so we ensure that only registered ones can get through // Same goes to the non-callable modules, which are not registered yet, so we ensure that only registered ones can get through
(...args) => args[0] === "AppRegistry" || !batchedBridge.getCallableModule(args[0]), (...args) =>
args[0] === "AppRegistry" ||
!batchedBridge.getCallableModule(args[0]),
({ args }) => { ({ args }) => {
if (batchedBridge.getCallableModule(args[0])) { if (batchedBridge.getCallableModule(args[0])) {
batchedBridge.__callFunction(...args); batchedBridge.__callFunction(...args);
} }
}, },
() => batchedBridge.flushedQueue() () => batchedBridge.flushedQueue(),
); );
} }
@ -101,7 +109,7 @@ if (typeof window.__r === "undefined") {
} }
const startDiscord = async () => { const startDiscord = async () => {
await initializeKettu(); await initializeShiggyCord();
for (const unpatch of unpatches) unpatch(); for (const unpatch of unpatches) unpatch();
unpatches.length = 0; unpatches.length = 0;
@ -111,7 +119,7 @@ if (typeof window.__r === "undefined") {
}; };
startDiscord(); startDiscord();
} };
Object.defineProperties(globalThis, { Object.defineProperties(globalThis, {
__r: { __r: {
@ -122,12 +130,13 @@ if (typeof window.__r === "undefined") {
_requireFunc = function patchedRequire(a: number) { _requireFunc = function patchedRequire(a: number) {
// Initializing index.ts(x) // Initializing index.ts(x)
if (a === 0) { if (a === 0) {
if (window.modules instanceof Map) window.modules = Object.fromEntries(window.modules); if (window.modules instanceof Map)
window.modules = Object.fromEntries(window.modules);
onceIndexRequired(v); onceIndexRequired(v);
_requireFunc = v; _requireFunc = v;
} else return v(a); } else return v(a);
}; };
} },
}, },
__d: { __d: {
configurable: true, configurable: true,
@ -138,9 +147,11 @@ if (typeof window.__r === "undefined") {
} }
return this.value; return this.value;
}, },
set(v) { this.value = v; } set(v) {
} this.value = v;
},
},
}); });
} else { } else {
initializeKettu(); initializeShiggyCord();
} }

View file

@ -30,10 +30,10 @@ export default async () => {
initSettings(), initSettings(),
initFixes(), initFixes(),
// patchErrorBoundary(), // patchErrorBoundary(),
updatePlugins() updatePlugins(),
]).then( ]).then(
// Push them all to unloader // Push them all to unloader
u => u.forEach(f => f && lib.unload.push(f)) (u) => u.forEach((f) => f && lib.unload.push(f)),
); );
// Assign window object // Assign window object
@ -41,15 +41,15 @@ export default async () => {
// Once done, load Vendetta plugins // Once done, load Vendetta plugins
VdPluginManager.initPlugins() VdPluginManager.initPlugins()
.then(u => lib.unload.push(u)) .then((u) => lib.unload.push(u))
.catch(() => alert("Failed to initialize Vendetta plugins")); .catch(() => alert("Failed to initialize Vendetta plugins"));
// And then, load Kettu plugins // And then, load ShiggyCord plugins
initPlugins(); initPlugins();
// Update the fonts // Update the fonts
updateFonts(); updateFonts();
// We good :) // We good :)
logger.log("Kettu is ready!"); logger.log("ShiggyCord is ready!");
}; };

View file

@ -1,4 +1,9 @@
import { createFileBackend, createMMKVBackend, createStorage, wrapSync } from "@core/vendetta/storage"; import {
createFileBackend,
createMMKVBackend,
createStorage,
wrapSync,
} from "@core/vendetta/storage";
import { getLoaderConfigPath } from "@lib/api/native/loader"; import { getLoaderConfigPath } from "@lib/api/native/loader";
export interface Settings { export interface Settings {
@ -20,13 +25,17 @@ export interface LoaderConfig {
loadReactDevTools: boolean; loadReactDevTools: boolean;
} }
export const settings = wrapSync(createStorage<Settings>(createMMKVBackend("VENDETTA_SETTINGS"))); export const settings = wrapSync(
createStorage<Settings>(createMMKVBackend("VENDETTA_SETTINGS")),
);
export const loaderConfig = wrapSync(createStorage<LoaderConfig>( export const loaderConfig = wrapSync(
createStorage<LoaderConfig>(
createFileBackend(getLoaderConfigPath(), { createFileBackend(getLoaderConfigPath(), {
customLoadUrl: { customLoadUrl: {
enabled: false, enabled: false,
url: "http://localhost:4040/kettu.js" url: "http://localhost:4040/shiggycord.js",
} },
}) }),
)); ),
);

View file

@ -10,9 +10,9 @@ export interface RowConfig {
onPress?: () => any; onPress?: () => any;
render?: Parameters<typeof lazy>[0]; render?: Parameters<typeof lazy>[0];
icon?: ImageURISource | number; icon?: ImageURISource | number;
IconComponent?: React.ReactNode, IconComponent?: React.ReactNode;
usePredicate?: () => boolean, usePredicate?: () => boolean;
useTrailing?: () => string | JSX.Element, useTrailing?: () => string | JSX.Element;
rawTabsConfig?: Record<string, any>; rawTabsConfig?: Record<string, any>;
} }
@ -20,7 +20,7 @@ export const registeredSections = {} as {
[key: string]: RowConfig[]; [key: string]: RowConfig[];
}; };
export function registerSection(section: { name: string; items: RowConfig[]; }) { export function registerSection(section: { name: string; items: RowConfig[] }) {
registeredSections[section.name] = section.items; registeredSections[section.name] = section.items;
return () => delete registeredSections[section.name]; return () => delete registeredSections[section.name];
} }
@ -29,9 +29,9 @@ export function registerSection(section: { name: string; items: RowConfig[]; })
* @internal * @internal
*/ */
export function patchSettings() { export function patchSettings() {
const unpatches = new Array<() => boolean>; const unpatches = new Array<() => boolean>();
patchTabsUI(unpatches); patchTabsUI(unpatches);
return () => unpatches.forEach(u => u()); return () => unpatches.forEach((u) => u());
} }

View file

@ -18,8 +18,10 @@ function useIsFirstRender() {
} }
export function patchTabsUI(unpatches: (() => void | boolean)[]) { export function patchTabsUI(unpatches: (() => void | boolean)[]) {
const getRows = () => Object.values(registeredSections) const getRows = () =>
.flatMap(sect => sect.map(row => ({ Object.values(registeredSections)
.flatMap((sect) =>
sect.map((row) => ({
[row.key]: { [row.key]: {
type: "pressable", type: "pressable",
title: row.title, title: row.title,
@ -29,9 +31,10 @@ export function patchTabsUI(unpatches: (() => void | boolean)[]) {
useTrailing: row.useTrailing, useTrailing: row.useTrailing,
onPress: wrapOnPress(row.onPress, null, row.render, row.title()), onPress: wrapOnPress(row.onPress, null, row.render, row.title()),
withArrow: true, withArrow: true,
...row.rawTabsConfig ...row.rawTabsConfig,
} },
}))) })),
)
.reduce((a, c) => Object.assign(a, c)); .reduce((a, c) => Object.assign(a, c));
const origRendererConfig = settingConstants.SETTING_RENDERER_CONFIG; const origRendererConfig = settingConstants.SETTING_RENDERER_CONFIG;
@ -47,28 +50,28 @@ export function patchTabsUI(unpatches: (() => void | boolean)[]) {
title: () => "Kettu", title: () => "Kettu",
screen: { screen: {
route: "VendettaCustomPage", route: "VendettaCustomPage",
getComponent: () => CustomPageRenderer getComponent: () => CustomPageRenderer,
} },
}, },
PUPU_CUSTOM_PAGE: { PUPU_CUSTOM_PAGE: {
type: "route", type: "route",
title: () => "Kettu", title: () => "Kettu",
screen: { screen: {
route: "PUPU_CUSTOM_PAGE", route: "PUPU_CUSTOM_PAGE",
getComponent: () => CustomPageRenderer getComponent: () => CustomPageRenderer,
} },
}, },
BUNNY_CUSTOM_PAGE: { BUNNY_CUSTOM_PAGE: {
type: "route", type: "route",
title: () => "Kettu", title: () => "Kettu",
screen: { screen: {
route: "BUNNY_CUSTOM_PAGE", route: "BUNNY_CUSTOM_PAGE",
getComponent: () => CustomPageRenderer getComponent: () => CustomPageRenderer,
}
}, },
...getRows() },
...getRows(),
}), }),
set: v => rendererConfigValue = v, set: (v) => (rendererConfigValue = v),
}); });
unpatches.push(() => { unpatches.push(() => {
@ -76,23 +79,26 @@ export function patchTabsUI(unpatches: (() => void | boolean)[]) {
value: origRendererConfig, value: origRendererConfig,
writable: true, writable: true,
get: undefined, get: undefined,
set: undefined set: undefined,
}); });
}); });
unpatches.push(after("default", SettingsOverviewScreen, (_, ret) => { unpatches.push(
after("default", SettingsOverviewScreen, (_, ret) => {
if (useIsFirstRender()) return; // :shrug: if (useIsFirstRender()) return; // :shrug:
const { sections } = findInReactTree(ret, i => i.props?.sections).props; const { sections } = findInReactTree(ret, (i) => i.props?.sections).props;
// Credit to @palmdevs - https://discord.com/channels/1196075698301968455/1243605828783571024/1307940348378742816 // Credit to @palmdevs - https://discord.com/channels/1196075698301968455/1243605828783571024/1307940348378742816
let index = -~sections.findIndex((i: any) => i.settings.includes("ACCOUNT")) || 1; let index =
-~sections.findIndex((i: any) => i.settings.includes("ACCOUNT")) || 1;
Object.keys(registeredSections).forEach(sect => { Object.keys(registeredSections).forEach((sect) => {
sections.splice(index++, 0, { sections.splice(index++, 0, {
label: sect, label: sect,
title: sect, title: sect,
settings: registeredSections[sect].map(a => a.key) settings: registeredSections[sect].map((a) => a.key),
}); });
}); });
})); }),
);
} }

View file

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

View file

@ -12,4 +12,4 @@ export interface Logger {
} }
export const LoggerClass = findByNameLazy("Logger"); export const LoggerClass = findByNameLazy("Logger");
export const logger: Logger = new LoggerClass("Kettu"); export const logger: Logger = new LoggerClass("ShiggyCord");

2
src/modules.d.ts vendored
View file

@ -1,5 +1,5 @@
declare module "bunny-build-info" { declare module "bunny-build-info" {
const version: string; const version: "1.0.0";
} }
declare module "*.png" { declare module "*.png" {