diff options
-rw-r--r-- | browser/VencordNativeStub.ts | 18 | ||||
-rw-r--r-- | src/VencordNative.ts | 13 | ||||
-rw-r--r-- | src/api/Settings.ts | 8 | ||||
-rw-r--r-- | src/components/PluginSettings/index.tsx | 29 | ||||
-rw-r--r-- | src/components/PluginSettings/styles.css | 53 | ||||
-rw-r--r-- | src/components/VencordSettings/AddonCard.tsx | 77 | ||||
-rw-r--r-- | src/components/VencordSettings/ThemesTab.tsx | 299 | ||||
-rw-r--r-- | src/components/VencordSettings/addonCard.css | 63 | ||||
-rw-r--r-- | src/components/VencordSettings/settingsStyles.css | 3 | ||||
-rw-r--r-- | src/components/VencordSettings/shared.tsx | 1 | ||||
-rw-r--r-- | src/components/VencordSettings/themesStyles.css | 29 | ||||
-rw-r--r-- | src/main/index.ts | 16 | ||||
-rw-r--r-- | src/main/ipcMain.ts | 45 | ||||
-rw-r--r-- | src/main/themes/LICENSE | 177 | ||||
-rw-r--r-- | src/main/themes/index.ts | 81 | ||||
-rw-r--r-- | src/main/utils/constants.ts | 1 | ||||
-rw-r--r-- | src/utils/IpcEvents.ts | 6 | ||||
-rw-r--r-- | src/utils/quickCss.ts | 24 |
18 files changed, 813 insertions, 130 deletions
diff --git a/browser/VencordNativeStub.ts b/browser/VencordNativeStub.ts index 515ccc3..664e9ee 100644 --- a/browser/VencordNativeStub.ts +++ b/browser/VencordNativeStub.ts @@ -23,6 +23,7 @@ import monacoHtml from "~fileContent/../src/components/monacoWin.html"; import * as DataStore from "../src/api/DataStore"; import { debounce } from "../src/utils"; import { getTheme, Theme } from "../src/utils/discord"; +import { getThemeInfo } from "../src/main/themes"; // Discord deletes this so need to store in variable const { localStorage } = window; @@ -34,8 +35,20 @@ const NOOP_ASYNC = async () => { }; const setCssDebounced = debounce((css: string) => VencordNative.quickCss.set(css)); +const themeStore = DataStore.createStore("VencordThemes", "VencordThemeData"); + // probably should make this less cursed at some point window.VencordNative = { + themes: { + uploadTheme: (fileName: string, fileData: string) => DataStore.set(fileName, fileData, themeStore), + deleteTheme: (fileName: string) => DataStore.del(fileName, themeStore), + getThemesDir: async () => "", + getThemesList: () => DataStore.entries(themeStore).then(entries => + entries.map(([name, css]) => getThemeInfo(css, name.toString())) + ), + getThemeData: (fileName: string) => DataStore.get(fileName, themeStore) + }, + native: { getVersions: () => ({}), openExternal: async (url) => void open(url, "_blank") @@ -57,6 +70,7 @@ window.VencordNative = { addChangeListener(cb) { cssListeners.add(cb); }, + addThemeChangeListener: NOOP, openFile: NOOP_ASYNC, async openEditor() { const features = `popup,width=${Math.min(window.innerWidth, 1000)},height=${Math.min(window.innerHeight, 1000)}`; @@ -81,5 +95,7 @@ window.VencordNative = { get: () => localStorage.getItem("VencordSettings") || "{}", set: async (s: string) => localStorage.setItem("VencordSettings", s), getSettingsDir: async () => "LocalStorage" - } + }, + + pluginHelpers: {} as any, }; diff --git a/src/VencordNative.ts b/src/VencordNative.ts index ed0686d..da09ade 100644 --- a/src/VencordNative.ts +++ b/src/VencordNative.ts @@ -19,6 +19,7 @@ import { IpcEvents } from "@utils/IpcEvents"; import { IpcRes } from "@utils/types"; import { ipcRenderer } from "electron"; +import type { UserThemeHeader } from "main/themes"; function invoke<T = any>(event: IpcEvents, ...args: any[]) { return ipcRenderer.invoke(event, ...args) as Promise<T>; @@ -29,6 +30,14 @@ export function sendSync<T = any>(event: IpcEvents, ...args: any[]) { } export default { + themes: { + uploadTheme: (fileName: string, fileData: string) => invoke<void>(IpcEvents.UPLOAD_THEME, fileName, fileData), + deleteTheme: (fileName: string) => invoke<void>(IpcEvents.DELETE_THEME, fileName), + getThemesDir: () => invoke<string>(IpcEvents.GET_THEMES_DIR), + getThemesList: () => invoke<Array<UserThemeHeader>>(IpcEvents.GET_THEMES_LIST), + getThemeData: (fileName: string) => invoke<string | undefined>(IpcEvents.GET_THEME_DATA, fileName) + }, + updater: { getUpdates: () => invoke<IpcRes<Record<"hash" | "author" | "message", string>[]>>(IpcEvents.GET_UPDATES), update: () => invoke<IpcRes<boolean>>(IpcEvents.UPDATE), @@ -50,6 +59,10 @@ export default { ipcRenderer.on(IpcEvents.QUICK_CSS_UPDATE, (_, css) => cb(css)); }, + addThemeChangeListener(cb: () => void) { + ipcRenderer.on(IpcEvents.THEME_UPDATE, cb); + }, + openFile: () => invoke<void>(IpcEvents.OPEN_QUICKCSS), openEditor: () => invoke<void>(IpcEvents.OPEN_MONACO_EDITOR), }, diff --git a/src/api/Settings.ts b/src/api/Settings.ts index 709050f..c380f63 100644 --- a/src/api/Settings.ts +++ b/src/api/Settings.ts @@ -34,6 +34,7 @@ export interface Settings { useQuickCss: boolean; enableReactDevtools: boolean; themeLinks: string[]; + enabledThemes: string[]; frameless: boolean; transparent: boolean; winCtrlQ: boolean; @@ -68,6 +69,7 @@ const DefaultSettings: Settings = { autoUpdateNotification: true, useQuickCss: true, themeLinks: [], + enabledThemes: [], enableReactDevtools: false, frameless: false, transparent: false, @@ -107,7 +109,7 @@ const saveSettingsOnFrequentAction = debounce(async () => { } }, 60_000); -type SubscriptionCallback = ((newValue: any, path: string) => void) & { _path?: string; }; +type SubscriptionCallback = ((newValue: any, path: string) => void) & { _paths?: Array<string>; }; const subscriptions = new Set<SubscriptionCallback>(); const proxyCache = {} as Record<string, any>; @@ -164,7 +166,7 @@ function makeProxy(settings: any, root = settings, path = ""): Settings { const setPath = `${path}${path && "."}${p}`; delete proxyCache[setPath]; for (const subscription of subscriptions) { - if (!subscription._path || subscription._path === setPath) { + if (!subscription._paths || subscription._paths.includes(setPath)) { subscription(v, setPath); } } @@ -235,7 +237,7 @@ type ResolvePropDeep<T, P> = P extends "" ? T : export function addSettingsListener<Path extends keyof Settings>(path: Path, onUpdate: (newValue: Settings[Path], path: Path) => void): void; export function addSettingsListener<Path extends string>(path: Path, onUpdate: (newValue: Path extends "" ? any : ResolvePropDeep<Settings, Path>, path: Path extends "" ? string : Path) => void): void; export function addSettingsListener(path: string, onUpdate: (newValue: any, path: string) => void) { - (onUpdate as SubscriptionCallback)._path = path; + ((onUpdate as SubscriptionCallback)._paths ??= []).push(path); subscriptions.add(onUpdate); } diff --git a/src/components/PluginSettings/index.tsx b/src/components/PluginSettings/index.tsx index 7749abd..12487c6 100644 --- a/src/components/PluginSettings/index.tsx +++ b/src/components/PluginSettings/index.tsx @@ -22,10 +22,8 @@ import * as DataStore from "@api/DataStore"; import { showNotice } from "@api/Notices"; import { Settings, useSettings } from "@api/Settings"; import { classNameFactory } from "@api/Styles"; -import { Flex } from "@components/Flex"; -import { Badge } from "@components/PluginSettings/components"; import PluginModal from "@components/PluginSettings/PluginModal"; -import { Switch } from "@components/Switch"; +import { AddonCard } from "@components/VencordSettings/AddonCard"; import { SettingsTab } from "@components/VencordSettings/shared"; import { ChangeList } from "@utils/ChangeList"; import { Logger } from "@utils/Logger"; @@ -152,24 +150,23 @@ function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, onMouseLe } return ( - <Flex className={cl("card", { "card-disabled": disabled })} flexDirection="column" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}> - <div className={cl("card-header")}> - <Text variant="text-md/bold" className={cl("name")}> - {plugin.name}{isNew && <Badge text="NEW" color="#ED4245" />} - </Text> + <AddonCard + name={plugin.name} + description={plugin.description} + isNew={isNew} + enabled={isEnabled()} + setEnabled={toggleEnabled} + disabled={disabled} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + infoButton={ <button role="switch" onClick={() => openModal()} className={classes(ButtonClasses.button, cl("info-button"))}> {plugin.options ? <CogWheel /> : <InfoIcon width="24" height="24" />} </button> - <Switch - checked={isEnabled()} - onChange={toggleEnabled} - disabled={disabled} - /> - </div> - <Text className={cl("note")} variant="text-sm/normal">{plugin.description}</Text> - </Flex > + } + /> ); } diff --git a/src/components/PluginSettings/styles.css b/src/components/PluginSettings/styles.css index a756fa9..66b2a21 100644 --- a/src/components/PluginSettings/styles.css +++ b/src/components/PluginSettings/styles.css @@ -23,38 +23,6 @@ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } -.vc-plugins-card { - background-color: var(--background-secondary-alt); - color: var(--interactive-active); - border-radius: 8px; - display: block; - height: 100%; - padding: 12px; - width: 100%; - transition: 0.1s ease-out; - transition-property: box-shadow, transform, background, opacity; -} - -.vc-plugins-card-disabled { - opacity: 0.6; -} - -.vc-plugins-card:hover { - background-color: var(--background-tertiary); - transform: translateY(-1px); - box-shadow: var(--elevation-high); -} - -.vc-plugins-card-header { - margin-top: auto; - display: flex; - width: 100%; - justify-content: flex-end; - height: 1.5rem; - align-items: center; - gap: 8px; -} - .vc-plugins-info-button { height: 24px; width: 24px; @@ -86,27 +54,6 @@ text-align: center; } -.vc-plugins-note { - height: 36px; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - /* stylelint-disable-next-line property-no-unknown */ - box-orient: vertical; -} - -.vc-plugins-name { - display: flex; - width: 100%; - align-items: center; - flex-grow: 1; - gap: 8px; - cursor: "default"; -} - .vc-plugins-dep-name { margin: 0 auto; } diff --git a/src/components/VencordSettings/AddonCard.tsx b/src/components/VencordSettings/AddonCard.tsx new file mode 100644 index 0000000..c4c3aac --- /dev/null +++ b/src/components/VencordSettings/AddonCard.tsx @@ -0,0 +1,77 @@ +/* + * 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 <https://www.gnu.org/licenses/>. +*/ + +import "./addonCard.css"; + +import { classNameFactory } from "@api/Styles"; +import { Badge } from "@components/Badge"; +import { Switch } from "@components/Switch"; +import { Text } from "@webpack/common"; +import type { MouseEventHandler, ReactNode } from "react"; + +const cl = classNameFactory("vc-addon-"); + +interface Props { + name: ReactNode; + description: ReactNode; + enabled: boolean; + setEnabled: (enabled: boolean) => void; + disabled?: boolean; + isNew?: boolean; + onMouseEnter?: MouseEventHandler<HTMLDivElement>; + onMouseLeave?: MouseEventHandler<HTMLDivElement>; + + infoButton?: ReactNode; + footer?: ReactNode; + author?: ReactNode; +} + +export function AddonCard({ disabled, isNew, name, infoButton, footer, author, enabled, setEnabled, description, onMouseEnter, onMouseLeave }: Props) { + return ( + <div + className={cl("card", { "card-disabled": disabled })} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + > + <div className={cl("header")}> + <div className={cl("name-author")}> + <Text variant="text-md/bold" className={cl("name")}> + {name}{isNew && <Badge text="NEW" color="#ED4245" />} + </Text> + {!!author && ( + <Text variant="text-md/normal" className={cl("author")}> + {author} + </Text> + )} + </div> + + {infoButton} + + <Switch + checked={enabled} + onChange={setEnabled} + disabled={disabled} + /> + </div> + + <Text className={cl("note")} variant="text-sm/normal">{description}</Text> + + {footer} + </div> + ); +} diff --git a/src/components/VencordSettings/ThemesTab.tsx b/src/components/VencordSettings/ThemesTab.tsx index a670394..c44ad45 100644 --- a/src/components/VencordSettings/ThemesTab.tsx +++ b/src/components/VencordSettings/ThemesTab.tsx @@ -17,16 +17,35 @@ */ import { useSettings } from "@api/Settings"; +import { classNameFactory } from "@api/Styles"; +import { Flex } from "@components/Flex"; import { Link } from "@components/Link"; import { Margins } from "@utils/margins"; +import { classes } from "@utils/misc"; +import { showItemInFolder } from "@utils/native"; import { useAwaiter } from "@utils/react"; -import { findLazy } from "@webpack"; -import { Button, Card, Forms, React, TextArea } from "@webpack/common"; +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 type { ComponentType, Ref, SyntheticEvent } from "react"; +import { AddonCard } from "./AddonCard"; import { SettingsTab, wrapTab } from "./shared"; +type FileInput = ComponentType<{ + ref: Ref<HTMLInputElement>; + onChange: (e: SyntheticEvent<HTMLInputElement>) => void; + multiple?: boolean; + filters?: { name?: string; extensions: string[]; }[]; +}>; + +const InviteActions = findByPropsLazy("resolveInvite"); +const TrashIcon = findByCodeLazy("M5 6.99902V18.999C5 20.101 5.897 20.999"); +const FileInput: FileInput = findByCodeLazy("activateUploadDialogue="); const TextAreaProps = findLazy(m => typeof m.textarea === "string"); +const cl = classNameFactory("vc-settings-theme-"); + function Validator({ link }: { link: string; }) { const [res, err, pending] = useAwaiter(() => fetch(link).then(res => { if (res.status > 300) throw `${res.status} ${res.statusText}`; @@ -75,10 +94,191 @@ function Validators({ themeLinks }: { themeLinks: string[]; }) { ); } +interface ThemeCardProps { + theme: UserThemeHeader; + enabled: boolean; + onChange: (enabled: boolean) => void; + onDelete: () => void; +} + +function ThemeCard({ theme, enabled, onChange, onDelete }: ThemeCardProps) { + return ( + <AddonCard + name={theme.name} + description={theme.description} + author={theme.author} + enabled={enabled} + setEnabled={onChange} + infoButton={ + IS_WEB && ( + <div style={{ cursor: "pointer", color: "var(--status-danger" }} onClick={onDelete}> + <TrashIcon /> + </div> + ) + } + footer={ + <Flex flexDirection="row" style={{ gap: "0.2em" }}> + {!!theme.website && <Link href={theme.website}>Website</Link>} + {!!(theme.website && theme.invite) && " • "} + {!!theme.invite && ( + <Link + href={`https://discord.gg/${theme.invite}`} + onClick={async e => { + e.preventDefault(); + const { invite } = await InviteActions.resolveInvite(theme.invite, "Desktop Modal"); + if (!invite) return showToast("Invalid or expired invite"); + + FluxDispatcher.dispatch({ + type: "INVITE_MODAL_OPEN", + invite, + code: theme.invite, + context: "APP" + }); + }} + > + Discord Server + </Link> + )} + </Flex> + } + /> + ); +} + +enum ThemeTab { + LOCAL, + ONLINE +} + function ThemesTab() { - const settings = useSettings(["themeLinks"]); - const [themeText, setThemeText] = React.useState(settings.themeLinks.join("\n")); + const settings = useSettings(["themeLinks", "enabledThemes"]); + + const fileInputRef = useRef<HTMLInputElement>(null); + const [currentTab, setCurrentTab] = useState(ThemeTab.LOCAL); + const [themeText, setThemeText] = useState(settings.themeLinks.join("\n")); + const [userThemes, setUserThemes] = useState<UserThemeHeader[] | null>(null); + const [themeDir, , themeDirPending] = useAwaiter(VencordNative.themes.getThemesDir); + useEffect(() => { + refreshLocalThemes(); + }, []); + + async function refreshLocalThemes() { + const themes = await VencordNative.themes.getThemesList(); + setUserThemes(themes); + } + + // When a local theme is enabled/disabled, update the settings + function onLocalThemeChange(fileName: string, value: boolean) { + if (value) { + if (settings.enabledThemes.includes(fileName)) return; + settings.enabledThemes = [...settings.enabledThemes, fileName]; + } else { + settings.enabledThemes = settings.enabledThemes.filter(f => f !== fileName); + } + } + + async function onFileUpload(e: SyntheticEvent<HTMLInputElement>) { + e.stopPropagation(); + e.preventDefault(); + if (!e.currentTarget?.files?.length) return; + const { files } = e.currentTarget; + + const uploads = Array.from(files, file => { + const { name } = file; + if (!name.endsWith(".css")) return; + + return new Promise<void>((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + VencordNative.themes.uploadTheme(name, reader.result as string) + .then(resolve) + .catch(reject); + }; + reader.readAsText(file); + }); + }); + + await Promise.all(uploads); + refreshLocalThemes(); + } + + function renderLocalThemes() { + return ( + <> + <Card className="vc-settings-card"> + <Forms.FormTitle tag="h5">Find Themes:</Forms.FormTitle> + <div style={{ marginBottom: ".5em", display: "flex", flexDirection: "column" }}> + <Link style={{ marginRight: ".5em" }} href="https://betterdiscord.app/themes"> + BetterDiscord Themes + </Link> + <Link href="https://github.com/search?q=discord+theme">GitHub</Link> + </div> + <Forms.FormText>If using the BD site, click on "Download" and place the downloaded .theme.css file into your themes folder.</Forms.FormText> + </Card> + + <Forms.FormSection title="Local Themes"> + <Card className="vc-settings-quick-actions-card"> + <> + {IS_WEB ? + ( + <Button + size={Button.Sizes.SMALL} + disabled={themeDirPending} + > + Upload Theme + <FileInput + ref={fileInputRef} + onChange={onFileUpload} + multiple={true} + filters={[{ extensions: ["*.css"] }]} + /> + </Button> + ) : ( + <Button + onClick={() => showItemInFolder(themeDir!)} + size={Button.Sizes.SMALL} + disabled={themeDirPending} + > + Open Themes Folder + </Button> + )} + <Button + onClick={refreshLocalThemes} + size={Button.Sizes.SMALL} + > + Load missing Themes + </Button> + <Button + onClick={() => VencordNative.quickCss.openEditor()} + size={Button.Sizes.SMALL} + > + Edit QuickCSS + </Button> + </> + </Card> + + <div className={cl("grid")}> + {userThemes?.map(theme => ( + <ThemeCard + key={theme.fileName} + enabled={settings.enabledThemes.includes(theme.fileName)} + onChange={enabled => onLocalThemeChange(theme.fileName, enabled)} + onDelete={async () => { + onLocalThemeChange(theme.fileName, false); + await VencordNative.themes.deleteTheme(theme.fileName); + refreshLocalThemes(); + }} + theme={theme} + /> + ))} + </div> + </Forms.FormSection> + </> + ); + } + + // When the user leaves the online theme textbox, update the settings function onBlur() { settings.themeLinks = [...new Set( themeText @@ -89,51 +289,56 @@ function ThemesTab() { )]; } + function renderOnlineThemes() { + return ( + <> + <Card className="vc-settings-card vc-text-selectable"> + <Forms.FormTitle tag="h5">Paste links to css files here</Forms.FormTitle> + <Forms.FormText>One link per line</Forms.FormText> + <Forms.FormText>Make sure to use direct links to files (raw or github.io)!</Forms.FormText> + </Card> + + <Forms.FormSection title="Online Themes" tag="h5"> + <TextArea + value={themeText} + onChange={setThemeText} + className={classes(TextAreaProps.textarea, "vc-settings-theme-links")} + placeholder="Theme Links" + spellCheck={false} + onBlur={onBlur} + rows={10} + /> + <Validators themeLinks={settings.themeLinks} /> + </Forms.FormSection> + </> + ); + } + return ( <SettingsTab title="Themes"> - <Card className="vc-settings-card vc-text-selectable"> - <Forms.FormTitle tag="h5">Paste links to .theme.css files here</Forms.FormTitle> - <Forms.FormText>One link per line</Forms.FormText> - <Forms.FormText><strong>Make sure to use the raw links or github.io links!</strong></Forms.FormText> - <Forms.FormDivider className={Margins.top8 + " " + Margins.bottom8} /> - <Forms.FormTitle tag="h5">Find Themes:</Forms.FormTitle> - <div style={{ marginBottom: ".5em" }}> - <Link style={{ marginRight: ".5em" }} href="https://betterdiscord.app/themes"> - BetterDiscord Themes - </Link> - <Link href="https://github.com/search?q=discord+theme">GitHub</Link> - </div> - <Forms.FormText>If using the BD site, click on "Source" somewhere below the Download button</Forms.FormText> - <Forms.FormText>In the GitHub repository of your theme, find X.theme.css, click on it, then click the "Raw" button</Forms.FormText> - <Forms.FormText> - If the theme has configuration that requires you to edit the file: - <ul> - <li>• Make a <Link href="https://github.com/signup">GitHub</Link> account</li> - <li>• Click the fork button on the top right</li> - <li>• Edit the file</li> - <li>• Use the link to your own repository instead</li> - <li>• Use the link to your own repository instead </li> - <li>OR</li> - <li>• Paste the contents of the edited theme file into the QuickCSS editor</li> - </ul> - <Forms.FormDivider className={Margins.top8 + " " + Margins.bottom16} /> - <Button - onClick={() => VencordNative.quickCss.openEditor()} - size={Button.Sizes.SMALL}> - Open QuickCSS File - </Button> - </Forms.FormText> - </Card> - <Forms.FormTitle tag="h5">Themes</Forms.FormTitle> - <TextArea - value={themeText} - onChange={setThemeText} - className={`${TextAreaProps.textarea} vc-settings-theme-links`} - placeholder="Theme Links" - spellCheck={false} - onBlur={onBlur} - /> - <Validators themeLinks={settings.themeLinks} /> + <TabBar + type="top" + look="brand" + className="vc-settings-tab-bar" + selectedItem={currentTab} + onItemSelect={setCurrentTab} + > + <TabBar.Item + className="vc-settings-tab-bar-item" + id={ThemeTab.LOCAL} + > + Local Themes + </TabBar.Item> + <TabBar.Item + className="vc-settings-tab-bar-item" + id={ThemeTab.ONLINE} + > + Online Themes + </TabBar.Item> + </TabBar> + + {currentTab === ThemeTab.LOCAL && renderLocalThemes()} + {currentTab === ThemeTab.ONLINE && renderOnlineThemes()} </SettingsTab> ); } diff --git a/src/components/VencordSettings/addonCard.css b/src/components/VencordSettings/addonCard.css new file mode 100644 index 0000000..92f8c25 --- /dev/null +++ b/src/components/VencordSettings/addonCard.css @@ -0,0 +1,63 @@ +.vc-addon-card { + background-color: var(--background-secondary-alt); + color: var(--interactive-active); + border-radius: 8px; + display: block; + height: 100%; + padding: 12px; + width: 100%; + transition: 0.1s ease-out; + transition-property: box-shadow, transform, background, opacity; +} + +.vc-addon-card-disabled { + opacity: 0.6; +} + +.vc-addon-card:hover { + background-color: var(--background-tertiary); + transform: translateY(-1px); + box-shadow: var(--elevation-high); +} + +.vc-addon-header { + margin-top: auto; + display: flex; + width: 100%; + justify-content: flex-end; + align-items: center; + gap: 8px; + margin-bottom: 0.5em; +} + +.vc-addon-note { + height: 36px; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + /* stylelint-disable-next-line property-no-unknown */ + box-orient: vertical; +} + +.vc-addon-name-author { + width: 100%; +} + +.vc-addon-name { + display: flex; + width: 100%; + align-items: center; + flex-grow: 1; + gap: 8px; +} + +.vc-addon-author { + font-size: 0.8em; +} + +.vc-addon-author::before { + content: "by "; +} diff --git a/src/components/VencordSettings/settingsStyles.css b/src/components/VencordSettings/settingsStyles.css index f8502c5..01cbcd5 100644 --- a/src/components/VencordSettings/settingsStyles.css +++ b/src/components/VencordSettings/settingsStyles.css @@ -1,6 +1,6 @@ .vc-settings-tab-bar { margin-top: 20px; - margin-bottom: -2px; + margin-bottom: 10px; border-bottom: 2px solid var(--background-modifier-accent); } @@ -43,6 +43,7 @@ color: var(--text-normal) !important; padding: 0.5em; border: 1px solid var(--background-modifier-accent); + max-height: unset; } .vc-cloud-settings-sync-grid { diff --git a/src/components/VencordSettings/shared.tsx b/src/components/VencordSettings/shared.tsx index 0d3910d..6dd34c4 100644 --- a/src/components/VencordSettings/shared.tsx +++ b/src/components/VencordSettings/shared.tsx @@ -17,6 +17,7 @@ */ import "./settingsStyles.css"; +import "./themesStyles.css"; import ErrorBoundary from "@components/ErrorBoundary"; import { handleComponentFailed } from "@components/handleComponentFailed"; diff --git a/src/components/VencordSettings/themesStyles.css b/src/components/VencordSettings/themesStyles.css new file mode 100644 index 0000000..6038274 --- /dev/null +++ b/src/components/VencordSettings/themesStyles.css @@ -0,0 +1,29 @@ +.vc-settings-theme-grid { + display: grid; + grid-gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); +} + +.vc-settings-theme-card { + display: flex; + flex-direction: column; + background-color: var(--background-secondary-alt); + color: var(--interactive-active); + border-radius: 8px; + padding: 1em; + width: 100%; + transition: 0.1s ease-out; + transition-property: box-shadow, transform, background, opacity; +} + +.vc-settings-theme-card-text { + text-overflow: ellipsis; + height: 1.2em; + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; +} + +.vc-settings-theme-author::before { + content: "by "; +} diff --git a/src/main/index.ts b/src/main/index.ts index cb723bb..a8b9429 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -19,8 +19,8 @@ import { app, protocol, session } from "electron"; import { join } from "path"; -import { getSettings } from "./ipcMain"; -import { IS_VANILLA } from "./utils/constants"; +import { ensureSafePath, getSettings } from "./ipcMain"; +import { IS_VANILLA, THEMES_DIR } from "./utils/constants"; import { installExt } from "./utils/extensions"; if (IS_VENCORD_DESKTOP || !IS_VANILLA) { @@ -30,6 +30,16 @@ if (IS_VENCORD_DESKTOP || !IS_VANILLA) { protocol.registerFileProtocol("vencord", ({ url: unsafeUrl }, cb) => { let url = unsafeUrl.slice("vencord://".length); if (url.endsWith("/")) url = url.slice(0, -1); + if (url.startsWith("/themes/")) { + const theme = url.slice("/themes/".length); + const safeUrl = ensureSafePath(THEMES_DIR, theme); + if (!safeUrl) { + cb({ statusCode: 403 }); + return; + } + cb(safeUrl.replace(/\?v=\d+$/, "")); + return; + } switch (url) { case "renderer.js.map": case "vencordDesktopRenderer.js.map": @@ -75,7 +85,7 @@ if (IS_VENCORD_DESKTOP || !IS_VANILLA) { const csp = parsePolicy(headers[header][0]); for (const directive of ["style-src", "connect-src", "img-src", "font-src", "media-src", "worker-src"]) { - csp[directive] = ["*", "blob:", "data:", "'unsafe-inline'"]; + csp[directive] = ["*", "blob:", "data:", "vencord:", "'unsafe-inline'"]; } // TODO: Restrict this to only imported packages with fixed version. // Perhaps auto generate with esbuild diff --git a/src/main/ipcMain.ts b/src/main/ipcMain.ts index d62888c..1dcb17c 100644 --- a/src/main/ipcMain.ts +++ b/src/main/ipcMain.ts @@ -24,19 +24,50 @@ import { IpcEvents } from "@utils/IpcEvents"; import { Queue } from "@utils/Queue"; import { BrowserWindow, ipcMain, shell } from "electron"; import { mkdirSync, readFileSync, watch } from "fs"; -import { open, readFile, writeFile } from "fs/promises"; -import { join } from "path"; +import { open, readdir, readFile, writeFile } from "fs/promises"; +import { join, normalize } from "path"; import monacoHtml from "~fileContent/../components/monacoWin.html;base64"; -import { ALLOWED_PROTOCOLS, QUICKCSS_PATH, SETTINGS_DIR, SETTINGS_FILE } from "./utils/constants"; +import { getThemeInfo, stripBOM, UserThemeHeader } from "./themes"; +import { ALLOWED_PROTOCOLS, QUICKCSS_PATH, SETTINGS_DIR, SETTINGS_FILE, THEMES_DIR } from "./utils/constants"; mkdirSync(SETTINGS_DIR, { recursive: true }); +mkdirSync(THEMES_DIR, { recursive: true }); + +export function ensureSafePath(basePath: string, path: string) { + const normalizedBasePath = normalize(basePath); + const newPath = join(basePath, path); + const normalizedPath = normalize(newPath); + return normalizedPath.startsWith(normalizedBasePath) ? normalizedPath : null; +} function readCss() { return readFile(QUICKCSS_PATH, "utf-8").catch(() => ""); } +async function listThemes(): Promise<UserThemeHeader[]> { + const files = await readdir(THEMES_DIR).catch(() => []); + + const themeInfo: UserThemeHeader[] = []; + + for (const fileName of files) { + const data = await getThemeData(fileName).then(stripBOM).catch(() => null); + if (!data) continue; + const parsed = getThemeInfo(data, fileName); + themeInfo.push(parsed); + } + + return themeInfo; +} + +function getThemeData(fileName: string) { + fileName = fileName.replace(/\?v=\d+$/, ""); + const safePath = ensureSafePath(THEMES_DIR, fileName); + if (!safePath) return Promise.reject(`Unsafe path ${fileName}`); + return readFile(safePath, "utf-8"); +} + export function readSettings() { try { return readFileSync(SETTINGS_FILE, "utf-8"); @@ -75,6 +106,10 @@ ipcMain.handle(IpcEvents.SET_QUICK_CSS, (_, css) => cssWriteQueue.push(() => writeFile(QUICKCSS_PATH, css)) ); +ipcMain.handle(IpcEvents.GET_THEMES_DIR, () => THEMES_DIR); +ipcMain.handle(IpcEvents.GET_THEMES_LIST, () => listThemes()); +ipcMain.handle(IpcEvents.GET_THEME_DATA, (_, fileName) => getThemeData(fileName)); + ipcMain.handle(IpcEvents.GET_SETTINGS_DIR, () => SETTINGS_DIR); ipcMain.on(IpcEvents.GET_SETTINGS, e => e.returnValue = readSettings()); @@ -90,6 +125,10 @@ export function initIpc(mainWindow: BrowserWindow) { mainWindow.webContents.postMessage(IpcEvents.QUICK_CSS_UPDATE, await readCss()); }, 50)); }); + + watch(THEMES_DIR, { persistent: false }, debounce(() => { + mainWindow.webContents.postMessage(IpcEvents.THEME_UPDATE, void 0); + })); } ipcMain.handle(IpcEvents.OPEN_MONACO_EDITOR, async () => { diff --git a/src/main/themes/LICENSE b/src/main/themes/LICENSE new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/src/main/themes/LICENSE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/src/main/themes/index.ts b/src/main/themes/index.ts new file mode 100644 index 0000000..f660e50 --- /dev/null +++ b/src/main/themes/index.ts @@ -0,0 +1,81 @@ +/* eslint-disable 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> = {}): 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<UserThemeHeader> = {}; + 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/utils/constants.ts b/src/main/utils/constants.ts index 8ebf7f4..cd6e509 100644 --- a/src/main/utils/constants.ts +++ b/src/main/utils/constants.ts @@ -25,6 +25,7 @@ export const DATA_DIR = process.env.VENCORD_USER_DATA_DIR ?? ( : join(app.getPath("userData"), "..", "Vencord") ); export const SETTINGS_DIR = join(DATA_DIR, "settings"); +export const THEMES_DIR = join(DATA_DIR, "themes"); export const QUICKCSS_PATH = join(SETTINGS_DIR, "quickCss.css"); export const SETTINGS_FILE = join(SETTINGS_DIR, "settings.json"); export const ALLOWED_PROTOCOLS = [ diff --git a/src/utils/IpcEvents.ts b/src/utils/IpcEvents.ts index 6994c91..cb51f81 100644 --- a/src/utils/IpcEvents.ts +++ b/src/utils/IpcEvents.ts @@ -18,8 +18,14 @@ export const enum IpcEvents { QUICK_CSS_UPDATE = "VencordQuickCssUpdate", + THEME_UPDATE = "VencordThemeUpdate", GET_QUICK_CSS = "VencordGetQuickCss", SET_QUICK_CSS = "VencordSetQuickCss", + UPLOAD_THEME = "VencordUploadTheme", + DELETE_THEME = "VencordDeleteTheme", + GET_THEMES_DIR = "VencordGetThemesDir", + GET_THEMES_LIST = "VencordGetThemesList", + GET_THEME_DATA = "VencordGetThemeData", GET_SETTINGS_DIR = "VencordGetSettingsDir", GET_SETTINGS = "VencordGetSettings", SET_SETTINGS = "VencordSetSettings", diff --git a/src/utils/quickCss.ts b/src/utils/quickCss.ts index 0ce50e5..488dcfb 100644 --- a/src/utils/quickCss.ts +++ b/src/utils/quickCss.ts @@ -46,9 +46,23 @@ async function initThemes() { document.documentElement.appendChild(themesStyle); } - const { themeLinks } = Settings; - const links = themeLinks.map(link => `@import url("${link.trim()}");`).join("\n"); - themesStyle.textContent = links; + const { themeLinks, enabledThemes } = Settings; + + const links: string[] = [...themeLinks]; + + if (IS_WEB) { + for (const theme of enabledThemes) { + const themeData = await VencordNative.themes.getThemeData(theme); + if (!themeData) continue; + const blob = new Blob([themeData], { type: "text/css" }); + links.push(URL.createObjectURL(blob)); + } + } else { + const localThemes = enabledThemes.map(theme => `vencord:///themes/${theme}?v=${Date.now()}`); + links.push(...localThemes); + } + + themesStyle.textContent = links.map(link => `@import url("${link.trim()}");`).join("\n"); } document.addEventListener("DOMContentLoaded", () => { @@ -57,4 +71,8 @@ document.addEventListener("DOMContentLoaded", () => { initThemes(); addSettingsListener("themeLinks", initThemes); + addSettingsListener("enabledThemes", initThemes); + + if (!IS_WEB) + VencordNative.quickCss.addThemeChangeListener(initThemes); }); |