From 2ef2baafbe219957220098b17706b2e04b793ca8 Mon Sep 17 00:00:00 2001 From: Lewis Crichton Date: Sat, 2 Sep 2023 17:51:17 +0100 Subject: [PATCH] feat: initial usercss support Parses UserCSS/UserStyle files (.user.css) but doesn't do anything special yet with the variables. This is a first step towards supporting UserCSS themes. --- package.json | 1 + pnpm-lock.yaml | 8 ++ src/VencordNative.ts | 5 +- src/components/VencordSettings/ThemesTab.tsx | 88 ++++++++++++--- src/main/ipcMain.ts | 22 +++- src/main/themes/{ => bd}/LICENSE | 0 src/main/themes/bd/index.ts | 81 ++++++++++++++ src/main/themes/index.ts | 90 +++------------- src/main/themes/usercss/index.ts | 15 +++ src/main/themes/usercss/usercss-meta.d.ts | 108 +++++++++++++++++++ 10 files changed, 318 insertions(+), 100 deletions(-) rename src/main/themes/{ => bd}/LICENSE (100%) create mode 100644 src/main/themes/bd/index.ts create mode 100644 src/main/themes/usercss/index.ts create mode 100644 src/main/themes/usercss/usercss-meta.d.ts diff --git a/package.json b/package.json index a07bb247d..033d907f1 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "eslint-plugin-simple-header": "^1.0.2", "fflate": "^0.7.4", "nanoid": "^4.0.2", + "usercss-meta": "^0.12.0", "virtual-merge": "^1.0.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5edbb11a8..cbbfd5db9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ dependencies: nanoid: specifier: ^4.0.2 version: 4.0.2 + usercss-meta: + specifier: ^0.12.0 + version: 0.12.0 virtual-merge: specifier: ^1.0.1 version: 1.0.1 @@ -3246,6 +3249,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /usercss-meta@0.12.0: + resolution: {integrity: sha512-zKrXCKdpeIwtVe87omxGo9URf+7mbozduMZEg79dmT4KB3XJwfIkEi/Uk0PcTwR/nZLtAK1+k7isgbGB/g6E7Q==} + engines: {node: '>=8.3'} + dev: false + /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true diff --git a/src/VencordNative.ts b/src/VencordNative.ts index 4f8638bca..c4979704c 100644 --- a/src/VencordNative.ts +++ b/src/VencordNative.ts @@ -7,7 +7,8 @@ import { IpcEvents } from "@utils/IpcEvents"; import { IpcRes } from "@utils/types"; import { ipcRenderer } from "electron"; -import type { UserThemeHeader } from "main/themes"; + +import type { ThemeHeader } from "./main/themes"; function invoke(event: IpcEvents, ...args: any[]) { return ipcRenderer.invoke(event, ...args) as Promise; @@ -22,7 +23,7 @@ export default { uploadTheme: (fileName: string, fileData: string) => invoke(IpcEvents.UPLOAD_THEME, fileName, fileData), deleteTheme: (fileName: string) => invoke(IpcEvents.DELETE_THEME, fileName), getThemesDir: () => invoke(IpcEvents.GET_THEMES_DIR), - getThemesList: () => invoke>(IpcEvents.GET_THEMES_LIST), + getThemesList: () => invoke>(IpcEvents.GET_THEMES_LIST), getThemeData: (fileName: string) => invoke(IpcEvents.GET_THEME_DATA, fileName) }, diff --git a/src/components/VencordSettings/ThemesTab.tsx b/src/components/VencordSettings/ThemesTab.tsx index 4ff5be50f..141696198 100644 --- a/src/components/VencordSettings/ThemesTab.tsx +++ b/src/components/VencordSettings/ThemesTab.tsx @@ -26,9 +26,11 @@ import { showItemInFolder } from "@utils/native"; import { useAwaiter } from "@utils/react"; import { findByCodeLazy, findByPropsLazy, findLazy } from "@webpack"; import { Button, Card, FluxDispatcher, Forms, React, showToast, TabBar, TextArea, useEffect, useRef, useState } from "@webpack/common"; -import { UserThemeHeader } from "main/themes"; +import { UserThemeHeader } from "main/themes/bd"; import type { ComponentType, Ref, SyntheticEvent } from "react"; +import { UserstyleHeader } from "usercss-meta"; +import type { ThemeHeader } from "../../main/themes"; import { AddonCard } from "./AddonCard"; import { SettingsTab, wrapTab } from "./shared"; @@ -41,6 +43,7 @@ type FileInput = ComponentType<{ const InviteActions = findByPropsLazy("resolveInvite"); const TrashIcon = findByCodeLazy("M5 6.99902V18.999C5 20.101 5.897 20.999"); +const CogWheel = findByCodeLazy("18.564C15.797 19.099 14.932 19.498 14 19.738V22H10V19.738C9.069"); const FileInput: FileInput = findByCodeLazy("activateUploadDialogue="); const TextAreaProps = findLazy(m => typeof m.textarea === "string"); @@ -94,14 +97,52 @@ function Validators({ themeLinks }: { themeLinks: string[]; }) { ); } -interface ThemeCardProps { +interface BDThemeCardProps { theme: UserThemeHeader; enabled: boolean; onChange: (enabled: boolean) => void; onDelete: () => void; } -function ThemeCard({ theme, enabled, onChange, onDelete }: ThemeCardProps) { +interface UserCSSCardProps { + theme: UserstyleHeader; + enabled: boolean; + onChange: (enabled: boolean) => void; + onDelete: () => void; +} + +function UserCSSThemeCard({ theme, enabled, onChange, onDelete }: UserCSSCardProps) { + return ( + +
+ +
+ {IS_WEB && ( +
+ +
+ )} + + } + footer={ + + {!!theme.homepageURL && Homepage} + {!!(theme.homepageURL && theme.supportURL) && " • "} + {!!theme.supportURL && Support} + + } + /> + ); +} + +function BDThemeCard({ theme, enabled, onChange, onDelete }: BDThemeCardProps) { return ( (null); const [currentTab, setCurrentTab] = useState(ThemeTab.LOCAL); const [themeText, setThemeText] = useState(settings.themeLinks.join("\n")); - const [userThemes, setUserThemes] = useState(null); + const [userThemes, setUserThemes] = useState(null); const [themeDir, , themeDirPending] = useAwaiter(VencordNative.themes.getThemesDir); useEffect(() => { @@ -259,19 +300,32 @@ function ThemesTab() {
- {userThemes?.map(theme => ( - onLocalThemeChange(theme.fileName, enabled)} - onDelete={async () => { - onLocalThemeChange(theme.fileName, false); - await VencordNative.themes.deleteTheme(theme.fileName); - refreshLocalThemes(); - }} - theme={theme} - /> - ))} + {userThemes?.map(({ type, header: theme }: ThemeHeader) => ( + type === "bd" ? ( + onLocalThemeChange(theme.fileName, enabled)} + onDelete={async () => { + onLocalThemeChange(theme.fileName, false); + await VencordNative.themes.deleteTheme(theme.fileName); + refreshLocalThemes(); + }} + theme={theme as UserThemeHeader} + /> + ) : ( + onLocalThemeChange(theme.fileName, enabled)} + onDelete={async () => { + onLocalThemeChange(theme.fileName, false); + await VencordNative.themes.deleteTheme(theme.fileName); + refreshLocalThemes(); + }} + theme={theme as UserstyleHeader} + /> + )))}
diff --git a/src/main/ipcMain.ts b/src/main/ipcMain.ts index c8e456765..31935eb8a 100644 --- a/src/main/ipcMain.ts +++ b/src/main/ipcMain.ts @@ -29,7 +29,9 @@ import { join, normalize } from "path"; import monacoHtml from "~fileContent/../components/monacoWin.html;base64"; -import { getThemeInfo, stripBOM, UserThemeHeader } from "./themes"; +import type { ThemeHeader } from "./themes"; +import { getThemeInfo, stripBOM } from "./themes/bd"; +import { parse as usercssParse } from "./themes/usercss"; import { ALLOWED_PROTOCOLS, QUICKCSS_PATH, SETTINGS_DIR, SETTINGS_FILE, THEMES_DIR } from "./utils/constants"; import { makeLinksOpenExternally } from "./utils/externalLinks"; @@ -47,10 +49,10 @@ function readCss() { return readFile(QUICKCSS_PATH, "utf-8").catch(() => ""); } -async function listThemes(): Promise { +async function listThemes(): Promise { const files = await readdir(THEMES_DIR).catch(() => []); - const themeInfo: UserThemeHeader[] = []; + const themeInfo: ThemeHeader[] = []; for (const fileName of files) { if (!fileName.endsWith(".css")) continue; @@ -58,7 +60,19 @@ async function listThemes(): Promise { const data = await getThemeData(fileName).then(stripBOM).catch(() => null); if (data == null) continue; - themeInfo.push(getThemeInfo(data, fileName)); + if (fileName.endsWith(".user.css")) { + // handle it as usercss + themeInfo.push({ + type: "usercss", + header: usercssParse(data, fileName) + }); + } else { + // presumably BD but could also be plain css + themeInfo.push({ + type: "bd", + header: getThemeInfo(data, fileName) + }); + } } return themeInfo; diff --git a/src/main/themes/LICENSE b/src/main/themes/bd/LICENSE similarity index 100% rename from src/main/themes/LICENSE rename to src/main/themes/bd/LICENSE diff --git a/src/main/themes/bd/index.ts b/src/main/themes/bd/index.ts new file mode 100644 index 000000000..0751663f0 --- /dev/null +++ b/src/main/themes/bd/index.ts @@ -0,0 +1,81 @@ +/* eslint-disable simple-header/header */ + +/*! + * BetterDiscord addon meta parser + * Copyright 2023 BetterDiscord contributors + * Copyright 2023 Vendicated and Vencord contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const splitRegex = /[^\S\r\n]*?\r?(?:\r\n|\n)[^\S\r\n]*?\*[^\S\r\n]?/; +const escapedAtRegex = /^\\@/; + +export interface UserThemeHeader { + fileName: string; + name: string; + author: string; + description: string; + version?: string; + license?: string; + source?: string; + website?: string; + invite?: string; +} + +function makeHeader(fileName: string, opts: Partial = {}): UserThemeHeader { + return { + fileName, + name: opts.name ?? fileName.replace(/\.css$/i, ""), + author: opts.author ?? "Unknown Author", + description: opts.description ?? "A Discord Theme.", + version: opts.version, + license: opts.license, + source: opts.source, + website: opts.website, + invite: opts.invite + }; +} + +export function stripBOM(fileContent: string) { + if (fileContent.charCodeAt(0) === 0xFEFF) { + fileContent = fileContent.slice(1); + } + return fileContent; +} + +export function getThemeInfo(css: string, fileName: string): UserThemeHeader { + if (!css) return makeHeader(fileName); + + const block = css.split("/**", 2)?.[1]?.split("*/", 1)?.[0]; + if (!block) return makeHeader(fileName); + + const header: Partial = {}; + let field = ""; + let accum = ""; + for (const line of block.split(splitRegex)) { + if (line.length === 0) continue; + if (line.charAt(0) === "@" && line.charAt(1) !== " ") { + header[field] = accum.trim(); + const l = line.indexOf(" "); + field = line.substring(1, l); + accum = line.substring(l + 1); + } + else { + accum += " " + line.replace("\\n", "\n").replace(escapedAtRegex, "@"); + } + } + header[field] = accum.trim(); + delete header[""]; + return makeHeader(fileName, header); +} diff --git a/src/main/themes/index.ts b/src/main/themes/index.ts index 0751663f0..73b4cb86b 100644 --- a/src/main/themes/index.ts +++ b/src/main/themes/index.ts @@ -1,81 +1,17 @@ -/* eslint-disable simple-header/header */ - -/*! - * BetterDiscord addon meta parser - * Copyright 2023 BetterDiscord contributors - * Copyright 2023 Vendicated and Vencord contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later */ -const splitRegex = /[^\S\r\n]*?\r?(?:\r\n|\n)[^\S\r\n]*?\*[^\S\r\n]?/; -const escapedAtRegex = /^\\@/; +import type { UserstyleHeader } from "usercss-meta"; -export interface UserThemeHeader { - fileName: string; - name: string; - author: string; - description: string; - version?: string; - license?: string; - source?: string; - website?: string; - invite?: string; -} +import type { UserThemeHeader } from "./bd"; -function makeHeader(fileName: string, opts: Partial = {}): UserThemeHeader { - return { - fileName, - name: opts.name ?? fileName.replace(/\.css$/i, ""), - author: opts.author ?? "Unknown Author", - description: opts.description ?? "A Discord Theme.", - version: opts.version, - license: opts.license, - source: opts.source, - website: opts.website, - invite: opts.invite - }; -} - -export function stripBOM(fileContent: string) { - if (fileContent.charCodeAt(0) === 0xFEFF) { - fileContent = fileContent.slice(1); - } - return fileContent; -} - -export function getThemeInfo(css: string, fileName: string): UserThemeHeader { - if (!css) return makeHeader(fileName); - - const block = css.split("/**", 2)?.[1]?.split("*/", 1)?.[0]; - if (!block) return makeHeader(fileName); - - const header: Partial = {}; - let field = ""; - let accum = ""; - for (const line of block.split(splitRegex)) { - if (line.length === 0) continue; - if (line.charAt(0) === "@" && line.charAt(1) !== " ") { - header[field] = accum.trim(); - const l = line.indexOf(" "); - field = line.substring(1, l); - accum = line.substring(l + 1); - } - else { - accum += " " + line.replace("\\n", "\n").replace(escapedAtRegex, "@"); - } - } - header[field] = accum.trim(); - delete header[""]; - return makeHeader(fileName, header); -} +export type ThemeHeader = { + type: "bd"; + header: UserThemeHeader; +} | { + type: "usercss"; + header: UserstyleHeader; +}; diff --git a/src/main/themes/usercss/index.ts b/src/main/themes/usercss/index.ts new file mode 100644 index 000000000..137b7595e --- /dev/null +++ b/src/main/themes/usercss/index.ts @@ -0,0 +1,15 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { parse as originalParse, UserstyleHeader } from "usercss-meta"; + +export function parse(text: string, fileName: string): UserstyleHeader { + const { metadata } = originalParse(text.replace(/\r/g, "")); + return { + ...metadata, + fileName, + }; +} diff --git a/src/main/themes/usercss/usercss-meta.d.ts b/src/main/themes/usercss/usercss-meta.d.ts new file mode 100644 index 000000000..1d6c794f2 --- /dev/null +++ b/src/main/themes/usercss/usercss-meta.d.ts @@ -0,0 +1,108 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +declare module "usercss-meta" { + import { Simplify } from "type-fest"; + + export type UserCSSVariable = Simplify<{ name: string; label: string; } & ( + | { + type: "text"; + default: string; + } + | { + type: "color"; + // Hex, rgb(), rgba() + default: string; + } + | { + type: "checkbox"; + default: boolean; + } + | { + type: "range"; + default: number; + min?: number; + max?: number; + step?: number; + units?: string; + } + | { + type: "number"; + default: number; + } + | { + type: "select"; + default: string; + options: Record; + } + )>; + + export interface UserstyleHeader { + /** + * The file name of the UserCSS style. + * + * @vencord Specific to Vencord, not part of the original module. + */ + fileName: string; + + /** + * The name of your style. + * + * The combination of {@link name} and {@link namespace} must be unique. + */ + name: string; + /** + * The namespace of the style. Helps to distinguish between styles with the same name. + * + * The combination of {@link name} and {@link namespace} must be unique. + */ + namespace: string; + /** + * The version of your style. + */ + version: string; + + /** + * A short significant description. + */ + description?: string; + /** + * The author of the style. + */ + author?: string; + /** + * The project's homepage. + * + * This is not an update URL. See {@link updateURL}. + */ + homepageURL?: string; + /** + * The URL the user can report issues to the style author. + */ + supportURL?: string; + /** + * The URL used when updating the style. + */ + updateURL?: string; + /** + * The SPDX license identifier for this style. If none is included, the style is assumed to be All Rights Reserved. + */ + license?: string; + /** + * The CSS preprocessor used to write this style. + * + * @vencord Unimplemented in Vencord, just part of the metadata. + */ + preprocessor?: string; + + /** + * A list of variables the style defines. + */ + vars: Record; + } + + export function parse(text: string): { metadata: UserstyleHeader; }; +}