diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5e4a15145..c46617c54 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -64,7 +64,8 @@ jobs: git config --global user.email actions@github.com git clone https://$USERNAME:$GITHUB_TOKEN@github.com/$REPO.git plugins cd plugins - cp ../dist/release/*plugins.json . + + cp ../dist/release/*.json . git add -A git commit -m "Plugins for https://github.com/$GITHUB_REPOSITORY/commit/$GITHUB_SHA" git push --force https://$USERNAME:$GITHUB_TOKEN@github.com/$REPO.git diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index add75c8a8..23499757e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -77,13 +77,22 @@ jobs: if: steps.check.outputs.release == 'true' run: pnpm buildWebStandalone + - name: Create sources zip + if: steps.check.outputs.release == 'true' + run: zip -r dist/extension-sources.zip . -x "node_modules/*" -x "dist/*" -x ".git/*" + - name: Publish extension if: steps.check.outputs.release == 'true' - run: | - cd dist/browser/chromium-unpacked - pnpx chrome-webstore-upload-cli@2.1.0 upload --auto-publish + run: pnpx publish-browser-extension@latest --chrome-zip dist/extension-chrome.zip --firefox-zip dist/extension-firefox.zip --firefox-sources-zip dist/extension-sources.zip --edge-zip dist/extension-chrome.zip + env: - EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }} - CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }} - CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }} - REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }} + CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }} + CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }} + CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }} + CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }} + FIREFOX_EXTENSION_ID: ${{ secrets.FIREFOX_EXTENSION_ID }} + FIREFOX_JWT_ISSUER: ${{ secrets.FIREFOX_JWT_ISSUER }} + FIREFOX_JWT_SECRET: ${{ secrets.FIREFOX_JWT_SECRET }} + EDGE_PRODUCT_ID: ${{ secrets.EDGE_PRODUCT_ID }} + EDGE_CLIENT_ID: ${{ secrets.EDGE_CLIENT_ID }} + EDGE_API_KEY: ${{ secrets.EDGE_API_KEY }} diff --git a/README.md b/README.md index 36209d543..523db4bfb 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ MacOS Linux -- [GUI-X11](https://github.com/Equicord/Equilotl/releases/latest/download/Equilotl-x11) +- [GUI](https://github.com/Equicord/Equilotl/releases/latest/download/Equilotl-x11) - [CLI](https://github.com/Equicord/Equilotl/releases/latest/download/EquilotlCli-Linux) - [AUR](https://aur.archlinux.org/packages?O=0&K=equicord) @@ -70,12 +70,22 @@ Build Equicord: bun run build ``` -Inject Equicord into your client: +Inject Equicord into your desktop client: ```shell bun run inject ``` +Build Equicord for web: + +```shell +pnpm buildWeb +``` + +After building Equicord's web extension, locate the appropriate ZIP file in the `dist` directory and follow your browser’s guide for installing custom extensions, if supported. + +Note: Firefox extension zip requires Firefox for developers + ## Credits Thank you to [Vendicated](https://github.com/Vendicated) for creating [Vencord](https://github.com/Vendicated/Vencord) & [Suncord](https://github.com/verticalsync/Suncord) by [verticalsync](https://github.com/verticalsync) for helping when needed. diff --git a/browser/VencordNativeStub.ts b/browser/VencordNativeStub.ts index ba789c666..70978f31b 100644 --- a/browser/VencordNativeStub.ts +++ b/browser/VencordNativeStub.ts @@ -19,13 +19,15 @@ /// /// +// Be very careful with imports in this file to avoid circular dependency issues. +// Only import pure modules that don't import other parts of Vencord. import monacoHtmlLocal from "file://monacoWin.html?minify"; -import * as DataStore from "../src/api/DataStore"; -import { debounce, localStorage } from "../src/utils"; -import { EXTENSION_BASE_URL } from "../src/utils/web-metadata"; -import { getTheme, Theme } from "../src/utils/discord"; -import { Settings } from "../src/Vencord"; +import * as DataStore from "@api/DataStore"; +import type { Settings } from "@api/Settings"; +import { debounce } from "@shared/debounce"; +import { localStorage } from "@utils/localStorage"; import { getStylusWebStoreUrl } from "@utils/web"; +import { EXTENSION_BASE_URL } from "@utils/web-metadata"; // listeners for ipc.on const cssListeners = new Set<(css: string) => void>(); @@ -90,6 +92,8 @@ window.VencordNative = { return; } + const { getTheme, Theme } = require("@utils/discord"); + win.baseUrl = EXTENSION_BASE_URL; win.setCss = setCssDebounced; win.getCurrentCss = () => VencordNative.quickCss.get(); diff --git a/browser/manifestv2.json b/browser/manifestv2.json index b95080d54..22187aea9 100644 --- a/browser/manifestv2.json +++ b/browser/manifestv2.json @@ -49,8 +49,17 @@ ], "browser_specific_settings": { "gecko": { - "id": "equicord-firefox@equicord.org", - "strict_min_version": "128.0" + "id": "firefox@equicord.org", + "strict_min_version": "128.0", + "data_collection_permissions": { + "required": [ + "none" + ], + "optional": [ + "authenticationInfo", + "locationInfo" + ] + } } } -} +} \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index fabceecd1..544de86aa 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -88,7 +88,7 @@ export default tseslint.config( "@stylistic/no-extra-semi": "error", // TS Rules - "@stylistic/func-call-spacing": ["error", "never"], + "@stylistic/function-call-spacing": ["error", "never"], // ESLint Rules "yoda": "error", @@ -113,7 +113,7 @@ export default tseslint.config( "no-useless-escape": [ "error", { - "extra": "i" + "allowRegexCharacters": ["i"] } ], "no-fallthrough": "error", @@ -138,7 +138,7 @@ export default tseslint.config( // Plugin Rules "simple-import-sort/imports": "error", "simple-import-sort/exports": "error", - "unused-imports/no-unused-imports": "error", + "unused-imports/no-unused-imports": "error" } } ); diff --git a/package.json b/package.json index 9f0212c21..7501857e8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "equicord", "private": "true", - "version": "1.13.4", + "version": "1.13.9.1", "description": "The other cutest Discord client mod", "homepage": "https://github.com/Equicord/Equicord#readme", "bugs": { @@ -25,8 +25,8 @@ "watchWeb": "bun run buildWeb --watch", "generateDevsList": "bun run scripts/generateDevsList.ts", "generatePluginJson": "bun run scripts/generatePluginList.ts", - "generateEquicordPluginJson": "bun run scripts/generateEquicordPluginList.ts", - "generateVencordPluginJson": "bun run scripts/generateVencordPluginList.ts", + "generateEquicordPluginJson": "bun run scripts/generatePluginList.ts --equicord", + "generateVencordPluginJson": "bun run scripts/generatePluginList.ts --vencord", "generateTypes": "tspc --emitDeclarationOnly --declaration --outDir packages/vencord-types --allowJs false", "inject": "bun run scripts/runInstaller.mjs --install", "uninject": "bun run scripts/runInstaller.mjs --uninstall", @@ -51,45 +51,46 @@ "gifenc": "github:mattdesl/gifenc#64842fca317b112a8590f8fef2bf3825da8f6fe3", "idb": "8.0.3", "jsqr": "1.4.0", - "monaco-editor": "^0.52.2", - "nanoid": "^5.1.5", + "monaco-editor": "^0.54.0", + "nanoid": "^5.1.6", "usercss-meta": "^0.12.0", "virtual-merge": "^1.0.1" }, "devDependencies": { "@electron/asar": "^4.0.1", - "@stylistic/eslint-plugin": "^4.2.0", + "@stylistic/eslint-plugin": "^5.6.0", "@types/adm-zip": "^0.5.7", - "@types/chrome": "^0.0.312", + "@types/chrome": "^0.1.30", "@types/diff": "^7.0.2", - "@types/lodash": "^4.17.14", - "@types/node": "^22.13.13", + "@types/lodash": "^4.17.20", + "@types/node": "^24.10.1", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", - "@types/yazl": "^2.4.5", + "@types/yazl": "^3.3.0", "@vencord/discord-types": "link:@vencord/discord-types", "adm-zip": "^0.5.16", - "diff": "^7.0.0", - "esbuild": "^0.25.1", - "eslint": "^8.57.0", + "diff": "^8.0.2", + "esbuild": "^0.27.0", + "eslint": "9.39.1", "eslint-import-resolver-alias": "^1.1.2", - "eslint-plugin-react": "^7.37.3", + "eslint-plugin-react": "^7.37.5", "eslint-plugin-simple-header": "^1.2.1", "eslint-plugin-simple-import-sort": "^12.1.1", - "eslint-plugin-unused-imports": "^4.1.4", + "eslint-plugin-unused-imports": "^4.3.0", "highlight.js": "11.11.1", "html-minifier-terser": "^7.2.0", "moment": "^2.22.2", - "puppeteer-core": "^24.19.0", + "puppeteer-core": "^24.30.0", "standalone-electron-types": "^34.2.0", - "stylelint": "^16.17.0", - "stylelint-config-standard": "^37.0.0", + "stylelint": "^16.25.0", + "stylelint-config-standard": "^39.0.1", "ts-patch": "^3.3.0", "ts-pattern": "^5.6.0", - "type-fest": "^4.38.0", - "typescript": "^5.8.2", - "typescript-eslint": "^8.28.0", - "typescript-transform-paths": "^3.5.5" + "type-fest": "^5.2.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.47.0", + "typescript-transform-paths": "^3.5.5", + "zip-local": "^0.3.5" }, "packageManager": "bun@1.1.0", "trustedDependencies": [ diff --git a/packages/discord-types/src/common/Activity.d.ts b/packages/discord-types/src/common/Activity.d.ts index 677ee963e..06e91e7bd 100644 --- a/packages/discord-types/src/common/Activity.d.ts +++ b/packages/discord-types/src/common/Activity.d.ts @@ -3,8 +3,10 @@ import { ActivityFlags, ActivityStatusDisplayType, ActivityType } from "../../en export interface ActivityAssets { large_image?: string; large_text?: string; + large_url?: string; small_image?: string; small_text?: string; + small_url?: string; } export interface ActivityButton { @@ -45,3 +47,5 @@ export interface Activity { size?: [number, number]; }; } + +export type OnlineStatus = "online" | "idle" | "dnd" | "invisible" | "offline" | "unknown" | "streaming"; diff --git a/packages/discord-types/src/common/User.d.ts b/packages/discord-types/src/common/User.d.ts index 07eb47187..06f7b4067 100644 --- a/packages/discord-types/src/common/User.d.ts +++ b/packages/discord-types/src/common/User.d.ts @@ -62,4 +62,5 @@ export interface UserJSON { id: string; publicFlags: number; username: string; + globalName: string | undefined; } diff --git a/packages/discord-types/src/common/messages/Message.d.ts b/packages/discord-types/src/common/messages/Message.d.ts index c2af24962..4a6f3e8b5 100644 --- a/packages/discord-types/src/common/messages/Message.d.ts +++ b/packages/discord-types/src/common/messages/Message.d.ts @@ -2,7 +2,7 @@ import { CommandOption } from './Commands'; import { User, UserJSON } from '../User'; import { Embed, EmbedJSON } from './Embed'; import { DiscordRecord } from "../Record"; -import { MessageFlags, MessageType, StickerFormatType } from "../../../enums"; +import { ApplicationIntegrationType, MessageFlags, MessageType, StickerFormatType } from "../../../enums"; /* * TODO: looks like discord has moved over to Date instead of Moment; @@ -71,6 +71,19 @@ export class Message extends DiscordRecord { type: number; version: string; }[]; + interactionMetadata?: { + id: string; + type: number; + name?: string; + command_type?: number; + ephemerality_reason?: number; + user: User; + authorizing_integration_owners: Record; + original_response_message_id?: string; + interacted_message_id?: string; + target_user?: User; + target_message_id?: string; + }; interactionError: unknown[]; isSearchHit: boolean; loggingName: unknown; diff --git a/packages/discord-types/src/components.d.ts b/packages/discord-types/src/components.d.ts index 29e90eab6..4fb634a12 100644 --- a/packages/discord-types/src/components.d.ts +++ b/packages/discord-types/src/components.d.ts @@ -318,14 +318,6 @@ export type Slider = ComponentClass>; -// TODO - type maybe idk probably not that useful other than the constants -export type Flex = ComponentType> & { - Align: Record<"START" | "END" | "CENTER" | "STRETCH" | "BASELINE", string>; - Direction: Record<"VERTICAL" | "HORIZONTAL" | "HORIZONTAL_REVERSE", string>; - Justify: Record<"START" | "END" | "CENTER" | "BETWEEN" | "AROUND", string>; - Wrap: Record<"NO_WRAP" | "WRAP" | "WRAP_REVERSE", string>; -}; - declare enum PopoutAnimation { NONE = "1", TRANSLATE = "2", diff --git a/packages/discord-types/src/menu.d.ts b/packages/discord-types/src/menu.d.ts index 20c3494c3..78810296a 100644 --- a/packages/discord-types/src/menu.d.ts +++ b/packages/discord-types/src/menu.d.ts @@ -1,4 +1,4 @@ -import type { ComponentType, CSSProperties, MouseEvent, PropsWithChildren, ReactNode, UIEvent } from "react"; +import type { ComponentType, CSSProperties, ForwardRefRenderFunction, MouseEvent, PropsWithChildren, ReactNode, UIEvent } from "react"; type RC = ComponentType>>; @@ -46,6 +46,8 @@ export interface Menu { MenuControlItem: RC<{ id: string; interactive?: boolean; + label?: string; + control: ForwardRefRenderFunction; }>; MenuSliderControl: RC<{ minValue?: number, diff --git a/packages/discord-types/src/stores/PresenceStore.d.ts b/packages/discord-types/src/stores/PresenceStore.d.ts new file mode 100644 index 000000000..3f245aca9 --- /dev/null +++ b/packages/discord-types/src/stores/PresenceStore.d.ts @@ -0,0 +1,26 @@ +import { Activity, OnlineStatus } from "../common"; +import { FluxStore } from "./FluxStore"; + +export interface UserAndActivity { + userId: string; + activity: Activity; +} + +export type DiscordPlatform = "desktop" | "mobile" | "web" | "embedded"; + +export class PresenceStore extends FluxStore { + findActivity(userId: string, predicate: (activity: Activity) => boolean, guildId?: string): Activity | undefined; + getActivities(userId: string, guildId?: string): Activity[]; + getActivityMetadata(userId: string): any; + getAllApplicationActivities(applicationId: string): UserAndActivity[]; + getApplicationActivity(userId: string, applicationId: string, guildId?: string): Activity | null; + getClientStatus(userId: string): Record; + getHiddenActivities(): any; + /** literally just getActivities(...)[0] */ + getPrimaryActivity(userId: string, guildId?: string): Activity | null; + getState(): any; + getStatus(userId: string, guildId?: string | null, defaultStatus?: OnlineStatus): OnlineStatus; + getUnfilteredActivities(userId: string, guildId?: string): Activity[]; + getUserIds(): string[]; + isMobileOnline(userId: string): boolean; +} diff --git a/packages/discord-types/src/stores/UploadAttachmentStore.d.ts b/packages/discord-types/src/stores/UploadAttachmentStore.d.ts new file mode 100644 index 000000000..99490aff7 --- /dev/null +++ b/packages/discord-types/src/stores/UploadAttachmentStore.d.ts @@ -0,0 +1,11 @@ +import { CloudUpload, DraftType, FluxStore } from ".."; + +export class UploadAttachmentStore extends FluxStore { + getFirstUpload(channelId: string, draftType: string): CloudUpload; + hasAdditionalUploads(channelId: string, draftType: string): boolean; + getUploads(channelId: string, draftType: DraftType): CloudUpload[]; + getUploadCount(channelId: string, draftType: DraftType): number; + getUpload(channelId: string, commandName: string, draftType: DraftType): CloudUpload; + findUpload(channelId: string, draftType: string, predicate: (upload: CloudUpload) => boolean): CloudUpload | undefined; + +} diff --git a/packages/discord-types/src/stores/VoiceStateStore.d.ts b/packages/discord-types/src/stores/VoiceStateStore.d.ts index 2678e693b..2feaf3925 100644 --- a/packages/discord-types/src/stores/VoiceStateStore.d.ts +++ b/packages/discord-types/src/stores/VoiceStateStore.d.ts @@ -10,6 +10,7 @@ export interface VoiceState extends DiscordRecord { sessionId: string | null | undefined; mute: boolean; deaf: boolean; + stream: boolean; selfMute: boolean; selfDeaf: boolean; selfVideo: boolean; diff --git a/packages/discord-types/src/stores/index.d.ts b/packages/discord-types/src/stores/index.d.ts index 45f66142b..ecbb256ef 100644 --- a/packages/discord-types/src/stores/index.d.ts +++ b/packages/discord-types/src/stores/index.d.ts @@ -8,6 +8,7 @@ export * from "./GuildMemberStore"; export * from "./GuildRoleStore"; export * from "./GuildStore"; export * from "./MessageStore"; +export * from "./PresenceStore"; export * from "./RelationshipStore"; export * from "./MediaEngineStore"; export * from "./StreamerModeStore"; diff --git a/packages/vencord-types/README.md b/packages/vencord-types/README.md index 9cd14c610..775f1743c 100644 --- a/packages/vencord-types/README.md +++ b/packages/vencord-types/README.md @@ -1,11 +1,11 @@ -# Vencord Types +# Equicord Types -Typings for Vencord's api, published to npm +Typings for Equicord's api, published to npm ```sh -npm i @vencord/types +npm i @equicord/types -yarn add @vencord/types +yarn add @equicord/types bun add @vencord/types ``` diff --git a/packages/vencord-types/package.json b/packages/vencord-types/package.json index 60aa7928f..ea9fcd6a2 100644 --- a/packages/vencord-types/package.json +++ b/packages/vencord-types/package.json @@ -1,7 +1,7 @@ { "name": "@vencord/types", "private": false, - "version": "1.13.3", + "version": "1.13.8", "description": "", "types": "index.d.ts", "scripts": { diff --git a/patches/eslint@9.20.1.patch b/patches/eslint@9.20.1.patch deleted file mode 100644 index c91e45d56..000000000 --- a/patches/eslint@9.20.1.patch +++ /dev/null @@ -1,49 +0,0 @@ -diff --git a/lib/rules/no-useless-escape.js b/lib/rules/no-useless-escape.js -index 0e0f6f09f2c35f3276173c08f832cde9f2cf56a0..7dc22851715f3574d935f513c1b5e35552985711 100644 ---- a/lib/rules/no-useless-escape.js -+++ b/lib/rules/no-useless-escape.js -@@ -65,13 +65,31 @@ module.exports = { - escapeBackslash: "Replace the `\\` with `\\\\` to include the actual backslash character." - }, - -- schema: [] -+ schema: [{ -+ type: "object", -+ properties: { -+ extra: { -+ type: "string", -+ default: "" -+ }, -+ extraCharClass: { -+ type: "string", -+ default: "" -+ }, -+ }, -+ additionalProperties: false -+ }] - }, - - create(context) { -+ const options = context.options[0] || {}; -+ const { extra, extraCharClass } = options; - const sourceCode = context.sourceCode; - const parser = new RegExpParser(); - -+ const NON_CHARCLASS_ESCAPES = union(REGEX_NON_CHARCLASS_ESCAPES, new Set(extra)); -+ const CHARCLASS_ESCAPES = union(REGEX_GENERAL_ESCAPES, new Set(extraCharClass)); -+ - /** - * Reports a node - * @param {ASTNode} node The node to report -@@ -200,9 +218,9 @@ module.exports = { - let allowedEscapes; - - if (characterClassStack.length) { -- allowedEscapes = unicodeSets ? REGEX_CLASSSET_CHARACTER_ESCAPES : REGEX_GENERAL_ESCAPES; -+ allowedEscapes = unicodeSets ? REGEX_CLASSSET_CHARACTER_ESCAPES : CHARCLASS_ESCAPES; - } else { -- allowedEscapes = REGEX_NON_CHARCLASS_ESCAPES; -+ allowedEscapes = NON_CHARCLASS_ESCAPES; - } - if (allowedEscapes.has(escapedChar)) { - return; diff --git a/scripts/build/build.mjs b/scripts/build/build.mjs index 8a1186716..748553645 100644 --- a/scripts/build/build.mjs +++ b/scripts/build/build.mjs @@ -21,7 +21,7 @@ import { createPackage } from "@electron/asar"; import { readdir, writeFile } from "fs/promises"; -import { dirname, join } from "path"; +import { dirname, join, resolve } from "path"; import { fileURLToPath } from "url"; import { BUILD_TIMESTAMP, commonOpts, exists, globPlugins, IS_DEV, IS_REPORTER, IS_COMPANION_TEST, IS_STANDALONE, IS_UPDATER_DISABLED, resolvePluginName, VERSION, commonRendererPlugins, watch, buildOrWatchAll, stringifyValues, IS_ANTI_CRASH_TEST } from "./common.mjs"; @@ -81,6 +81,10 @@ const globNativesPlugin = { let code = ""; let natives = "\n"; let i = 0; + /** + * @type {string[]} + */ + const watchFiles = []; for (const dir of pluginDirs) { const dirPath = join("src", dir); if (!await exists(dirPath)) continue; @@ -90,6 +94,8 @@ const globNativesPlugin = { const nativePath = join(dirPath, fileName, "native.ts"); const indexNativePath = join(dirPath, fileName, "native/index.ts"); + watchFiles.push(resolve(nativePath), resolve(indexNativePath)); + if (!(await exists(nativePath)) && !(await exists(indexNativePath))) continue; @@ -104,7 +110,9 @@ const globNativesPlugin = { code += `export default {${natives}};`; return { contents: code, - resolveDir: "./src" + resolveDir: "./src", + watchDirs: pluginDirs.map(d => resolve("src", d)), + watchFiles, }; }); } diff --git a/scripts/build/common.mjs b/scripts/build/common.mjs index dc1949e13..da858e2ec 100644 --- a/scripts/build/common.mjs +++ b/scripts/build/common.mjs @@ -26,7 +26,7 @@ import esbuild, { build, context } from "esbuild"; import { constants as FsConstants, readFileSync } from "fs"; import { access, readdir, readFile } from "fs/promises"; import { minify as minifyHtml } from "html-minifier-terser"; -import { dirname, join, relative } from "path"; +import { dirname, join, relative, resolve } from "path"; import { fileURLToPath } from "url"; import { promisify } from "util"; @@ -195,7 +195,8 @@ export const globPlugins = kind => ({ code += `export default {${pluginsCode}};export const PluginMeta={${metaCode}};export const ExcludedPlugins={${excludedCode}};`; return { contents: code, - resolveDir: "./src" + resolveDir: "./src", + watchDirs: pluginDirs.map(d => resolve("src", d)), }; }); } diff --git a/scripts/generateEquicordPluginList.ts b/scripts/generateEquicordPluginList.ts deleted file mode 100644 index 37d48929c..000000000 --- a/scripts/generateEquicordPluginList.ts +++ /dev/null @@ -1,298 +0,0 @@ -/* - * Vencord, a modification for Discord's desktop app - * Copyright (c) 2023 Vendicated and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . -*/ - -import { Dirent, readdirSync, readFileSync, writeFileSync } from "fs"; -import { access, readFile } from "fs/promises"; -import { join, sep } from "path"; -import { normalize as posixNormalize, sep as posixSep } from "path/posix"; -import { BigIntLiteral, createSourceFile, Identifier, isArrayLiteralExpression, isCallExpression, isExportAssignment, isIdentifier, isObjectLiteralExpression, isPropertyAccessExpression, isPropertyAssignment, isSatisfiesExpression, isStringLiteral, isVariableStatement, NamedDeclaration, NodeArray, ObjectLiteralExpression, PropertyAssignment, ScriptTarget, StringLiteral, SyntaxKind } from "typescript"; - -import { getPluginTarget } from "./utils.mjs"; - -interface Dev { - name: string; - id: string; -} - -interface Command { - name: string; - description: string; -} - -interface PluginData { - name: string; - description: string; - tags: string[]; - authors: Dev[]; - dependencies: string[]; - hasPatches: boolean; - hasCommands: boolean; - commands: Command[]; - required: boolean; - enabledByDefault: boolean; - target: "discordDesktop" | "vesktop" | "equibop" | "desktop" | "web" | "dev"; - filePath: string; - dirName: string; - isModified: boolean; -} - -const devs = {} as Record; -const equicordDevs = {} as Record; - -function getName(node: NamedDeclaration) { - return node.name && isIdentifier(node.name) ? node.name.text : undefined; -} - -function hasName(node: NamedDeclaration, name: string) { - return getName(node) === name; -} - -function getObjectProp(node: ObjectLiteralExpression, name: string) { - const prop = node.properties.find(p => hasName(p, name)); - if (prop && isPropertyAssignment(prop)) return prop.initializer; - return prop; -} - -function parseDevs() { - const file = createSourceFile("constants.ts", readFileSync("src/utils/constants.ts", "utf8"), ScriptTarget.Latest); - - for (const child of file.getChildAt(0).getChildren()) { - if (!isVariableStatement(child)) continue; - - const devsDeclaration = child.declarationList.declarations.find(d => hasName(d, "Devs")); - if (!devsDeclaration?.initializer || !isCallExpression(devsDeclaration.initializer)) continue; - - const value = devsDeclaration.initializer.arguments[0]; - - if (!isSatisfiesExpression(value) || !isObjectLiteralExpression(value.expression)) throw new Error("Failed to parse devs: not an object literal"); - - for (const prop of value.expression.properties) { - const name = (prop.name as Identifier).text; - const value = isPropertyAssignment(prop) ? prop.initializer : prop; - - if (!isObjectLiteralExpression(value)) throw new Error(`Failed to parse devs: ${name} is not an object literal`); - - devs[name] = { - name: (getObjectProp(value, "name") as StringLiteral).text, - id: (getObjectProp(value, "id") as BigIntLiteral).text.slice(0, -1) - }; - } - - return; - } - - throw new Error("Could not find Devs constant"); -} - -function parseEquicordDevs() { - const file = createSourceFile("constants.ts", readFileSync("src/utils/constants.ts", "utf8"), ScriptTarget.Latest); - - for (const child of file.getChildAt(0).getChildren()) { - if (!isVariableStatement(child)) continue; - - const devsDeclaration = child.declarationList.declarations.find(d => hasName(d, "EquicordDevs")); - if (!devsDeclaration?.initializer || !isCallExpression(devsDeclaration.initializer)) continue; - - const value = devsDeclaration.initializer.arguments[0]; - - if (!isSatisfiesExpression(value) || !isObjectLiteralExpression(value.expression)) throw new Error("Failed to parse EquicordDevs: not an object literal"); - - for (const prop of value.expression.properties) { - const name = (prop.name as Identifier).text; - const value = isPropertyAssignment(prop) ? prop.initializer : prop; - - if (!isObjectLiteralExpression(value)) throw new Error(`Failed to parse EquicordDevs: ${name} is not an object literal`); - - equicordDevs[name] = { - name: (getObjectProp(value, "name") as StringLiteral).text, - id: (getObjectProp(value, "id") as BigIntLiteral).text.slice(0, -1) - }; - } - - return; - } - - throw new Error("Could not find EquicordDevs constant"); -} - -async function parseFile(fileName: string) { - const file = createSourceFile(fileName, await readFile(fileName, "utf8"), ScriptTarget.Latest); - - const fail = (reason: string) => { - return new Error(`Invalid plugin ${fileName}, because ${reason}`); - }; - - for (const node of file.getChildAt(0).getChildren()) { - if (!isExportAssignment(node) || !isCallExpression(node.expression)) continue; - - const call = node.expression; - if (!isIdentifier(call.expression) || call.expression.text !== "definePlugin") continue; - - const pluginObj = node.expression.arguments[0]; - if (!isObjectLiteralExpression(pluginObj)) throw fail("no object literal passed to definePlugin"); - - const data = { - hasPatches: false, - hasCommands: false, - enabledByDefault: false, - required: false, - isModified: false, - tags: [] as string[] - } as PluginData; - - for (const prop of pluginObj.properties) { - const key = getName(prop); - const value = isPropertyAssignment(prop) ? prop.initializer : prop; - - switch (key) { - case "name": - case "description": - if (!isStringLiteral(value)) throw fail(`${key} is not a string literal`); - data[key] = value.text; - break; - case "patches": - data.hasPatches = true; - break; - case "commands": - data.hasCommands = true; - if (isArrayLiteralExpression(value)) { - data.commands = value.elements.map((e) => { - if (isObjectLiteralExpression(e)) { - const nameProperty = e.properties.find((p): p is PropertyAssignment => - isPropertyAssignment(p) && isIdentifier(p.name) && p.name.escapedText === "name" - ); - const descriptionProperty = e.properties.find((p): p is PropertyAssignment => - isPropertyAssignment(p) && isIdentifier(p.name) && p.name.escapedText === "description" - ); - if (!nameProperty || !descriptionProperty) throw fail("command missing required properties"); - const name = isStringLiteral(nameProperty.initializer) ? nameProperty.initializer.text : ""; - const description = isStringLiteral(descriptionProperty.initializer) ? descriptionProperty.initializer.text : ""; - return { name, description }; - } else if (isCallExpression(e) && isIdentifier(e.expression)) { - const [nameArg] = e.arguments; - if (!isStringLiteral(nameArg)) throw fail("first argument must be a string"); - return { name: nameArg.text, description: "" }; - } else if (e.kind === SyntaxKind.SpreadElement) { - return undefined; - } - throw fail("commands array contains invalid elements"); - }).filter((c): c is { name: string; description: string; } => Boolean(c)) as Command[]; - } else if (isIdentifier(value)) { - data.commands = []; - } else { - throw fail("commands is not an array literal or identifier"); - } - break; - case "authors": - if (!isArrayLiteralExpression(value)) throw fail("authors is not an array literal"); - data.authors = value.elements.map(e => { - if (!isPropertyAccessExpression(e)) throw fail("authors array contains non-property access expressions"); - const d = devs[getName(e)!] || equicordDevs[getName(e)!]; - if (!d) throw fail(`couldn't look up author ${getName(e)}`); - return d; - }); - break; - case "tags": - if (!isArrayLiteralExpression(value)) throw fail("tags is not an array literal"); - data.tags = value.elements.map(e => { - if (!isStringLiteral(e)) throw fail("tags array contains non-string literals"); - return e.text; - }); - break; - case "dependencies": - if (!isArrayLiteralExpression(value)) throw fail("dependencies is not an array literal"); - const { elements } = value; - if (elements.some(e => !isStringLiteral(e))) throw fail("dependencies array contains non-string elements"); - data.dependencies = (elements as NodeArray).map(e => e.text); - break; - case "required": - case "isModified": - case "enabledByDefault": - data[key] = value.kind === SyntaxKind.TrueKeyword; - break; - } - } - - if (!data.name || !data.description || !data.authors) throw fail("name, description or authors are missing"); - - const target = getPluginTarget(fileName); - if (target) { - if (!["web", "discordDesktop", "vencordDesktop", "equibop", "desktop", "dev"].includes(target)) throw fail(`invalid target ${target}`); - data.target = target as any; - } - - data.filePath = posixNormalize(fileName) - .split(sep) - .join(posixSep) - .replace(/\/index\.([jt]sx?)$/, ""); - - data.dirName = posixNormalize(fileName) - .split(sep) - .join(posixSep) - .replace(/\/index\.([jt]sx?)$/, "") - .replace(/^src\/equicordplugins\//, ""); - - return [data] as const; - } - - throw fail("no default export called 'definePlugin' found"); -} - -async function getEntryPoint(dir: string, dirent: Dirent) { - const base = join(dir, dirent.name); - if (!dirent.isDirectory()) return base; - - for (const name of ["index.ts", "index.tsx"]) { - const full = join(base, name); - try { - await access(full); - return full; - } catch { } - } - - throw new Error(`${dirent.name}: Couldn't find entry point`); -} - -function isPluginFile({ name }: { name: string; }) { - if (name === "index.ts") return false; - return !name.startsWith("_") && !name.startsWith("."); -} - -(async () => { - parseDevs(); - parseEquicordDevs(); - - const plugins = [] as PluginData[]; - - await Promise.all(["src/equicordplugins"].flatMap(dir => - readdirSync(dir, { withFileTypes: true }) - .filter(isPluginFile) - .map(async dirent => { - const [data] = await parseFile(await getEntryPoint(dir, dirent)); - plugins.sort().push(data); - }) - )); - - const data = JSON.stringify(plugins); - - if (process.argv.length > 2) { - writeFileSync(process.argv[2], data); - } else { - console.log(data); - } -})(); diff --git a/scripts/generatePluginList.ts b/scripts/generatePluginList.ts index 847020075..76a03bd60 100644 --- a/scripts/generatePluginList.ts +++ b/scripts/generatePluginList.ts @@ -16,283 +16,47 @@ * along with this program. If not, see . */ -import { Dirent, readdirSync, readFileSync, writeFileSync } from "fs"; -import { access, readFile } from "fs/promises"; -import { join, sep } from "path"; -import { normalize as posixNormalize, sep as posixSep } from "path/posix"; -import { BigIntLiteral, createSourceFile, Identifier, isArrayLiteralExpression, isCallExpression, isExportAssignment, isIdentifier, isObjectLiteralExpression, isPropertyAccessExpression, isPropertyAssignment, isSatisfiesExpression, isStringLiteral, isVariableStatement, NamedDeclaration, NodeArray, ObjectLiteralExpression, PropertyAssignment, ScriptTarget, StringLiteral, SyntaxKind } from "typescript"; - -import { getPluginTarget } from "./utils.mjs"; - -interface Dev { - name: string; - id: string; -} - -interface Command { - name: string; - description: string; -} - -interface PluginData { - name: string; - description: string; - tags: string[]; - authors: Dev[]; - dependencies: string[]; - hasPatches: boolean; - hasCommands: boolean; - commands: Command[]; - required: boolean; - enabledByDefault: boolean; - target: "discordDesktop" | "vesktop" | "equibop" | "desktop" | "web" | "dev"; - filePath: string; - dirName: string; - isModified: boolean; -} - -const devs = {} as Record; -const equicordDevs = {} as Record; - -function getName(node: NamedDeclaration) { - return node.name && isIdentifier(node.name) ? node.name.text : undefined; -} - -function hasName(node: NamedDeclaration, name: string) { - return getName(node) === name; -} - -function getObjectProp(node: ObjectLiteralExpression, name: string) { - const prop = node.properties.find(p => hasName(p, name)); - if (prop && isPropertyAssignment(prop)) return prop.initializer; - return prop; -} - -function parseDevs() { - const file = createSourceFile("constants.ts", readFileSync("src/utils/constants.ts", "utf8"), ScriptTarget.Latest); - - for (const child of file.getChildAt(0).getChildren()) { - if (!isVariableStatement(child)) continue; - - const devsDeclaration = child.declarationList.declarations.find(d => hasName(d, "Devs")); - if (!devsDeclaration?.initializer || !isCallExpression(devsDeclaration.initializer)) continue; - - const value = devsDeclaration.initializer.arguments[0]; - - if (!isSatisfiesExpression(value) || !isObjectLiteralExpression(value.expression)) throw new Error("Failed to parse devs: not an object literal"); - - for (const prop of value.expression.properties) { - const name = (prop.name as Identifier).text; - const value = isPropertyAssignment(prop) ? prop.initializer : prop; - - if (!isObjectLiteralExpression(value)) throw new Error(`Failed to parse devs: ${name} is not an object literal`); - - devs[name] = { - name: (getObjectProp(value, "name") as StringLiteral).text, - id: (getObjectProp(value, "id") as BigIntLiteral).text.slice(0, -1) - }; - } - - return; - } - - throw new Error("Could not find Devs constant"); -} - -function parseEquicordDevs() { - const file = createSourceFile("constants.ts", readFileSync("src/utils/constants.ts", "utf8"), ScriptTarget.Latest); - - for (const child of file.getChildAt(0).getChildren()) { - if (!isVariableStatement(child)) continue; - - const devsDeclaration = child.declarationList.declarations.find(d => hasName(d, "EquicordDevs")); - if (!devsDeclaration?.initializer || !isCallExpression(devsDeclaration.initializer)) continue; - - const value = devsDeclaration.initializer.arguments[0]; - - if (!isSatisfiesExpression(value) || !isObjectLiteralExpression(value.expression)) throw new Error("Failed to parse EquicordDevs: not an object literal"); - - for (const prop of value.expression.properties) { - const name = (prop.name as Identifier).text; - const value = isPropertyAssignment(prop) ? prop.initializer : prop; - - if (!isObjectLiteralExpression(value)) throw new Error(`Failed to parse EquicordDevs: ${name} is not an object literal`); - - equicordDevs[name] = { - name: (getObjectProp(value, "name") as StringLiteral).text, - id: (getObjectProp(value, "id") as BigIntLiteral).text.slice(0, -1) - }; - } - - return; - } - - throw new Error("Could not find EquicordDevs constant"); -} - -async function parseFile(fileName: string) { - const file = createSourceFile(fileName, await readFile(fileName, "utf8"), ScriptTarget.Latest); - - const fail = (reason: string) => { - return new Error(`Invalid plugin ${fileName}, because ${reason}`); - }; - - for (const node of file.getChildAt(0).getChildren()) { - if (!isExportAssignment(node) || !isCallExpression(node.expression)) continue; - - const call = node.expression; - if (!isIdentifier(call.expression) || call.expression.text !== "definePlugin") continue; - - const pluginObj = node.expression.arguments[0]; - if (!isObjectLiteralExpression(pluginObj)) throw fail("no object literal passed to definePlugin"); - - const data = { - hasPatches: false, - hasCommands: false, - enabledByDefault: false, - required: false, - isModified: false, - tags: [] as string[] - } as PluginData; - - for (const prop of pluginObj.properties) { - const key = getName(prop); - const value = isPropertyAssignment(prop) ? prop.initializer : prop; - - switch (key) { - case "name": - case "description": - if (!isStringLiteral(value)) throw fail(`${key} is not a string literal`); - data[key] = value.text; - break; - case "patches": - data.hasPatches = true; - break; - case "commands": - data.hasCommands = true; - if (isArrayLiteralExpression(value)) { - data.commands = value.elements.map((e) => { - if (isObjectLiteralExpression(e)) { - const nameProperty = e.properties.find((p): p is PropertyAssignment => - isPropertyAssignment(p) && isIdentifier(p.name) && p.name.escapedText === "name" - ); - const descriptionProperty = e.properties.find((p): p is PropertyAssignment => - isPropertyAssignment(p) && isIdentifier(p.name) && p.name.escapedText === "description" - ); - if (!nameProperty || !descriptionProperty) throw fail("command missing required properties"); - const name = isStringLiteral(nameProperty.initializer) ? nameProperty.initializer.text : ""; - const description = isStringLiteral(descriptionProperty.initializer) ? descriptionProperty.initializer.text : ""; - return { name, description }; - } else if (isCallExpression(e) && isIdentifier(e.expression)) { - const [nameArg] = e.arguments; - if (!isStringLiteral(nameArg)) throw fail("first argument must be a string"); - return { name: nameArg.text, description: "" }; - } else if (e.kind === SyntaxKind.SpreadElement) { - return undefined; - } - throw fail("commands array contains invalid elements"); - }).filter((c): c is { name: string; description: string; } => Boolean(c)) as Command[]; - } else if (isIdentifier(value)) { - data.commands = []; - } else { - throw fail("commands is not an array literal or identifier"); - } - break; - case "authors": - if (!isArrayLiteralExpression(value)) throw fail("authors is not an array literal"); - data.authors = value.elements.map(e => { - if (!isPropertyAccessExpression(e)) throw fail("authors array contains non-property access expressions"); - const d = devs[getName(e)!] || equicordDevs[getName(e)!]; - if (!d) throw fail(`couldn't look up author ${getName(e)}`); - return d; - }); - break; - case "tags": - if (!isArrayLiteralExpression(value)) throw fail("tags is not an array literal"); - data.tags = value.elements.map(e => { - if (!isStringLiteral(e)) throw fail("tags array contains non-string literals"); - return e.text; - }); - break; - case "dependencies": - if (!isArrayLiteralExpression(value)) throw fail("dependencies is not an array literal"); - const { elements } = value; - if (elements.some(e => !isStringLiteral(e))) throw fail("dependencies array contains non-string elements"); - data.dependencies = (elements as NodeArray).map(e => e.text); - break; - case "required": - case "isModified": - case "enabledByDefault": - data[key] = value.kind === SyntaxKind.TrueKeyword; - break; - } - } - - if (!data.name || !data.description || !data.authors) throw fail("name, description or authors are missing"); - - const target = getPluginTarget(fileName); - if (target) { - if (!["web", "discordDesktop", "vesktop", "equibop", "desktop", "dev"].includes(target)) throw fail(`invalid target ${target}`); - data.target = target as any; - } - - data.filePath = posixNormalize(fileName) - .split(sep) - .join(posixSep) - .replace(/\/index\.([jt]sx?)$/, ""); - - data.dirName = posixNormalize(fileName) - .split(sep) - .join(posixSep) - .replace(/\/index\.([jt]sx?)$/, "") - .replace(/^src\/plugins\//, "") - .replace(/^src\/equicordplugins\//, ""); - - return [data] as const; - } - - throw fail("no default export called 'definePlugin' found"); -} - -async function getEntryPoint(dir: string, dirent: Dirent) { - const base = join(dir, dirent.name); - if (!dirent.isDirectory()) return base; - - for (const name of ["index.ts", "index.tsx"]) { - const full = join(base, name); - try { - await access(full); - return full; - } catch { } - } - - throw new Error(`${dirent.name}: Couldn't find entry point`); -} - -function isPluginFile({ name }: { name: string; }) { - if (name === "index.ts") return false; - return !name.startsWith("_") && !name.startsWith("."); -} +import { readdirSync, writeFileSync } from "fs"; +import { getEntryPoint, isPluginFile, parseDevs, parseEquicordDevs, parseFile, PluginData } from "./utils"; (async () => { parseDevs(); parseEquicordDevs(); + const args = process.argv.slice(2); + + const equicordFlag = args.includes("--equicord"); + const vencordFlag = args.includes("--vencord"); + + let dirs: string[]; + + if (equicordFlag) { + dirs = ["src/equicordplugins"]; + } else if (vencordFlag) { + dirs = ["src/plugins", "src/plugins/_core"]; + } else { + dirs = ["src/plugins", "src/plugins/_core", "src/equicordplugins"]; + } + + const outputPath = args.find(a => !a.startsWith("--")) ?? null; + const plugins = [] as PluginData[]; - await Promise.all(["src/plugins", "src/plugins/_core", "src/equicordplugins"].flatMap(dir => - readdirSync(dir, { withFileTypes: true }) - .filter(isPluginFile) - .map(async dirent => { - const [data] = await parseFile(await getEntryPoint(dir, dirent)); - plugins.sort().push(data); - }) - )); + await Promise.all( + dirs.flatMap(dir => + readdirSync(dir, { withFileTypes: true }) + .filter(isPluginFile) + .map(async dirent => { + const [data] = await parseFile(await getEntryPoint(dir, dirent)); + plugins.sort().push(data); + }) + ) + ); const data = JSON.stringify(plugins); - if (process.argv.length > 2) { - writeFileSync(process.argv[2], data); + if (outputPath) { + writeFileSync(outputPath, data); } else { console.log(data); } diff --git a/scripts/generateReport.ts b/scripts/generateReport.ts index 5a1c42585..d6373e99f 100644 --- a/scripts/generateReport.ts +++ b/scripts/generateReport.ts @@ -87,8 +87,14 @@ const IGNORED_DISCORD_ERRORS = [ function toCodeBlock(s: string, indentation = 0, isDiscord = false) { s = s.replace(/```/g, "`\u200B`\u200B`"); - const indentationStr = Array(!isDiscord ? indentation : 0).fill(" ").join(""); - return `\`\`\`\n${s.split("\n").map(s => indentationStr + s).join("\n")}\n${indentationStr}\`\`\``; + s = s.replace(/\\x([0-9A-Fa-f]{2})/g, (_, hex) => + String.fromCharCode(parseInt(hex, 16)) + ); + + const indentationStr = " ".repeat(!isDiscord ? indentation : 0); + const content = s.split("\n").map(s => indentationStr + s).join("\n"); + + return `\`\`\`\n${content}\n${indentationStr}\`\`\``; } async function printReport() { @@ -340,7 +346,7 @@ page.on("console", async e => { }); page.on("error", e => logStderr("[Error]", e.message)); -page.on("pageerror", e => { +page.on("pageerror", (e: any) => { if (e.message.includes("Sentry successfully disabled")) return; if (!e.message.startsWith("Object") && !e.message.includes("Cannot find module") && !/^.{1,2}$/.test(e.message)) { diff --git a/scripts/generateVencordPluginList.ts b/scripts/utils.ts similarity index 90% rename from scripts/generateVencordPluginList.ts rename to scripts/utils.ts index 94b7b5ef3..701919c8a 100644 --- a/scripts/generateVencordPluginList.ts +++ b/scripts/utils.ts @@ -24,17 +24,17 @@ import { BigIntLiteral, createSourceFile, Identifier, isArrayLiteralExpression, import { getPluginTarget } from "./utils.mjs"; -interface Dev { +export interface Dev { name: string; id: string; } -interface Command { +export interface Command { name: string; description: string; } -interface PluginData { +export interface PluginData { name: string; description: string; tags: string[]; @@ -50,24 +50,25 @@ interface PluginData { dirName: string; isModified: boolean; } -const devs = {} as Record; -const equicordDevs = {} as Record; -function getName(node: NamedDeclaration) { +export const devs = {} as Record; +export const equicordDevs = {} as Record; + +export function getName(node: NamedDeclaration) { return node.name && isIdentifier(node.name) ? node.name.text : undefined; } -function hasName(node: NamedDeclaration, name: string) { +export function hasName(node: NamedDeclaration, name: string) { return getName(node) === name; } -function getObjectProp(node: ObjectLiteralExpression, name: string) { +export function getObjectProp(node: ObjectLiteralExpression, name: string) { const prop = node.properties.find(p => hasName(p, name)); if (prop && isPropertyAssignment(prop)) return prop.initializer; return prop; } -function parseDevs() { +export function parseDevs() { const file = createSourceFile("constants.ts", readFileSync("src/utils/constants.ts", "utf8"), ScriptTarget.Latest); for (const child of file.getChildAt(0).getChildren()) { @@ -98,7 +99,7 @@ function parseDevs() { throw new Error("Could not find Devs constant"); } -function parseEquicordDevs() { +export function parseEquicordDevs() { const file = createSourceFile("constants.ts", readFileSync("src/utils/constants.ts", "utf8"), ScriptTarget.Latest); for (const child of file.getChildAt(0).getChildren()) { @@ -129,7 +130,7 @@ function parseEquicordDevs() { throw new Error("Could not find EquicordDevs constant"); } -async function parseFile(fileName: string) { +export async function parseFile(fileName: string) { const file = createSourceFile(fileName, await readFile(fileName, "utf8"), ScriptTarget.Latest); const fail = (reason: string) => { @@ -244,7 +245,8 @@ async function parseFile(fileName: string) { .split(sep) .join(posixSep) .replace(/\/index\.([jt]sx?)$/, "") - .replace(/^src\/plugins\//, ""); + .replace(/^src\/plugins\//, "") + .replace(/^src\/equicordplugins\//, ""); return [data] as const; } @@ -252,7 +254,7 @@ async function parseFile(fileName: string) { throw fail("no default export called 'definePlugin' found"); } -async function getEntryPoint(dir: string, dirent: Dirent) { +export async function getEntryPoint(dir: string, dirent: Dirent) { const base = join(dir, dirent.name); if (!dirent.isDirectory()) return base; @@ -267,31 +269,7 @@ async function getEntryPoint(dir: string, dirent: Dirent) { throw new Error(`${dirent.name}: Couldn't find entry point`); } -function isPluginFile({ name }: { name: string; }) { +export function isPluginFile({ name }: { name: string; }) { if (name === "index.ts") return false; return !name.startsWith("_") && !name.startsWith("."); } - -(async () => { - parseDevs(); - parseEquicordDevs(); - - const plugins = [] as PluginData[]; - - await Promise.all(["src/plugins", "src/plugins/_core"].flatMap(dir => - readdirSync(dir, { withFileTypes: true }) - .filter(isPluginFile) - .map(async dirent => { - const [data] = await parseFile(await getEntryPoint(dir, dirent)); - plugins.sort().push(data); - }) - )); - - const data = JSON.stringify(plugins); - - if (process.argv.length > 2) { - writeFileSync(process.argv[2], data); - } else { - console.log(data); - } -})(); diff --git a/src/Vencord.ts b/src/Vencord.ts index bca2e046f..8fb08db06 100644 --- a/src/Vencord.ts +++ b/src/Vencord.ts @@ -20,34 +20,32 @@ import "~plugins"; export * as Api from "./api"; +export * as Plugins from "./api/PluginManager"; export * as Components from "./components"; -export * as Plugins from "./plugins"; export * as Util from "./utils"; -export * as QuickCss from "./utils/quickCss"; export * as Updater from "./utils/updater"; export * as Webpack from "./webpack"; export * as WebpackPatcher from "./webpack/patchWebpack"; export { PlainSettings, Settings }; -import "./utils/quickCss"; -import "./webpack/patchWebpack"; - import { addVencordUiStyles } from "@components/css"; -import { openUpdaterModal } from "@components/settings/tabs/updater"; +import { openSettingsTabModal, UpdaterTab } from "@components/settings"; +import { debounce } from "@shared/debounce"; import { IS_WINDOWS } from "@utils/constants"; import { createAndAppendStyle } from "@utils/css"; import { StartAt } from "@utils/types"; import { get as dsGet } from "./api/DataStore"; import { NotificationData, showNotification } from "./api/Notifications"; -import { PlainSettings, Settings } from "./api/Settings"; -import { patches, PMLogger, startAllPlugins } from "./plugins"; +import { initPluginManager, PMLogger, startAllPlugins } from "./api/PluginManager"; +import { PlainSettings, Settings, SettingsStore } from "./api/Settings"; +import { getCloudSettings, putCloudSettings } from "./api/SettingsSync/cloudSync"; import { localStorage } from "./utils/localStorage"; import { relaunch } from "./utils/native"; -import { getCloudSettings, putCloudSettings } from "./utils/settingsSync"; import { checkForUpdates, update, UpdateLogger } from "./utils/updater"; import { onceReady } from "./webpack"; -import { SettingsRouter } from "./webpack/common"; +import { openUserSettingsPanel } from "./webpack/common"; +import { patches } from "./webpack/patchWebpack"; if (IS_REPORTER) { require("./debug/runReporter"); @@ -63,7 +61,7 @@ async function syncSettings() { title: "Cloud Settings", body: "Cloud sync was disabled because this account isn't connected to the cloud App. You can enable it again by connecting this account in Cloud Settings. (note: it will store your preferences separately)", color: "var(--yellow-360)", - onClick: () => SettingsRouter.open("VencordCloud") + onClick: () => openUserSettingsPanel("equicord_cloud") }); // Disable cloud sync globally Settings.cloud.authenticated = false; @@ -82,7 +80,7 @@ async function syncSettings() { body: "We've noticed you have cloud integrations enabled in another client! Due to limitations, you will " + "need to re-authenticate to continue using them. Click here to go to the settings page to do so!", color: "var(--yellow-360)", - onClick: () => SettingsRouter.open("EquicordCloud") + onClick: () => openUserSettingsPanel("equicord_cloud") }); return; } @@ -108,11 +106,25 @@ async function syncSettings() { }); } } + + const saveSettingsOnFrequentAction = debounce(async () => { + if (Settings.cloud.settingsSync && Settings.cloud.authenticated) { + await putCloudSettings(); + delete localStorage.Vencord_settingsDirty; + } + }, 60_000); + + SettingsStore.addGlobalChangeListener(() => { + localStorage.Vencord_settingsDirty = true; + saveSettingsOnFrequentAction(); + }); } let notifiedForUpdatesThisSession = false; async function runUpdateCheck() { + if (IS_UPDATER_DISABLED) return; + const notify = (data: NotificationData) => { if (notifiedForUpdatesThisSession) return; notifiedForUpdatesThisSession = true; @@ -143,7 +155,7 @@ async function runUpdateCheck() { notify({ title: "A Equicord update is available!", body: "Click here to view the update", - onClick: openUpdaterModal! + onClick: () => openSettingsTabModal(UpdaterTab!) }); } catch (err) { UpdateLogger.error("Failed to check for updates", err); @@ -179,6 +191,7 @@ async function init() { } } +initPluginManager(); startAllPlugins(StartAt.Init); init(); diff --git a/src/api/Badges.ts b/src/api/Badges.ts index 43db6c70c..9ab0f5cb2 100644 --- a/src/api/Badges.ts +++ b/src/api/Badges.ts @@ -17,9 +17,12 @@ */ import ErrorBoundary from "@components/ErrorBoundary"; -import BadgeAPIPlugin from "plugins/_api/badges"; +import globalBadges from "@equicordplugins/globalBadges"; +import BadgeAPIPlugin from "@plugins/_api/badges"; import { ComponentType, HTMLProps } from "react"; +import { isPluginEnabled } from "./PluginManager"; + export const enum BadgePosition { START, END @@ -31,7 +34,7 @@ export interface ProfileBadge { /** Custom component for the badge (tooltip not included) */ component?: ComponentType; /** The custom image to use */ - image?: string; + iconSrc?: string; link?: string; /** Action to perform when you click the badge */ onClick?(event: React.MouseEvent, props: ProfileBadge & BadgeUserArgs): void; @@ -99,6 +102,18 @@ export function _getBadges(args: BadgeUserArgs) { const donorBadges = BadgeAPIPlugin.getDonorBadges(args.userId); const equicordDonorBadges = BadgeAPIPlugin.getEquicordDonorBadges(args.userId); + const GlobalBadges = isPluginEnabled(globalBadges.name) ? globalBadges.getGlobalBadges(args.userId) : false; + + // do globalbadges first so it shows before the contrib badges but after donor badges + if (GlobalBadges) { + badges.unshift( + ...GlobalBadges.map(badge => ({ + ...args, + ...badge, + })) + ); + } + if (donorBadges) { badges.unshift( ...donorBadges.map(badge => ({ diff --git a/src/api/ChatButtons.tsx b/src/api/ChatButtons.tsx index 2fea24d3d..f4871141d 100644 --- a/src/api/ChatButtons.tsx +++ b/src/api/ChatButtons.tsx @@ -9,11 +9,15 @@ import "./ChatButton.css"; import ErrorBoundary from "@components/ErrorBoundary"; import { Logger } from "@utils/Logger"; import { classes } from "@utils/misc"; +import { IconComponent } from "@utils/types"; import { Channel } from "@vencord/discord-types"; import { waitFor } from "@webpack"; -import { ButtonWrapperClasses, Clickable, Tooltip } from "@webpack/common"; +import { ButtonWrapperClasses, Clickable, Menu, Tooltip } from "@webpack/common"; import { HTMLProps, JSX, MouseEventHandler, ReactNode } from "react"; +import { addContextMenuPatch, findGroupChildrenByChildId } from "./ContextMenu"; +import { useSettings } from "./Settings"; + let ChannelTextAreaClasses: Record<"button" | "buttonContainer", string>; waitFor(["buttonContainer", "channelTextArea"], m => ChannelTextAreaClasses = m); @@ -75,25 +79,53 @@ export interface ChatBarProps { }; } -export type ChatBarButtonFactory = (props: ChatBarProps & { isMainChat: boolean; }) => JSX.Element | null; +export type ChatBarButtonFactory = (props: ChatBarProps & { isMainChat: boolean; isAnyChat: boolean; }) => JSX.Element | null; +export type ChatBarButtonData = { + render: ChatBarButtonFactory; + /** + * This icon is used only for Settings UI. Your render function must still render an icon, + * and it can be different from this one. + */ + icon: IconComponent; +}; -const buttonFactories = new Map(); +/** + * Don't use this directly, use {@link addChatBarButton} and {@link removeChatBarButton} instead. + */ +export const ChatBarButtonMap = new Map(); const logger = new Logger("ChatButtons"); +function VencordChatBarButtons(props: ChatBarProps) { + const { chatBarButtons } = useSettings(["uiElements.chatBarButtons.*"]).uiElements; + + const { analyticsName } = props.type; + return ( + <> + {Array.from(ChatBarButtonMap) + .filter(([key]) => chatBarButtons[key]?.enabled !== false) + .map(([key, { render: Button }]) => ( + logger.error(`Failed to render ${key}`, e.error)}> + - - {currentThemeLink && } - + setOnlineThemes(prev => + prev?.map(t => t.link === link ? updatedTheme : t) ?? null + ); + showToast("Theme refreshed!", Toasts.Type.SUCCESS); + } catch { + showToast("Failed to refresh theme", Toasts.Type.FAILURE); + } + } -
- {themes.map(theme => { - const { label, link } = (() => { - const match = /^@(light|dark) (.*)/.exec(theme.link); - if (!match) return { label: theme, link: theme }; + async function downloadTheme(link: string, name: string) { + try { + const res = await fetch(link); + if (!res.ok) throw new Error(`Failed to fetch ${link}`); + const css = await res.text(); + const fileName = name.replace(/[^a-z0-9]/gi, "-") + ".css"; - const [, mode, link] = match; - return { label: `[${mode} mode only] ${link}`, link }; - })(); + if (IS_DISCORD_DESKTOP) { + DiscordNative.fileManager.saveWithDialog(new TextEncoder().encode(css), fileName); + } else { + const blob = new Blob([css], { type: "text/css" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); + } + } catch { + showToast("Failed to download theme", Toasts.Type.FAILURE); + } + } + const allThemes = useMemo((): UnifiedTheme[] => { + const themes: UnifiedTheme[] = []; + + for (const theme of onlineThemes ?? []) { + const customName = themeNames[theme.link] ?? null; + themes.push({ + type: "online", + themeType: "other", + name: customName ?? theme.name ?? theme.fileName, + enabled: settings.enabledThemeLinks.includes(theme.link), + header: { ...theme, customName }, + link: theme.link + }); + } + + for (const { type, header } of userThemes ?? []) { + const name = type === "usercss" + ? (header as UserstyleHeader).name ?? "Unknown" + : (header as UserThemeHeader).name ?? (header as UserThemeHeader).fileName; + + themes.push({ + type: "local", + themeType: type, + name, + enabled: settings.enabledThemes.includes(header.fileName), + header + }); + } + + return themes; + }, [onlineThemes, userThemes, themeNames, settings.enabledThemeLinks, settings.enabledThemes]); + + const filteredThemes = useMemo(() => { + let themes = allThemes; + + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + themes = themes.filter(t => t.name.toLowerCase().includes(query)); + } + + switch (filter) { + case ThemeFilter.Online: + themes = themes.filter(t => t.type === "online"); + break; + case ThemeFilter.Local: + themes = themes.filter(t => t.type === "local"); + break; + case ThemeFilter.Enabled: + themes = themes.filter(t => t.enabled); + break; + case ThemeFilter.Disabled: + themes = themes.filter(t => !t.enabled); + break; + } + + const getThemeId = (t: UnifiedTheme) => t.type === "online" ? t.link! : (t.header as UserThemeHeader).fileName; + themes.sort((a, b) => { + const aId = getThemeId(a); + const bId = getThemeId(b); + const aPinIndex = settings.pinnedThemes.indexOf(aId); + const bPinIndex = settings.pinnedThemes.indexOf(bId); + const aIsPinned = aPinIndex !== -1; + const bIsPinned = bPinIndex !== -1; + + if (aIsPinned && !bIsPinned) return -1; + if (!aIsPinned && bIsPinned) return 1; + if (aIsPinned && bIsPinned) return aPinIndex - bPinIndex; + return 0; + }); + + return themes; + }, [allThemes, searchQuery, filter, settings.pinnedThemes]); + + const localCount = allThemes.filter(t => t.type === "local").length; + const onlineCount = allThemes.filter(t => t.type === "online").length; + const enabledCount = allThemes.filter(t => t.enabled).length; + + return ( + + + + Theme Management + + Customize Discord's appearance with themes. Add local .css files or load themes directly from URLs. Themes with a cog wheel icon have customizable settings you can modify. + + + Quick Actions + + Shortcuts for managing your themes. Open your themes folder to add new themes, use QuickCSS for quick style tweaks, or reload themes after making changes. + + + + {IS_WEB ? ( + + Upload Theme + + + } + Icon={PlusIcon} + /> + ) : ( + VencordNative.themes.openFolder()} + Icon={FolderIcon} + /> + )} + + VencordNative.quickCss.openEditor()} + Icon={PaintbrushIcon} + /> + {Settings.plugins.ClientTheme.enabled && ( + openPluginModal(Plugins.ClientTheme)} + Icon={PencilIcon} + /> + )} + + + + + Online Themes + + Load themes directly from URLs instead of local files. Online themes auto-update when the source changes, so you always have the latest version without manual downloads. + + { + settings.enableOnlineThemes = value; + if (!value) { + settings.enabledThemeLinks = []; + } + }} + /> + + + Looking for themes? Check out BetterDiscord Themes or search on GitHub. When downloading from BetterDiscord, click "Download" and place the .theme.css file into your themes folder. + + +
+ + +
+ {currentThemeLink && ( +
+ +
+ )} + + + + Installed Themes + + Manage your themes here. Local themes load from your themes folder, online themes from URLs. Themes with a cog wheel icon have customizable settings. + + + {allThemes.length} theme{allThemes.length !== 1 ? "s" : ""} installed ({localCount} local, {onlineCount} online) · {enabledCount} enabled + + +
+ +
+