├── src ├── backend │ ├── backendHelpers │ │ ├── index.ts │ │ └── toggleTheme.tsx │ ├── pythonRoot.ts │ └── pythonMethods │ │ ├── storeUtils.ts │ │ └── pluginSettingsMethods.ts ├── hooks │ ├── index.ts │ └── useRerender.ts ├── state │ └── index.ts ├── components │ ├── Modals │ │ ├── ThemeSettingsModal │ │ │ ├── index.ts │ │ │ ├── ThemeSettingsModalButtons.tsx │ │ │ └── ThemeSettingsModal.tsx │ │ ├── index.ts │ │ ├── DeleteConfirmationModal.tsx │ │ ├── CreatePresetModal.tsx │ │ └── AuthorViewModal.tsx │ ├── Styles │ │ ├── index.ts │ │ ├── ThemeBrowserCardStyles.tsx │ │ └── ExpandedViewStyles.tsx │ ├── QAMTab │ │ ├── index.ts │ │ ├── QAMThemeToggleList.tsx │ │ ├── PresetSelectionDropdown.tsx │ │ └── MOTDDisplay.tsx │ ├── ThemeManager │ │ ├── index.ts │ │ ├── FilterDropdownCustomLabel.tsx │ │ ├── LoadMoreButton.tsx │ │ ├── BrowserItemCard.tsx │ │ └── BrowserSearchFields.tsx │ ├── index.ts │ ├── ThemeErrorCard.tsx │ ├── ThemeSettings │ │ ├── UpdateAllThemesButton.tsx │ │ ├── DeleteMenu.tsx │ │ ├── FullscreenProfileEntry.tsx │ │ └── FullscreenSingleThemeEntry.tsx │ ├── DepsOptionSelector.tsx │ ├── SupporterIcon.tsx │ ├── OptionalDepsModal.tsx │ ├── TitleView.tsx │ ├── ThemePatch.tsx │ ├── PatchComponent.tsx │ └── ThemeToggle.tsx ├── logic │ ├── index.ts │ ├── calcButtonColor.ts │ ├── generateParamStr.ts │ ├── numbers.ts │ └── bulkThemeUpdateCheck.ts ├── apiTypes │ ├── index.ts │ ├── Motd.ts │ ├── BlobTypes.ts │ ├── AccountData.ts │ └── CSSThemeTypes.ts ├── pages │ ├── theme-manager │ │ ├── index.ts │ │ ├── ThemeManagerRouter.tsx │ │ ├── StarredThemesPage.tsx │ │ ├── LogInPage.tsx │ │ ├── SubmissionBrowserPage.tsx │ │ └── ThemeBrowserPage.tsx │ └── settings │ │ ├── Credits.tsx │ │ ├── PresetSettings.tsx │ │ ├── SettingsPageRouter.tsx │ │ ├── PluginSettings.tsx │ │ ├── ThemeSettings.tsx │ │ └── DonatePage.tsx ├── deckyPatches │ ├── NavControllerFinder.tsx │ ├── SteamTabElementsFinder.tsx │ ├── NavPatchInfoModal.tsx │ ├── ClassHashMap.tsx │ ├── NavPatch.tsx │ └── UnminifyMode.tsx ├── ThemeTypes.ts ├── styles │ └── themeSettingsModalStyles.css ├── index.tsx └── api.ts ├── assets ├── css-store-preview.png └── paint-roller-solid.png ├── requirements.txt ├── README.md ├── .prettierrc ├── plugin.json ├── .gitignore ├── tsconfig.json ├── css_sfp_compat.py ├── css_server.py ├── rollup.config.js ├── package.json ├── .github └── workflows │ └── push.yml ├── css_win_tray.py ├── css_remoteinstall.py ├── css_themepatch.py ├── css_utils.py ├── css_themepatchcomponent.py ├── css_inject.py └── css_theme.py /src/backend/backendHelpers/index.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./useRerender"; 2 | -------------------------------------------------------------------------------- /src/state/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./CssLoaderState"; 2 | -------------------------------------------------------------------------------- /src/components/Modals/ThemeSettingsModal/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ThemeSettingsModal"; 2 | -------------------------------------------------------------------------------- /src/logic/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./calcButtonColor"; 2 | export * from "./generateParamStr"; 3 | -------------------------------------------------------------------------------- /src/components/Modals/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./CreatePresetModal"; 2 | export * from "./ThemeSettingsModal"; 3 | -------------------------------------------------------------------------------- /assets/css-store-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeckThemes/SDH-CssLoader/HEAD/assets/css-store-preview.png -------------------------------------------------------------------------------- /assets/paint-roller-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeckThemes/SDH-CssLoader/HEAD/assets/paint-roller-solid.png -------------------------------------------------------------------------------- /src/components/Styles/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ExpandedViewStyles"; 2 | export * from "./ThemeBrowserCardStyles"; 3 | -------------------------------------------------------------------------------- /src/apiTypes/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./CSSThemeTypes"; 2 | export * from "./AccountData"; 3 | export * from "./BlobTypes"; 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.8.1 2 | aiohttp-jinja2==1.5.0 3 | aiohttp_cors==0.7.0 4 | watchdog==2.1.7 5 | certifi==2022.12.7 6 | pystray -------------------------------------------------------------------------------- /src/backend/pythonRoot.ts: -------------------------------------------------------------------------------- 1 | import { server } from "../python"; 2 | import { globalState } from "../python"; 3 | 4 | export { server, globalState }; 5 | -------------------------------------------------------------------------------- /src/components/QAMTab/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./QAMThemeToggleList"; 2 | export * from "./PresetSelectionDropdown"; 3 | export * from "./MOTDDisplay"; 4 | -------------------------------------------------------------------------------- /src/apiTypes/Motd.ts: -------------------------------------------------------------------------------- 1 | export interface Motd { 2 | id: string; 3 | name: string; 4 | description: string; 5 | date: string; 6 | severity: "High" | "Medium" | "Low"; 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CSS Loader 2 | Dynamically loads CSS files from storage and reloads alongside Steam UI. 3 | 4 | Read the documentation for this tool at [docs.deckthemes.com](https://docs.deckthemes.com/) -------------------------------------------------------------------------------- /src/apiTypes/BlobTypes.ts: -------------------------------------------------------------------------------- 1 | export type BlobType = "Zip" | "Jpg"; 2 | 3 | export interface APIBlob { 4 | id: string; 5 | blobType: BlobType; 6 | uploaded: Date; 7 | downloadCount: number; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/ThemeManager/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./LoadMoreButton"; 2 | export * from "./FilterDropdownCustomLabel"; 3 | export * from "./BrowserItemCard"; 4 | export * from "./BrowserSearchFields"; 5 | -------------------------------------------------------------------------------- /src/pages/theme-manager/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ThemeBrowserPage"; 2 | export * from "./ExpandedView"; 3 | export * from "./LogInPage"; 4 | export * from "./StarredThemesPage"; 5 | export * from "./SubmissionBrowserPage"; 6 | export * from "./ThemeManagerRouter"; 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "tabWidth": 2, 4 | "semi": true, 5 | "trailingComma": "es5", 6 | "bracketSameLine": false, 7 | "printWidth": 100, 8 | "quoteProps": "as-needed", 9 | "bracketSpacing": true, 10 | "arrowParens": "always" 11 | } 12 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ThemeToggle"; 2 | export * from "./ThemePatch"; 3 | export * from "./PatchComponent"; 4 | export * from "./TitleView"; 5 | export * from "./ThemeManager"; 6 | export * from "./OptionalDepsModal"; 7 | export * from "./QAMTab"; 8 | export * from "./Modals"; 9 | -------------------------------------------------------------------------------- /src/logic/calcButtonColor.ts: -------------------------------------------------------------------------------- 1 | export function calcButtonColor(installStatus: string) { 2 | let filterCSS = ""; 3 | switch (installStatus) { 4 | case "outdated": 5 | filterCSS = "invert(6%) sepia(90%) saturate(200%) hue-rotate(160deg) contrast(122%)"; 6 | break; 7 | default: 8 | filterCSS = ""; 9 | break; 10 | } 11 | return filterCSS; 12 | } 13 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "CSS Loader", 3 | "author": "DeckThemes", 4 | "flags": [], 5 | "publish": { 6 | "tags": ["style"], 7 | "description": "Dynamically loads themes developed with CSS into the Steam UI. For more information, visit deckthemes.com.", 8 | "image": "https://raw.githubusercontent.com/suchmememanyskill/SDH-CssLoader/main/assets/css-store-preview.png" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/deckyPatches/NavControllerFinder.tsx: -------------------------------------------------------------------------------- 1 | import { Module, findModuleChild } from "decky-frontend-lib"; 2 | 3 | export const NavController = findModuleChild((m: Module) => { 4 | if (typeof m !== "object") return undefined; 5 | 6 | // Pre Chromium-109 7 | if (m?.CFocusNavNode) { 8 | return m.CFocusNavNode; 9 | } 10 | 11 | for (let prop in m) { 12 | if (m[prop]?.prototype?.FindNextFocusableChildInDirection) { 13 | return m[prop]; 14 | } 15 | } 16 | 17 | return undefined; 18 | }); 19 | -------------------------------------------------------------------------------- /src/components/ThemeManager/FilterDropdownCustomLabel.tsx: -------------------------------------------------------------------------------- 1 | export function FilterDropdownCustomLabel({ 2 | filterValue, 3 | itemCount, 4 | }: { 5 | filterValue: string; 6 | itemCount: number | string; 7 | }) { 8 | return ( 9 |
16 | {filterValue} 17 | {itemCount} 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *.swp 10 | 11 | pids 12 | logs 13 | results 14 | tmp 15 | 16 | # Coverage reports 17 | coverage 18 | 19 | # API keys and secrets 20 | .env 21 | 22 | # Dependency directory 23 | node_modules 24 | bower_components 25 | 26 | # Editors 27 | .idea 28 | *.iml 29 | 30 | # OS metadata 31 | .DS_Store 32 | Thumbs.db 33 | 34 | # Ignore built ts files 35 | dist/ 36 | 37 | __pycache__/ 38 | 39 | /.yalc 40 | yalc.lock 41 | 42 | .vscode/settings.json 43 | -------------------------------------------------------------------------------- /src/hooks/useRerender.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | // This should only be used to fix weird bugs with how valve's toggles/dropdowns/etc don't update state 4 | // If used, state why in a comment next to the invocation 5 | export function useRerender(): [boolean, () => void] { 6 | const [render, setRender] = useState(true); 7 | useEffect(() => { 8 | if (render === false) { 9 | setTimeout(() => setRender(true), 100); 10 | } 11 | }, [render]); 12 | const rerender = () => { 13 | setRender(false); 14 | }; 15 | 16 | return [render, rerender]; 17 | } 18 | -------------------------------------------------------------------------------- /src/logic/generateParamStr.ts: -------------------------------------------------------------------------------- 1 | export function generateParamStr(origSearchOpts: any, filterPrepend: string = "") { 2 | // This can be done with 'new URLSearchParams(obj)' but I want more control 3 | const searchOpts = Object.assign({}, origSearchOpts); 4 | if (filterPrepend) { 5 | searchOpts.filters = filterPrepend + searchOpts.filters; 6 | } 7 | let paramString = "?"; 8 | Object.keys(searchOpts).forEach((key, i) => { 9 | // @ts-ignore typescript doesn't know how object.keys works 🙄 10 | if (searchOpts[key]) { 11 | // @ts-ignore 12 | paramString += `${i !== 0 ? "&" : ""}${key}=${searchOpts[key]}`; 13 | } 14 | }); 15 | return paramString; 16 | } 17 | -------------------------------------------------------------------------------- /src/deckyPatches/SteamTabElementsFinder.tsx: -------------------------------------------------------------------------------- 1 | import { getGamepadNavigationTrees } from "decky-frontend-lib"; 2 | 3 | export function getElementFromNavID(navID: string) { 4 | const all = getGamepadNavigationTrees(); 5 | if (!all) return null; 6 | const tree = all?.find((e: any) => e.m_ID == navID); 7 | if (!tree) return null; 8 | return tree.Root.Element; 9 | } 10 | export function getSP() { 11 | return getElementFromNavID("root_1_"); 12 | } 13 | export function getQAM() { 14 | return getElementFromNavID("QuickAccess-NA"); 15 | } 16 | export function getMainMenu() { 17 | return getElementFromNavID("MainNavMenuContainer"); 18 | } 19 | export function getRootElements() { 20 | return [getSP(), getQAM(), getMainMenu()].filter((e) => e); 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "module": "ESNext", 5 | "target": "ES2020", 6 | "jsx": "react", 7 | "jsxFactory": "window.SP_REACT.createElement", 8 | "jsxFragmentFactory": "window.SP_REACT.Fragment", 9 | "declaration": false, 10 | "moduleResolution": "node", 11 | "noUnusedLocals": false, 12 | "noUnusedParameters": false, 13 | "esModuleInterop": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noImplicitAny": true, 17 | "strict": true, 18 | "suppressImplicitAnyIndexErrors": true, 19 | "allowSyntheticDefaultImports": true, 20 | "skipLibCheck": true 21 | }, 22 | "include": ["src"], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /src/components/ThemeErrorCard.tsx: -------------------------------------------------------------------------------- 1 | import { Focusable, PanelSectionRow } from "decky-frontend-lib"; 2 | import { ThemeError } from "../ThemeTypes"; 3 | 4 | export function ThemeErrorCard({ errorData }: { errorData: ThemeError }) { 5 | return ( 6 | {}} 9 | style={{ 10 | width: "100%", 11 | margin: 0, 12 | padding: 0, 13 | }} 14 | > 15 |
23 | 24 | {errorData[0]} 25 | 26 | {errorData[1]} 27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /css_sfp_compat.py: -------------------------------------------------------------------------------- 1 | import os 2 | from css_inject import Inject, to_inject 3 | 4 | SFP_DEFAULT_FILES = { 5 | "libraryroot.custom.css": ["desktop", "desktopoverlay", "desktopcontextmenu"], 6 | "bigpicture.custom.css": ["bigpicture", "bigpictureoverlay"], 7 | "friends.custom.css": ["desktopchat"], 8 | "webkit.css": ["store"] 9 | } 10 | 11 | def is_folder_sfp_theme(dir : str) -> bool: 12 | for x in SFP_DEFAULT_FILES: 13 | if os.path.exists(os.path.join(dir, x)): 14 | return True 15 | 16 | return False 17 | 18 | def convert_to_css_theme(dir : str, theme) -> None: 19 | theme.name = os.path.basename(dir) 20 | theme.id = theme.name 21 | theme.version = "v1.0" 22 | theme.author = "" 23 | theme.require = 1 24 | theme.dependencies = [] 25 | theme.injects = [to_inject(x, SFP_DEFAULT_FILES[x], dir, theme) for x in SFP_DEFAULT_FILES if os.path.exists(os.path.join(dir, x))] -------------------------------------------------------------------------------- /src/apiTypes/AccountData.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction } from "react"; 2 | import { PartialCSSThemeInfo, UserInfo } from "./CSSThemeTypes"; 3 | 4 | export enum Permissions { 5 | "editAny" = "EditAnyPost", 6 | "approveSubs" = "ApproveThemeSubmissions", 7 | "viewSubs" = "ViewThemeSubmissions", 8 | "admin" = "ManageApi", 9 | } 10 | 11 | export interface AccountData extends UserInfo { 12 | permissions: Permissions[]; 13 | } 14 | 15 | export interface AuthContextContents { 16 | accountInfo: AccountData | undefined; 17 | setAccountInfo: 18 | | Dispatch> 19 | | ((info: AccountData | undefined) => void); 20 | } 21 | 22 | export interface StarContextContents { 23 | starredThemes: StarredThemeList | undefined; 24 | setStarredThemes: 25 | | Dispatch> 26 | | ((info: StarredThemeList | undefined) => void); 27 | } 28 | 29 | export interface StarredThemeList { 30 | total: number; 31 | items: PartialCSSThemeInfo[]; 32 | } 33 | -------------------------------------------------------------------------------- /src/deckyPatches/NavPatchInfoModal.tsx: -------------------------------------------------------------------------------- 1 | import { DialogButton, Focusable, ConfirmModal } from "decky-frontend-lib"; 2 | import { Theme } from "../ThemeTypes"; 3 | import { setNavPatch } from "./NavPatch"; 4 | export function NavPatchInfoModalRoot({ 5 | themeData, 6 | closeModal, 7 | }: { 8 | themeData: Theme; 9 | closeModal?: any; 10 | }) { 11 | function onButtonClick() { 12 | setNavPatch(true, true); 13 | closeModal(); 14 | } 15 | return ( 16 | 24 | 25 | {themeData.name} hides elements that can be selected using a controller. For this to work 26 | correctly, CSS Loader needs to patch controller navigation. Not enabling this feature will 27 | cause visually hidden elements to be able to be selected using a controller. 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/ThemeSettings/UpdateAllThemesButton.tsx: -------------------------------------------------------------------------------- 1 | import { DialogButton } from "decky-frontend-lib"; 2 | import { useCssLoaderState } from "../../state"; 3 | import { Theme } from "../../ThemeTypes"; 4 | import { FaDownload } from "react-icons/fa"; 5 | 6 | export function UpdateAllThemesButton({ 7 | handleUpdate, 8 | }: { 9 | handleUpdate: (entry: Theme) => Promise; 10 | }) { 11 | const { updateStatuses, localThemeList } = useCssLoaderState(); 12 | 13 | async function updateAll() { 14 | const themesToBeUpdated = updateStatuses.filter((e) => e[1] === "outdated"); 15 | for (let i = 0; i < themesToBeUpdated.length; i++) { 16 | const entry = localThemeList.find((f) => f.id === themesToBeUpdated[i][0]); 17 | if (!entry) break; 18 | await handleUpdate(entry); 19 | } 20 | } 21 | return ( 22 | <> 23 | {updateStatuses.filter((e) => e[1] === "outdated").length > 0 && ( 24 | 25 | 26 | Update All Themes 27 | 28 | )} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/logic/numbers.ts: -------------------------------------------------------------------------------- 1 | // Code from the short-number package, could not be imported due 2 | // to TypeScript issues. 3 | // https://www.npmjs.com/package/short-number 4 | 5 | export function shortenNumber(num: number) { 6 | if (typeof num !== "number") { 7 | throw new TypeError("Expected a number"); 8 | } 9 | 10 | if (num > 1e19) { 11 | throw new RangeError("Input expected to be < 1e19"); 12 | } 13 | 14 | if (num < -1e19) { 15 | throw new RangeError("Input expected to be > 1e19"); 16 | } 17 | 18 | if (Math.abs(num) < 1000) { 19 | return num; 20 | } 21 | 22 | var shortNumber; 23 | var exponent; 24 | var size; 25 | var sign = num < 0 ? "-" : ""; 26 | var suffixes = { 27 | K: 6, 28 | M: 9, 29 | B: 12, 30 | T: 16, 31 | }; 32 | 33 | num = Math.abs(num); 34 | size = Math.floor(num).toString().length; 35 | 36 | exponent = size % 3 === 0 ? size - 3 : size - (size % 3); 37 | shortNumber = String(Math.round(10 * (num / Math.pow(10, exponent))) / 10); 38 | 39 | for (var suffix in suffixes) { 40 | if (exponent < suffixes[suffix]) { 41 | shortNumber += suffix; 42 | break; 43 | } 44 | } 45 | 46 | return sign + shortNumber; 47 | } 48 | -------------------------------------------------------------------------------- /src/components/DepsOptionSelector.tsx: -------------------------------------------------------------------------------- 1 | import { DialogButton, Focusable } from "decky-frontend-lib"; 2 | import * as python from "../python"; 3 | 4 | export function DepsOptionSelector({ 5 | themeName, 6 | closeModal = undefined, 7 | }: { 8 | themeName: string; 9 | closeModal?: any; 10 | }) { 11 | function enableTheme(enableDeps: boolean = true, enableDepValues: boolean = true) { 12 | python.resolve(python.setThemeState(themeName, true, enableDeps, enableDepValues), () => { 13 | python.getInstalledThemes(); 14 | closeModal && closeModal(); 15 | }); 16 | } 17 | return ( 18 | 19 | enableTheme(true, true)} style={{ margin: "0 10px" }}> 20 | Enable with configuration {"(Recommended)"} 21 | 22 | enableTheme(true, false)} style={{ margin: "0 10px" }}> 23 | Enable without configuration 24 | 25 | enableTheme(false, false)} style={{ margin: "0 10px" }}> 26 | Enable only this theme 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /css_server.py: -------------------------------------------------------------------------------- 1 | import asyncio, aiohttp.web, json 2 | from css_utils import Log, create_cef_flag 3 | 4 | PLUGIN_CLASS = None 5 | 6 | async def handle(request : aiohttp.web.BaseRequest): 7 | data = await request.json() 8 | request_result = {"res": None, "success": True} 9 | 10 | # This is very cool decky code 11 | try: 12 | request_result["res"] = await getattr(PLUGIN_CLASS, data["method"])(PLUGIN_CLASS, **data["args"]) 13 | except Exception as e: 14 | request_result["res"] = str(e) 15 | request_result["success"] = False 16 | finally: 17 | return aiohttp.web.Response(text=json.dumps(request_result, ensure_ascii=False), content_type='application/json') 18 | 19 | def start_server(plugin): 20 | global PLUGIN_CLASS 21 | 22 | PLUGIN_CLASS = plugin 23 | loop = asyncio.get_running_loop() 24 | 25 | try: 26 | create_cef_flag() 27 | except Exception as e: 28 | Log(f"Failed to create steam cef flag. {str(e)}") 29 | 30 | app = aiohttp.web.Application(loop=loop) 31 | app.router.add_route('POST', '/req', handle) 32 | loop.create_task(aiohttp.web._run_app(app, host="127.0.0.1", port=35821)) 33 | Log("Started CSS_Loader server on port 35821") -------------------------------------------------------------------------------- /src/ThemeTypes.ts: -------------------------------------------------------------------------------- 1 | import { MinimalCSSThemeInfo } from "./apiTypes"; 2 | 3 | export interface Theme { 4 | id: string; 5 | enabled: boolean; // used to be called checked 6 | name: string; 7 | display_name: string; 8 | author: string; 9 | bundled: boolean; // deprecated 10 | require: number; 11 | version: string; 12 | patches: Patch[]; 13 | dependencies: string[]; 14 | flags: Flags[]; 15 | } 16 | 17 | export interface ThemePatchComponent { 18 | name: string; 19 | on: string; 20 | type: string; 21 | value: string; 22 | } 23 | 24 | export interface Patch { 25 | default: string; 26 | name: string; 27 | type: "dropdown" | "checkbox" | "slider" | "none"; 28 | value: string; 29 | options: string[]; 30 | components: ThemePatchComponent[]; 31 | } 32 | 33 | export enum Flags { 34 | "isPreset" = "PRESET", 35 | "dontDisableDeps" = "KEEP_DEPENDENCIES", 36 | "optionalDeps" = "OPTIONAL_DEPENDENCIES", 37 | "navPatch" = "REQUIRE_NAV_PATCH", 38 | } 39 | 40 | export type LocalThemeStatus = "installed" | "outdated" | "local"; 41 | export type UpdateStatus = [string, LocalThemeStatus, false | MinimalCSSThemeInfo]; 42 | 43 | type ThemeErrorTitle = string; 44 | type ThemeErrorDescription = string; 45 | export type ThemeError = [ThemeErrorTitle, ThemeErrorDescription]; 46 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from "@rollup/plugin-commonjs"; 2 | import json from "@rollup/plugin-json"; 3 | import { nodeResolve } from "@rollup/plugin-node-resolve"; 4 | import replace from "@rollup/plugin-replace"; 5 | import typescript from "@rollup/plugin-typescript"; 6 | import { defineConfig } from "rollup"; 7 | import importAssets from "rollup-plugin-import-assets"; 8 | import styles from "rollup-plugin-styles"; 9 | 10 | import { name } from "./plugin.json"; 11 | 12 | export default defineConfig({ 13 | input: "./src/index.tsx", 14 | plugins: [ 15 | commonjs(), 16 | nodeResolve(), 17 | typescript(), 18 | json(), 19 | styles(), 20 | replace({ 21 | preventAssignment: false, 22 | "process.env.NODE_ENV": JSON.stringify("production"), 23 | }), 24 | importAssets({ 25 | publicPath: `http://127.0.0.1:1337/plugins/${name}/`, 26 | }), 27 | ], 28 | context: "window", 29 | external: ["react", "react-dom", "decky-frontend-lib"], 30 | output: { 31 | file: "dist/index.js", 32 | globals: { 33 | react: "SP_REACT", 34 | "react-dom": "SP_REACTDOM", 35 | "decky-frontend-lib": "DFL", 36 | }, 37 | format: "iife", 38 | exports: "default", 39 | assetFileNames: "[name]-[hash][extname]", 40 | }, 41 | }); 42 | -------------------------------------------------------------------------------- /src/backend/pythonMethods/storeUtils.ts: -------------------------------------------------------------------------------- 1 | import { server, toast } from "../../python"; 2 | 3 | export async function booleanStoreRead(key: string) { 4 | const deckyRes = await server!.callPluginMethod<{ key: string }, string>("store_read", { 5 | key, 6 | }); 7 | if (!deckyRes.success) { 8 | toast(`Error fetching ${key}`, deckyRes.result); 9 | return false; 10 | } 11 | return deckyRes.result === "1" || deckyRes.result === "true"; 12 | } 13 | 14 | export async function booleanStoreWrite(key: string, value: boolean) { 15 | const deckyRes = await server!.callPluginMethod<{ key: string; val: string }>("store_write", { 16 | key, 17 | val: value ? "1" : "0", 18 | }); 19 | if (!deckyRes.success) { 20 | toast(`Error setting ${key}`, deckyRes.result); 21 | } 22 | } 23 | 24 | export async function stringStoreRead(key: string) { 25 | const deckyRes = await server!.callPluginMethod<{ key: string }, string>("store_read", { 26 | key, 27 | }); 28 | if (!deckyRes.success) { 29 | toast(`Error fetching ${key}`, deckyRes.result); 30 | return ""; 31 | } 32 | return deckyRes.result; 33 | } 34 | export async function stringStoreWrite(key: string, value: string) { 35 | const deckyRes = await server!.callPluginMethod<{ key: string; val: string }>("store_write", { 36 | key, 37 | val: value, 38 | }); 39 | if (!deckyRes.success) { 40 | toast(`Error setting ${key}`, deckyRes.result); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/pages/settings/Credits.tsx: -------------------------------------------------------------------------------- 1 | export function Credits() { 2 | return ( 3 |
4 |
5 |
6 |

Developers

7 |
    8 |
  • 9 | SuchMeme - github.com/suchmememanyskill 10 |
  • 11 |
  • 12 | Beebles - github.com/beebls 13 |
  • 14 |
  • 15 | EMERALD - github.com/EMERALD0874 16 |
  • 17 |
18 |
19 |
20 |

Support

21 | 22 | See the DeckThemes Discord server for support. 23 |
24 | discord.gg/HsU72Kfnpf 25 |
26 |
27 |
28 |

29 | Create and Submit Your Own Theme 30 |

31 | 32 | Instructions for theme creation/submission are available DeckThemes' docs website. 33 |
34 | docs.deckthemes.com 35 |
36 |
37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/components/Modals/DeleteConfirmationModal.tsx: -------------------------------------------------------------------------------- 1 | import { CssLoaderContextProvider } from "../../state"; 2 | import * as python from "../../python"; 3 | import { ConfirmModal, ModalRoot } from "decky-frontend-lib"; 4 | 5 | export function DeleteConfirmationModalRoot({ 6 | themesToBeDeleted, 7 | closeModal, 8 | leaveDeleteMode, 9 | }: { 10 | themesToBeDeleted: string[]; 11 | closeModal?: any; 12 | leaveDeleteMode?: () => void; 13 | }) { 14 | async function deleteThemes() { 15 | for (let i = 0; i < themesToBeDeleted.length; i++) { 16 | await python.deleteTheme(themesToBeDeleted[i]); 17 | } 18 | await python.getInstalledThemes(); 19 | leaveDeleteMode && leaveDeleteMode(); 20 | closeModal(); 21 | } 22 | 23 | return ( 24 | 30 | {/* @ts-ignore */} 31 | 32 | 33 | 34 | 35 | ); 36 | } 37 | 38 | function DeleteConfirmationModal({ themesToBeDeleted }: { themesToBeDeleted: string[] }) { 39 | return ( 40 |
41 | Are you sure you want to delete{" "} 42 | {themesToBeDeleted.length === 1 ? `this theme` : `these ${themesToBeDeleted.length} themes`}? 43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/logic/bulkThemeUpdateCheck.ts: -------------------------------------------------------------------------------- 1 | import { Theme, UpdateStatus } from "../ThemeTypes"; 2 | import { genericGET } from "../api"; 3 | import { MinimalCSSThemeInfo } from "../apiTypes"; 4 | import { globalState } from "../python"; 5 | const apiUrl = "https://api.deckthemes.com"; 6 | 7 | async function fetchThemeIDS(idsToQuery: string[]): Promise { 8 | const queryStr = "?ids=" + idsToQuery.join("."); 9 | return genericGET(`/themes/ids${queryStr}`) 10 | .then((data) => { 11 | if (data) return data; 12 | return []; 13 | }) 14 | .catch((err) => { 15 | console.error("Error Fetching Theme Updates!", err); 16 | return []; 17 | }); 18 | } 19 | 20 | export async function bulkThemeUpdateCheck(customThemeArr?: Theme[]) { 21 | const localThemeList = customThemeArr || globalState!.getPublicState().localThemeList; 22 | let idsToQuery: string[] = localThemeList.map((e) => e.id); 23 | 24 | if (idsToQuery.length === 0) return []; 25 | 26 | const themeArr = await fetchThemeIDS(idsToQuery); 27 | 28 | if (themeArr.length === 0) return []; 29 | 30 | const updateStatusArr: UpdateStatus[] = localThemeList.map((localEntry) => { 31 | const remoteEntry = themeArr.find( 32 | (remote) => remote.id === localEntry.id || remote.name === localEntry.id 33 | ); 34 | if (!remoteEntry) return [localEntry.id, "local", false]; 35 | if (remoteEntry.version === localEntry.version) 36 | return [localEntry.id, "installed", remoteEntry]; 37 | return [localEntry.id, "outdated", remoteEntry]; 38 | }); 39 | 40 | return updateStatusArr; 41 | } 42 | -------------------------------------------------------------------------------- /src/deckyPatches/ClassHashMap.tsx: -------------------------------------------------------------------------------- 1 | import { classMap } from "decky-frontend-lib"; 2 | 3 | export var classHashMap = new Map(); 4 | 5 | export function initializeClassHashMap() { 6 | const withoutLocalizationClasses = classMap.filter((module) => Object.keys(module).length < 1000); 7 | 8 | const allClasses = withoutLocalizationClasses 9 | .map((module) => { 10 | let filteredModule = {}; 11 | Object.entries(module).forEach(([propertyName, value]) => { 12 | // Filter out things that start with a number (eg: Breakpoints like 800px) 13 | // I have confirmed the new classes don't start with numbers 14 | if (isNaN(Number(value.charAt(0)))) { 15 | filteredModule[propertyName] = value; 16 | } 17 | }); 18 | return filteredModule; 19 | }) 20 | .filter((module) => { 21 | // Some modules will be empty after the filtering, remove those 22 | return Object.keys(module).length > 0; 23 | }); 24 | 25 | const mappings = allClasses.reduce((acc, cur) => { 26 | Object.entries(cur).forEach(([property, value]) => { 27 | if (acc[property]) { 28 | acc[property].push(value); 29 | } else { 30 | acc[property] = [value]; 31 | } 32 | }); 33 | return acc; 34 | }, {}); 35 | 36 | const hashMapNoDupes = Object.entries(mappings).reduce>( 37 | (acc, entry) => { 38 | if (entry[1].length === 1) { 39 | acc.set(entry[1][0], entry[0]); 40 | } 41 | return acc; 42 | }, 43 | new Map() 44 | ); 45 | 46 | classHashMap = hashMapNoDupes; 47 | } 48 | -------------------------------------------------------------------------------- /src/styles/themeSettingsModalStyles.css: -------------------------------------------------------------------------------- 1 | .CSSLoader_ThemeSettingsModal_ToggleParent { 2 | width: 90%; 3 | } 4 | 5 | .CSSLoader_ThemeSettingsModal_Title { 6 | font-weight: bold; 7 | font-size: 2em; 8 | } 9 | 10 | .CSSLoader_ThemeSettingsModal_Subtitle { 11 | font-size: 0.75em; 12 | } 13 | 14 | .CSSLoader_ThemeSettingsModal_Container { 15 | display: flex; 16 | flex-direction: column; 17 | align-items: center; 18 | gap: 1em; 19 | width: 100%; 20 | } 21 | 22 | .CSSLoader_ThemeSettingsModal_ButtonsContainer { 23 | display: flex; 24 | gap: 0.25em; 25 | } 26 | 27 | .CSSLoader_ThemeSettingsModalHeader_DialogButton { 28 | width: fit-content !important; 29 | min-width: fit-content !important; 30 | height: fit-content !important; 31 | padding: 10px 12px !important; 32 | } 33 | 34 | .CSSLoader_ThemeSettingsModal_IconTranslate { 35 | transform: translate(0px, 2px); 36 | } 37 | 38 | .CSSLoader_ThemeSettingsModal_Footer { 39 | display: flex; 40 | width: 100%; 41 | justify-content: space-between; 42 | } 43 | 44 | .CSSLoader_ThemeSettingsModal_TitleContainer { 45 | display: flex; 46 | max-width: 80%; 47 | flex-direction: column; 48 | } 49 | 50 | .CSSLoader_ThemeSettingsModal_Header { 51 | display: flex; 52 | align-items: center; 53 | justify-content: space-between; 54 | width: 100%; 55 | } 56 | 57 | .CSSLoader_ThemeSettingsModal_PatchContainer { 58 | display: flex; 59 | width: 100%; 60 | flex-direction: column; 61 | } 62 | 63 | .CSSLoader_ThemeSettingsModal_UpdateButton { 64 | display: flex !important; 65 | gap: 0.25em; 66 | } 67 | 68 | .CSSLoader_ThemeSettingsModal_UpdateText { 69 | font-size: 0.75em; 70 | } 71 | -------------------------------------------------------------------------------- /src/components/ThemeSettings/DeleteMenu.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DialogButton, 3 | DialogCheckbox, 4 | Focusable, 5 | PanelSection, 6 | showModal, 7 | } from "decky-frontend-lib"; 8 | import { Theme } from "../../ThemeTypes"; 9 | import { useState } from "react"; 10 | import { DeleteConfirmationModalRoot } from "../Modals/DeleteConfirmationModal"; 11 | 12 | export function DeleteMenu({ 13 | themeList, 14 | leaveDeleteMode, 15 | }: { 16 | themeList: Theme[]; 17 | leaveDeleteMode: () => void; 18 | }) { 19 | let [choppingBlock, setChoppingBlock] = useState([]); // name arr 20 | return ( 21 | 22 | 23 | {themeList.map((e) => ( 24 |
25 | { 27 | if (checked) { 28 | setChoppingBlock([...choppingBlock, e.name]); 29 | } else { 30 | setChoppingBlock(choppingBlock.filter((f) => f !== e.name)); 31 | } 32 | }} 33 | checked={choppingBlock.includes(e.name)} 34 | label={e.name} 35 | /> 36 |
37 | ))} 38 | { 41 | showModal( 42 | 46 | ); 47 | }} 48 | > 49 | Delete 50 | 51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/components/SupporterIcon.tsx: -------------------------------------------------------------------------------- 1 | import { RiMedalFill } from "react-icons/ri"; 2 | import { UserInfo } from "../apiTypes/CSSThemeTypes"; 3 | 4 | export function SupporterIcon({ author }: { author: UserInfo }) { 5 | const randId = Math.trunc(Math.random() * 69420); 6 | return ( 7 | <> 8 | {author?.premiumTier && author?.premiumTier !== "None" && ( 9 |
10 | 11 | 12 | 22 | 32 | 33 | 34 | 42 | {`Tier ${author?.premiumTier?.slice(-1)} Patreon Supporter`} 43 |
44 | )} 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/components/OptionalDepsModal.tsx: -------------------------------------------------------------------------------- 1 | import { ModalRoot } from "decky-frontend-lib"; 2 | import { DepsOptionSelector } from "./DepsOptionSelector"; 3 | import { Theme } from "../ThemeTypes"; 4 | export function OptionalDepsModalRoot({ 5 | themeData, 6 | closeModal, 7 | }: { 8 | themeData: Theme; 9 | closeModal?: any; 10 | }) { 11 | return ( 12 | 13 | 14 | 15 | ); 16 | } 17 | 18 | export function OptionalDepsModal({ 19 | themeData, 20 | closeModal, 21 | }: { 22 | themeData: Theme; 23 | closeModal: any; 24 | }) { 25 | return ( 26 | <> 27 |

36 | Enable dependencies for {themeData.name}? 37 |

38 | 39 | {themeData.name} enables optional themes to enhance this theme. Disabling these may break 40 | the theme, or make the theme look completely different. Specific optional themes can be 41 | configured and or enabled/disabled anytime via the Quick Access Menu. 42 | 43 | 44 | Enable without configuration will enable optional themes but not overwrite their 45 | configuration, and Enable only this theme will not enable any optional themes. 46 | 47 | 48 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SDH-CssLoader", 3 | "version": "2.1.2", 4 | "description": "A css loader", 5 | "scripts": { 6 | "build": "shx rm -rf dist && rollup -c", 7 | "watch": "rollup -c -w", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/suchmememanyskill/SDH-CssLoader.git" 13 | }, 14 | "keywords": [ 15 | "decky", 16 | "plugin", 17 | "plugin-template", 18 | "steam-deck", 19 | "deck" 20 | ], 21 | "author": "Jonas Dellinger ", 22 | "license": "GPL-2.0-or-later", 23 | "bugs": { 24 | "url": "https://github.com/SteamDeckHomebrew/decky-plugin-template/issues" 25 | }, 26 | "homepage": "https://github.com/SteamDeckHomebrew/decky-plugin-template#readme", 27 | "devDependencies": { 28 | "@rollup/plugin-commonjs": "^21.1.0", 29 | "@rollup/plugin-json": "^4.1.0", 30 | "@rollup/plugin-node-resolve": "^13.2.1", 31 | "@rollup/plugin-replace": "^4.0.0", 32 | "@rollup/plugin-typescript": "^8.3.2", 33 | "@types/color": "^3.0.3", 34 | "@types/lodash": "^4.14.191", 35 | "@types/react": "16.14.0", 36 | "@types/webpack": "^5.28.0", 37 | "rollup": "^2.70.2", 38 | "rollup-plugin-import-assets": "^1.1.1", 39 | "rollup-plugin-styles": "^4.0.0", 40 | "shx": "^0.3.4", 41 | "tslib": "^2.4.0", 42 | "typescript": "^4.6.4" 43 | }, 44 | "dependencies": { 45 | "color": "^4.2.3", 46 | "decky-frontend-lib": "^3.25.0", 47 | "lodash": "^4.17.21", 48 | "react-icons": "^4.12.0" 49 | }, 50 | "pnpm": { 51 | "peerDependencyRules": { 52 | "ignoreMissing": [ 53 | "react", 54 | "react-dom" 55 | ] 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/backend/pythonMethods/pluginSettingsMethods.ts: -------------------------------------------------------------------------------- 1 | import { storeRead, toast } from "../../python"; 2 | import { server, globalState } from "../pythonRoot"; 3 | import { booleanStoreRead, stringStoreRead } from "./storeUtils"; 4 | 5 | export function enableServer() { 6 | return server!.callPluginMethod("enable_server", {}); 7 | } 8 | 9 | export async function getServerState() { 10 | const deckyRes = await server!.callPluginMethod<{}, boolean>("get_server_state", {}); 11 | if (!deckyRes.success) { 12 | toast("Error fetching server state", deckyRes.result); 13 | return false; 14 | } 15 | return deckyRes.result; 16 | } 17 | 18 | export async function getWatchState() { 19 | const deckyRes = await server!.callPluginMethod<{}, boolean>("get_watch_state", {}); 20 | if (!deckyRes.success) { 21 | toast("Error fetching watch state", deckyRes.result); 22 | return false; 23 | } 24 | return deckyRes.result; 25 | } 26 | 27 | export async function getBetaTranslationsState() { 28 | return stringStoreRead("beta_translations"); 29 | } 30 | 31 | export function toggleWatchState(bool: boolean, onlyThisSession: boolean = false) { 32 | return server!.callPluginMethod<{ enable: boolean; only_this_session: boolean }, void>( 33 | "toggle_watch_state", 34 | { 35 | enable: bool, 36 | only_this_session: onlyThisSession, 37 | } 38 | ); 39 | } 40 | 41 | // Todo: when i rewrite store interop, move this 42 | export function setHiddenMotd(id: string) { 43 | return server!.callPluginMethod<{ key: string; val: string }>("store_write", { 44 | key: "hiddenMotd", 45 | val: id, 46 | }); 47 | } 48 | export function getHiddenMotd() { 49 | return server!.callPluginMethod<{ key: string }, string>("store_read", { 50 | key: "hiddenMotd", 51 | }); 52 | } 53 | 54 | export function fetchClassMappings() { 55 | return server!.callPluginMethod<{}>("fetch_class_mappings", {}); 56 | } 57 | -------------------------------------------------------------------------------- /src/deckyPatches/NavPatch.tsx: -------------------------------------------------------------------------------- 1 | import { replacePatch } from "decky-frontend-lib"; 2 | import { NavController } from "./NavControllerFinder"; 3 | import { globalState, toast, storeWrite } from "../python"; 4 | 5 | export function enableNavPatch(shouldToast: boolean = false) { 6 | const setGlobalState = globalState!.setGlobalState.bind(globalState); 7 | const { navPatchInstance } = globalState!.getPublicState(); 8 | // Don't patch twice 9 | if (navPatchInstance) return; 10 | const patch = replacePatch( 11 | NavController.prototype, 12 | "FindNextFocusableChildInDirection", 13 | function (args) { 14 | const e = args[0]; 15 | const t = args[1]; 16 | const r = args[2]; 17 | let n = t == 1 ? 1 : -1; 18 | // @ts-ignore 19 | for (let t = e + n; t >= 0 && t < this.m_rgChildren.length; t += n) { 20 | // @ts-ignore 21 | const e = this.m_rgChildren[t].FindFocusableNode(r); 22 | if (e && window.getComputedStyle(e.m_element).display !== "none") return e; 23 | } 24 | return null; 25 | } 26 | ); 27 | setGlobalState("navPatchInstance", patch); 28 | shouldToast && toast("CSS Loader", "Nav Patch Enabled"); 29 | return; 30 | } 31 | 32 | export function disableNavPatch(shouldToast: boolean = false) { 33 | const setGlobalState = globalState!.setGlobalState.bind(globalState); 34 | const { navPatchInstance } = globalState!.getPublicState(); 35 | // Don't unpatch something that doesn't exist 36 | // Probably the closest thing JS can get to null dereference 37 | if (!navPatchInstance) return; 38 | navPatchInstance.unpatch(); 39 | setGlobalState("navPatchInstance", undefined); 40 | shouldToast && toast("CSS Loader", "Nav Patch Disabled"); 41 | return; 42 | } 43 | 44 | export function setNavPatch(value: boolean, shouldToast: boolean = false) { 45 | value ? enableNavPatch(shouldToast) : disableNavPatch(shouldToast); 46 | storeWrite("enableNavPatch", value + ""); 47 | } 48 | -------------------------------------------------------------------------------- /src/pages/settings/PresetSettings.tsx: -------------------------------------------------------------------------------- 1 | import { Focusable, PanelSection } from "decky-frontend-lib"; 2 | import { useCssLoaderState } from "../../state"; 3 | import { Flags, Theme } from "../../ThemeTypes"; 4 | import { useState } from "react"; 5 | import { PresetSelectionDropdown } from "../../components"; 6 | import { FullscreenProfileEntry } from "../../components/ThemeSettings/FullscreenProfileEntry"; 7 | import { installTheme } from "../../api"; 8 | import * as python from "../../python"; 9 | 10 | export function PresetSettings() { 11 | const { localThemeList, setGlobalState, updateStatuses } = useCssLoaderState(); 12 | 13 | const [isInstalling, setInstalling] = useState(false); 14 | 15 | async function handleUpdate(e: Theme) { 16 | setInstalling(true); 17 | await installTheme(e.id); 18 | // This just updates the updateStatuses arr to know that this theme now is up to date, no need to re-fetch the API to know that 19 | setGlobalState( 20 | "updateStatuses", 21 | updateStatuses.map((f) => (f[0] === e.id ? [e.id, "installed", false] : e)) 22 | ); 23 | setInstalling(false); 24 | } 25 | 26 | async function handleUninstall(listEntry: Theme) { 27 | setInstalling(true); 28 | await python.deleteTheme(listEntry.name); 29 | await python.reloadBackend(); 30 | setInstalling(false); 31 | } 32 | 33 | return ( 34 |
35 | 36 | 37 | 40 | {localThemeList 41 | .filter((e) => e.flags.includes(Flags.isPreset)) 42 | .map((e) => ( 43 | 47 | ))} 48 | 49 | 50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/pages/theme-manager/ThemeManagerRouter.tsx: -------------------------------------------------------------------------------- 1 | import { Tabs } from "decky-frontend-lib"; 2 | import { Permissions } from "../../apiTypes"; 3 | import { useCssLoaderState } from "../../state"; 4 | import { LogInPage } from "./LogInPage"; 5 | import { StarredThemesPage } from "./StarredThemesPage"; 6 | import { SubmissionsPage } from "./SubmissionBrowserPage"; 7 | import { ThemeBrowserPage } from "./ThemeBrowserPage"; 8 | import { ThemeBrowserCardStyles } from "../../components/Styles"; 9 | export function ThemeManagerRouter() { 10 | const { apiMeData, currentTab, setGlobalState, browserCardSize } = useCssLoaderState(); 11 | return ( 12 |
19 | 20 | { 23 | setGlobalState("currentTab", tabID); 24 | }} 25 | tabs={[ 26 | { 27 | title: "All Themes", 28 | content: , 29 | id: "ThemeBrowser", 30 | }, 31 | ...(!!apiMeData 32 | ? [ 33 | { 34 | title: "Starred Themes", 35 | content: , 36 | id: "StarredThemes", 37 | }, 38 | ...(apiMeData.permissions.includes(Permissions.viewSubs) 39 | ? [ 40 | { 41 | title: "Submissions", 42 | content: , 43 | id: "SubmissionsPage", 44 | }, 45 | ] 46 | : []), 47 | ] 48 | : []), 49 | { 50 | title: "DeckThemes Account", 51 | content: , 52 | id: "LogInPage", 53 | }, 54 | ]} 55 | /> 56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/components/Modals/CreatePresetModal.tsx: -------------------------------------------------------------------------------- 1 | import { ConfirmModal, TextField } from "decky-frontend-lib"; 2 | import { useState } from "react"; 3 | import * as python from "../../python"; 4 | import { CssLoaderContextProvider, useCssLoaderState } from "../../state"; 5 | 6 | export function CreatePresetModalRoot({ closeModal }: { closeModal: any }) { 7 | return ( 8 | <> 9 | {/* @ts-ignore */} 10 | 11 | 12 | 13 | 14 | ); 15 | } 16 | 17 | function CreatePresetModal({ closeModal }: { closeModal: () => void }) { 18 | const { localThemeList, selectedPreset } = useCssLoaderState(); 19 | const [presetName, setPresetName] = useState(""); 20 | const enabledNumber = localThemeList.filter((e) => e.enabled).length; 21 | 22 | return ( 23 | { 29 | if (presetName.length === 0) { 30 | python.toast("No Name!", "Please add a name to your profile."); 31 | return; 32 | } 33 | // TODO: Potentially dont need 2 reloads here, not entirely sure 34 | await python.generatePreset(presetName); 35 | await python.reloadBackend(); 36 | if (selectedPreset) { 37 | await python.setThemeState(selectedPreset?.name, false); 38 | } 39 | await python.setThemeState(presetName + ".profile", true); 40 | await python.getInstalledThemes(); 41 | closeModal(); 42 | }} 43 | > 44 |
45 | { 49 | setPresetName(e.target.value); 50 | }} 51 | /> 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/components/TitleView.tsx: -------------------------------------------------------------------------------- 1 | import { DialogButton, Navigation, staticClasses, Focusable } from "decky-frontend-lib"; 2 | import { BsGearFill } from "react-icons/bs"; 3 | import { FaDownload } from "react-icons/fa"; 4 | import { useCssLoaderState } from "../state"; 5 | 6 | export function TitleView({ onDocsClick }: { onDocsClick?: () => {} }) { 7 | const { localThemeList } = useCssLoaderState(); 8 | 9 | const onSettingsClick = () => { 10 | Navigation.CloseSideMenus(); 11 | Navigation.Navigate("/cssloader/settings"); 12 | }; 13 | 14 | const onStoreClick = () => { 15 | Navigation.CloseSideMenus(); 16 | Navigation.Navigate("/cssloader/theme-manager"); 17 | }; 18 | 19 | return ( 20 | 31 | 46 |
CSS Loader
47 | 57 | 58 | 59 | 63 | 64 | 65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/components/ThemeManager/LoadMoreButton.tsx: -------------------------------------------------------------------------------- 1 | import { DialogButton } from "decky-frontend-lib"; 2 | import { useState, Dispatch, SetStateAction, useEffect } from "react"; 3 | import { ThemeQueryRequest, ThemeQueryResponse } from "../../apiTypes"; 4 | import { generateParamStr } from "../../logic"; 5 | import { genericGET } from "../../api"; 6 | import { useCssLoaderState } from "../../state"; 7 | 8 | export function LoadMoreButton({ 9 | fetchPath = "/themes", 10 | origSearchOpts, 11 | themeArr, 12 | themeArrVarName, 13 | paramStrFilterPrepend = "", 14 | setSnapIndex = undefined, 15 | }: { 16 | fetchPath: string; 17 | origSearchOpts: ThemeQueryRequest; 18 | themeArrVarName: string; 19 | themeArr: ThemeQueryResponse; 20 | paramStrFilterPrepend: string; 21 | setSnapIndex?: Dispatch>; 22 | }) { 23 | const { setGlobalState } = useCssLoaderState(); 24 | const [loadMoreCurPage, setLoadMorePage] = useState(1); 25 | const [loading, setLoading] = useState(false); 26 | 27 | function loadMore() { 28 | setLoading(true); 29 | // This just changes "All" to "", as that is what the backend looks for 30 | let searchOptClone = { ...origSearchOpts }; 31 | searchOptClone.page = loadMoreCurPage + 1; 32 | const searchOpts = generateParamStr( 33 | searchOptClone.filters !== "All" ? searchOptClone : { ...searchOptClone, filters: "" }, 34 | paramStrFilterPrepend 35 | ); 36 | genericGET(`${fetchPath}${searchOpts}`).then((data) => { 37 | if (data) { 38 | setGlobalState(themeArrVarName, { 39 | total: themeArr.total, 40 | items: [...themeArr.items, ...data.items], 41 | }); 42 | if (setSnapIndex) { 43 | setSnapIndex(origSearchOpts.perPage * loadMoreCurPage - 1); 44 | } 45 | setLoadMorePage((curPage) => curPage + 1); 46 | } 47 | setLoading(false); 48 | }); 49 | } 50 | 51 | useEffect(() => { 52 | setLoadMorePage(1); 53 | }, [origSearchOpts]); 54 | return ( 55 | <> 56 | {themeArr.items.length < themeArr.total ? ( 57 | <> 58 | 59 | Load More 60 | 61 | 62 | ) : null} 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Builder 2 | 3 | on: ["push", "pull_request", "workflow_dispatch"] 4 | 5 | jobs: 6 | build: 7 | name: Build SDH-CSSLoader for Decky 8 | runs-on: ubuntu-20.04 9 | 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | 14 | - name: Set up NodeJS 18 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: 18 18 | 19 | - name: Install JS dependencies 20 | run: | 21 | npm i -g pnpm 22 | pnpm install 23 | 24 | - name: Build Frontend 25 | run: | 26 | pnpm run build 27 | 28 | - name: Package Release 29 | run: | 30 | mkdir "SDH-CssLoader" 31 | cp *.py "./SDH-CssLoader" 32 | cp *.json "./SDH-CssLoader" 33 | cp LICENSE "./SDH-CssLoader" 34 | cp README.md "./SDH-CssLoader" 35 | cp -r dist "./SDH-CssLoader" 36 | mkdir upload 37 | mv "./SDH-CssLoader" ./upload 38 | 39 | - name: Upload package artifact 40 | uses: actions/upload-artifact@v3 41 | with: 42 | name: SDH-CSSLoader-Decky 43 | path: ./upload 44 | 45 | build-standalone-win: 46 | name: Build SDH-CSSLoader Standalone for Windows 47 | runs-on: windows-2022 48 | 49 | steps: 50 | - name: Checkout 51 | uses: actions/checkout@v3 52 | 53 | - name: Set up Python 3.10.2 54 | uses: actions/setup-python@v4 55 | with: 56 | python-version: "3.10.2" 57 | 58 | - name: Install Python dependencies 59 | run: | 60 | python -m pip install --upgrade pip 61 | pip install pyinstaller==5.5 62 | pip install -r requirements.txt 63 | 64 | - name: Build Python Backend 65 | run: pyinstaller --noconfirm --onefile --add-data "./assets;/assets" --name "CssLoader-Standalone" ./main.py ./css_win_tray.py 66 | 67 | - name: Build Python Backend Headless 68 | run: pyinstaller --noconfirm --noconsole --onefile --add-data "./assets;/assets" --name "CssLoader-Standalone-Headless" ./main.py ./css_win_tray.py 69 | 70 | - name: Upload package artifact 71 | uses: actions/upload-artifact@v3 72 | with: 73 | name: SDH-CSSLoader-Win-Standalone 74 | path: | 75 | ./dist/CssLoader-Standalone.exe 76 | ./dist/CssLoader-Standalone-Headless.exe 77 | -------------------------------------------------------------------------------- /src/components/QAMTab/QAMThemeToggleList.tsx: -------------------------------------------------------------------------------- 1 | import { Focusable } from "decky-frontend-lib"; 2 | import { useCssLoaderState } from "../../state"; 3 | import { ThemeToggle } from "../ThemeToggle"; 4 | import { Flags } from "../../ThemeTypes"; 5 | import { ThemeErrorCard } from "../ThemeErrorCard"; 6 | import { BsArrowDown } from "react-icons/bs"; 7 | import { FaEyeSlash } from "react-icons/fa"; 8 | 9 | export function QAMThemeToggleList() { 10 | const { localThemeList, unpinnedThemes } = useCssLoaderState(); 11 | 12 | if (localThemeList.length === 0) { 13 | return ( 14 | <> 15 | You have no themes installed. Get started by selecting the download icon above! 16 | 17 | ); 18 | } 19 | 20 | return ( 21 | <> 22 | {/* This styles the collapse buttons, putting it here just means it only needs to be rendered once instead of like 20 times */} 23 | 41 | 42 | <> 43 | {localThemeList 44 | .filter((e) => !unpinnedThemes.includes(e.id) && !e.flags.includes(Flags.isPreset)) 45 | .map((x) => ( 46 | 47 | ))} 48 | 49 | 50 | {unpinnedThemes.length > 0 && ( 51 |
60 | 61 |
62 | {unpinnedThemes.length} theme{unpinnedThemes.length > 1 ? "s are" : "is"} hidden. 63 |
64 |
65 | )} 66 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /src/deckyPatches/UnminifyMode.tsx: -------------------------------------------------------------------------------- 1 | import { classHashMap, initializeClassHashMap } from "./ClassHashMap"; 2 | import { getRootElements } from "./SteamTabElementsFinder"; 3 | 4 | export function unminifyElement(element: Element) { 5 | if (element.classList.length === 0) return; 6 | 7 | const classList = Array.from(element.classList); 8 | const unminifiedClassList = classList.map((c) => classHashMap.get(c) || c); 9 | element.setAttribute("unminified-class", unminifiedClassList.join(" ")); 10 | } 11 | 12 | export function recursivelyUnminifyElement(element: Element) { 13 | unminifyElement(element); 14 | Array.from(element.children).forEach(recursivelyUnminifyElement); 15 | } 16 | 17 | export function initialUnminification(rootElement: any) { 18 | const allElements = rootElement.ownerDocument.all as HTMLAllCollection; 19 | Array.from(allElements).forEach(unminifyElement); 20 | } 21 | 22 | var mutationObservers: MutationObserver[] = []; 23 | 24 | export function disconnectMutationObservers() { 25 | mutationObservers.forEach((observer) => observer.disconnect()); 26 | mutationObservers = []; 27 | } 28 | 29 | export function mutationObserverCallback(mutations: MutationRecord[]) { 30 | mutations.forEach((mutation) => { 31 | if (mutation.type === "childList" && mutation.addedNodes.length > 0) { 32 | mutation.addedNodes.forEach((node) => { 33 | recursivelyUnminifyElement(node as Element); 34 | }); 35 | } 36 | if (mutation.type === "attributes" && mutation.attributeName === "class") { 37 | unminifyElement(mutation.target as HTMLElement); 38 | } 39 | }); 40 | } 41 | 42 | export function setUpMutationObserver(rootElement: any) { 43 | const mutationObserver = new MutationObserver(mutationObserverCallback); 44 | mutationObserver.observe(rootElement.ownerDocument.documentElement, { 45 | attributes: true, 46 | attributeFilter: ["class"], 47 | childList: true, 48 | subtree: true, 49 | }); 50 | mutationObservers.push(mutationObserver); 51 | } 52 | 53 | export function enableUnminifyMode() { 54 | if (mutationObservers.length > 0) disconnectMutationObservers(); 55 | initializeClassHashMap(); 56 | const roots = getRootElements(); 57 | roots.forEach(initialUnminification); 58 | roots.forEach(setUpMutationObserver); 59 | } 60 | 61 | export function disableUnminifyMode() { 62 | disconnectMutationObservers(); 63 | } 64 | -------------------------------------------------------------------------------- /src/apiTypes/CSSThemeTypes.ts: -------------------------------------------------------------------------------- 1 | import { Url } from "url"; 2 | import { APIBlob } from "./BlobTypes"; 3 | 4 | export interface UserInfo { 5 | id: string; 6 | username: string; 7 | avatar: string; 8 | premiumTier: string; 9 | } 10 | 11 | export interface MinimalCSSThemeInfo { 12 | id: string; 13 | name: string; 14 | displayName: string; 15 | version: string; 16 | target: string; 17 | targets: string[]; 18 | manifestVersion: number; 19 | specifiedAuthor: string; 20 | type: "Css" | "Audio"; 21 | } 22 | 23 | export interface PartialCSSThemeInfo extends MinimalCSSThemeInfo { 24 | images: APIBlob[]; 25 | download: APIBlob; 26 | author: UserInfo; 27 | submitted: Date; 28 | updated: Date; 29 | starCount: number; 30 | } 31 | 32 | export interface FullCSSThemeInfo extends PartialCSSThemeInfo { 33 | dependencies: MinimalCSSThemeInfo[]; 34 | approved: boolean; 35 | disabled: boolean; 36 | description: string; 37 | source?: string; 38 | } 39 | 40 | export interface QueryResponseShell { 41 | total: number; 42 | } 43 | 44 | export interface ThemeQueryResponse extends QueryResponseShell { 45 | items: PartialCSSThemeInfo[]; 46 | } 47 | 48 | export interface ThemeQueryRequest { 49 | page: number; 50 | perPage: number; 51 | filters: string; 52 | order: string; 53 | search: string; 54 | } 55 | 56 | export interface ThemeSubmissionQueryResponse extends QueryResponseShell { 57 | items: ThemeSubmissionInfo[]; 58 | } 59 | 60 | export type SubmissionIntent = "NewTheme" | "UpdateTheme" | "UpdateMeta"; 61 | export enum FormattedSubmissionIntent { 62 | "NewTheme" = "New Theme", 63 | "UpdateTheme" = "Theme Update", 64 | "UpdateMeta" = "Theme Meta Update", 65 | } 66 | export type SubmissionStatus = "AwaitingApproval" | "Approved" | "Denied" | "Dead"; 67 | export enum FormattedSubmissionStatus { 68 | "AwaitingApproval" = "Awaiting Review", 69 | "Approved" = "Approved", 70 | "Denied" = "Denied", 71 | "Dead" = "Dead", 72 | } 73 | 74 | export interface ThemeSubmissionInfo { 75 | id: string; 76 | intent: SubmissionIntent; 77 | message: string | null; 78 | newTheme: FullCSSThemeInfo; 79 | oldTheme: MinimalCSSThemeInfo | null; 80 | owner: UserInfo; 81 | reviewedBy: UserInfo | null; 82 | status: SubmissionStatus; 83 | errors?: string[]; 84 | submitted: Date; 85 | } 86 | 87 | export interface FilterQueryResponse { 88 | filters: string[]; 89 | order: string[]; 90 | } 91 | -------------------------------------------------------------------------------- /css_win_tray.py: -------------------------------------------------------------------------------- 1 | import pystray, css_theme, css_utils, os, webbrowser, subprocess 2 | from PIL import Image, ImageDraw 3 | 4 | ICON = None 5 | MAIN = None 6 | LOOP = None 7 | DEV_MODE_STATE = False 8 | 9 | def reset(): 10 | LOOP.create_task(MAIN.reset(MAIN)) 11 | 12 | def open_theme_dir(): 13 | theme_dir = css_utils.get_theme_path() 14 | os.startfile(theme_dir) 15 | 16 | def exit(): 17 | LOOP.create_task(MAIN.exit(MAIN)) 18 | 19 | def get_dev_mode_state(x) -> bool: 20 | return DEV_MODE_STATE 21 | 22 | def toggle_dev_mode_state(): 23 | global DEV_MODE_STATE 24 | DEV_MODE_STATE = not DEV_MODE_STATE 25 | LOOP.create_task(MAIN.toggle_watch_state(MAIN, get_dev_mode_state(None))) 26 | 27 | def check_if_symlink_exists(): 28 | return os.path.exists(os.path.join(css_utils.get_steam_path(), "steamui", "themes_custom")) 29 | 30 | def open_install_docs(): 31 | webbrowser.open_new_tab("https://docs.deckthemes.com/CSSLoader/Install/#windows") 32 | 33 | def get_desktop_install_path() -> str|None: 34 | if os.path.exists("C:/Program Files/CSSLoader Desktop/CSSLoader Desktop.exe"): 35 | return "C:/Program Files/CSSLoader Desktop/CSSLoader Desktop.exe" 36 | 37 | return None 38 | 39 | def open_desktop(): 40 | path = get_desktop_install_path() 41 | if path != None: 42 | subprocess.Popen([path]) 43 | 44 | def start_icon(main, loop): 45 | global ICON, MAIN, LOOP, DEV_MODE_STATE 46 | MAIN = main 47 | LOOP = loop 48 | DEV_MODE_STATE = MAIN.observer != None 49 | symlink = check_if_symlink_exists() 50 | 51 | ICON = pystray.Icon( 52 | 'CSS Loader', 53 | title='CSS Loader', 54 | icon=Image.open(os.path.join(os.path.dirname(__file__), "assets", "paint-roller-solid.png")), 55 | menu=pystray.Menu( 56 | pystray.MenuItem(f"CSS Loader v{css_theme.CSS_LOADER_VER}", action=None, enabled=False), 57 | pystray.MenuItem("Local Images/Fonts: Enabled" if symlink else "Local Images/Fonts: Disabled", action=None, enabled=None), 58 | pystray.MenuItem("Please enable Windows Developer Mode", action=open_install_docs, visible=not symlink), 59 | pystray.MenuItem("Open Desktop App", action=open_desktop, enabled=get_desktop_install_path() != None, default=True), 60 | pystray.MenuItem("Live CSS Editing", toggle_dev_mode_state, checked=get_dev_mode_state), 61 | pystray.MenuItem("Open Themes Folder", open_theme_dir), 62 | pystray.MenuItem("Reload Themes", reset), 63 | pystray.MenuItem("Exit", exit) 64 | )) 65 | ICON.run_detached() 66 | 67 | def stop_icon(): 68 | if ICON != None: 69 | ICON.stop() -------------------------------------------------------------------------------- /src/components/QAMTab/PresetSelectionDropdown.tsx: -------------------------------------------------------------------------------- 1 | import { DropdownItem, PanelSectionRow, showModal } from "decky-frontend-lib"; 2 | import { useCssLoaderState } from "../../state"; 3 | import { Flags } from "../../ThemeTypes"; 4 | import { useMemo } from "react"; 5 | import { changePreset, getInstalledThemes } from "../../python"; 6 | import { CreatePresetModalRoot } from "../Modals/CreatePresetModal"; 7 | import { FiPlusCircle } from "react-icons/fi"; 8 | import { useRerender } from "../../hooks"; 9 | 10 | export function PresetSelectionDropdown() { 11 | const { localThemeList, selectedPreset } = useCssLoaderState(); 12 | const presets = useMemo( 13 | () => localThemeList.filter((e) => e.flags.includes(Flags.isPreset)), 14 | [localThemeList] 15 | ); 16 | const [render, rerender] = useRerender(); 17 | return ( 18 | <> 19 | {render && ( 20 | 21 | e.enabled && e.flags.includes(Flags.isPreset)).length > 1 25 | ? "Invalid State" 26 | : selectedPreset?.name || "None" 27 | } 28 | rgOptions={[ 29 | ...(localThemeList.filter((e) => e.enabled && e.flags.includes(Flags.isPreset)) 30 | .length > 1 31 | ? [{ data: "Invalid State", label: "Invalid State" }] 32 | : []), 33 | { data: "None", label: "None" }, 34 | ...presets.map((e) => ({ label: e.display_name, data: e.name })), 35 | // This is a jank way of only adding it if creatingNewProfile = false 36 | { 37 | data: "New Profile", 38 | label: ( 39 |
47 | 48 | New Profile 49 |
50 | ), 51 | }, 52 | ]} 53 | onChange={async ({ data }) => { 54 | if (data === "New Profile") { 55 | showModal( 56 | // @ts-ignore 57 | 58 | ); 59 | rerender(); 60 | return; 61 | } 62 | await changePreset(data, localThemeList); 63 | getInstalledThemes(); 64 | }} 65 | /> 66 |
67 | )} 68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /src/components/ThemeSettings/FullscreenProfileEntry.tsx: -------------------------------------------------------------------------------- 1 | import { DialogButton, Focusable, PanelSectionRow } from "decky-frontend-lib"; 2 | import { Flags, LocalThemeStatus, Theme } from "../../ThemeTypes"; 3 | import { useCssLoaderState } from "../../state"; 4 | import { AiOutlineDownload } from "react-icons/ai"; 5 | import { FaTrash } from "react-icons/fa"; 6 | import { installTheme } from "../../api"; 7 | 8 | export function FullscreenProfileEntry({ 9 | data: e, 10 | handleUninstall, 11 | isInstalling, 12 | handleUpdate, 13 | }: { 14 | data: Theme; 15 | handleUninstall: (e: Theme) => void; 16 | handleUpdate: (e: Theme) => void; 17 | isInstalling: boolean; 18 | }) { 19 | const { updateStatuses } = useCssLoaderState(); 20 | let [updateStatus]: [LocalThemeStatus] = ["installed"]; 21 | const themeArrPlace = updateStatuses.find((f) => f[0] === e.id); 22 | if (themeArrPlace) { 23 | updateStatus = themeArrPlace[1]; 24 | } 25 | return ( 26 | 27 |
33 | {e.display_name} 34 | 43 | {/* Update Button */} 44 | {updateStatus === "outdated" && ( 45 | handleUpdate(e)} 53 | disabled={isInstalling} 54 | > 55 | 56 | 57 | )} 58 | {/* This shows when a theme is local, but not a preset */} 59 | {updateStatus === "local" && !e.flags.includes(Flags.isPreset) && ( 60 | 68 | Local Theme 69 | 70 | )} 71 | handleUninstall(e)} 78 | disabled={isInstalling} 79 | > 80 | 81 | 82 | 83 |
84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /src/components/QAMTab/MOTDDisplay.tsx: -------------------------------------------------------------------------------- 1 | import { DialogButton, Focusable, PanelSection } from "decky-frontend-lib"; 2 | import { useEffect, useState, useMemo } from "react"; 3 | import { Motd } from "../../apiTypes/Motd"; 4 | import { genericGET } from "../../api"; 5 | import { FaTimes } from "react-icons/fa"; 6 | import { useCssLoaderState } from "../../state"; 7 | import { getHiddenMotd, setHiddenMotd } from "../../backend/pythonMethods/pluginSettingsMethods"; 8 | 9 | export function MOTDDisplay() { 10 | const [motd, setMotd] = useState(); 11 | const { hiddenMotd, setGlobalState } = useCssLoaderState(); 12 | useEffect(() => { 13 | async function getMotd() { 14 | const res = await genericGET("/motd", false, undefined, () => {}, true); 15 | setMotd(res); 16 | } 17 | getMotd(); 18 | }, []); 19 | async function dismiss() { 20 | if (motd) { 21 | await setHiddenMotd(motd.id); 22 | const res = await getHiddenMotd(); 23 | if (res.success) { 24 | setGlobalState("hiddenMotd", res.result); 25 | } 26 | } 27 | } 28 | 29 | const SEVERITIES = { 30 | High: { 31 | color: "#bb1414", 32 | text: "#fff", 33 | }, 34 | Medium: { 35 | color: "#bbbb14", 36 | text: "#fff", 37 | }, 38 | Low: { 39 | color: "#1488bb", 40 | text: "#fff", 41 | }, 42 | }; 43 | 44 | const hidden = useMemo(() => { 45 | return hiddenMotd === motd?.id; 46 | }, [hiddenMotd, motd]); 47 | 48 | const severity = SEVERITIES[motd?.severity || "Low"]; 49 | 50 | if (motd && motd?.name && !hidden) { 51 | return ( 52 | 53 | 67 |
68 | {motd?.name} 69 | 84 | 89 | 90 |
91 | {motd?.description} 92 |
93 |
94 | ); 95 | } 96 | return null; 97 | } 98 | -------------------------------------------------------------------------------- /src/backend/backendHelpers/toggleTheme.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction } from "react"; 2 | import { Flags, Theme } from "../../ThemeTypes"; 3 | import * as python from "../../python"; 4 | import { OptionalDepsModalRoot } from "../../components"; 5 | import { showModal } from "decky-frontend-lib"; 6 | import { enableNavPatch } from "../../deckyPatches/NavPatch"; 7 | import { NavPatchInfoModalRoot } from "../../deckyPatches/NavPatchInfoModal"; 8 | 9 | // rerender and setCollapsed only apply to the QAM list version of the ThemeToggle, not the one in the fullscreen 'Your Themes' modal 10 | export async function toggleTheme( 11 | data: Theme, 12 | enabled: boolean, 13 | rerender: () => void = () => {}, 14 | setCollapsed: Dispatch> = () => {} 15 | ) { 16 | const { selectedPreset, navPatchInstance } = python.globalState!.getPublicState(); 17 | 18 | // Optional Deps Themes 19 | if (enabled && data.flags.includes(Flags.optionalDeps)) { 20 | showModal(); 21 | rerender && rerender(); 22 | } else { 23 | // Actually enabling the theme 24 | await python.setThemeState(data.name, enabled); 25 | await python.getInstalledThemes(); 26 | } 27 | 28 | // Re-collapse menu 29 | setCollapsed && setCollapsed(true); 30 | 31 | // Dependency Toast 32 | if (data.dependencies.length > 0) { 33 | if (enabled) { 34 | python.toast( 35 | `${data.display_name} enabled other themes`, 36 | // This lists out the themes by name, but often overflowed off screen 37 | // @ts-ignore 38 | // `${new Intl.ListFormat().format(data.dependencies)} ${ 39 | // data.dependencies.length > 1 ? "are" : "is" 40 | // } required for this theme` 41 | // This just gives the number of themes 42 | `${ 43 | data.dependencies.length === 1 44 | ? `1 other theme is required by ${data.display_name}` 45 | : `${data.dependencies.length} other themes are required by ${data.display_name}` 46 | }` 47 | ); 48 | } 49 | if (!enabled && !data.flags.includes(Flags.dontDisableDeps)) { 50 | python.toast( 51 | `${data.display_name} disabled other themes`, 52 | `${ 53 | data.dependencies.length === 1 54 | ? `1 theme was originally enabled by ${data.display_name}` 55 | : `${data.dependencies.length} themes were originally enabled by ${data.display_name}` 56 | }` 57 | ); 58 | } 59 | } 60 | 61 | // Nav Patch 62 | if (enabled && data.flags.includes(Flags.navPatch) && !navPatchInstance) { 63 | showModal(); 64 | } 65 | 66 | // Preset Updating 67 | if (!selectedPreset) return; 68 | // Fetch this here so that the data is up to date 69 | const { localThemeList } = python.globalState!.getPublicState(); 70 | 71 | // This is copied from the desktop codebase 72 | await python.generatePresetFromThemeNames( 73 | selectedPreset.name, 74 | localThemeList.filter((e) => e.enabled && !e.flags.includes(Flags.isPreset)).map((e) => e.name) 75 | ); 76 | // Getting the new data for the preset 77 | await python.getInstalledThemes(); 78 | } 79 | -------------------------------------------------------------------------------- /src/pages/settings/SettingsPageRouter.tsx: -------------------------------------------------------------------------------- 1 | import { SidebarNavigation } from "decky-frontend-lib"; 2 | import { BsFolderFill, BsGearFill } from "react-icons/bs"; 3 | import { RiPaintFill, RiSettings2Fill } from "react-icons/ri"; 4 | import { ThemeSettings } from "./ThemeSettings"; 5 | import { PresetSettings } from "./PresetSettings"; 6 | import { PluginSettings } from "./PluginSettings"; 7 | import { Credits } from "./Credits"; 8 | import { AiFillGithub, AiFillHeart } from "react-icons/ai"; 9 | import { DonatePage } from "./DonatePage"; 10 | import { FaFolder, FaGithub, FaHeart } from "react-icons/fa"; 11 | 12 | export function SettingsPageRouter() { 13 | return ( 14 | <> 15 | 65 | , 70 | route: "/cssloader/settings/themes", 71 | content: , 72 | }, 73 | { 74 | title: "Profiles", 75 | icon: , 76 | route: "/cssloader/settings/profiles", 77 | 78 | content: , 79 | }, 80 | { 81 | title: "Settings", 82 | icon: , 83 | route: "/cssloader/settings/plugin", 84 | 85 | content: , 86 | }, 87 | { 88 | title: "Donate", 89 | icon: , 90 | route: "/cssloader/settings/donate", 91 | 92 | content: , 93 | }, 94 | { 95 | title: "Credits", 96 | icon: , 97 | route: "/cssloader/settings/credits", 98 | 99 | content: , 100 | }, 101 | ]} 102 | > 103 | 104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /src/pages/theme-manager/StarredThemesPage.tsx: -------------------------------------------------------------------------------- 1 | import { Focusable } from "decky-frontend-lib"; 2 | import { useCssLoaderState } from "../../state"; 3 | import * as python from "../../python"; 4 | import { BrowserSearchFields, LoadMoreButton, VariableSizeCard } from "../../components"; 5 | import { useEffect, useRef, useState } from "react"; 6 | import { isEqual } from "lodash"; 7 | import { getThemes } from "../../api"; 8 | 9 | export function StarredThemesPage() { 10 | const { 11 | apiFullToken, 12 | apiMeData, 13 | starredSearchOpts: searchOpts, 14 | starredServerFilters: serverFilters, 15 | starredThemeList: themeArr, 16 | browserCardSize, 17 | prevStarSearchOpts: prevSearchOpts, 18 | backendVersion, 19 | } = useCssLoaderState(); 20 | 21 | function reloadThemes() { 22 | getThemes(searchOpts, "/users/me/stars", "starredThemeList", setSnapIndex, true); 23 | python.reloadBackend(); 24 | } 25 | 26 | useEffect(() => { 27 | if (!isEqual(prevSearchOpts, searchOpts) || themeArr.total === 0) { 28 | getThemes(searchOpts, "/users/me/stars", "starredThemeList", setSnapIndex, true); 29 | } 30 | }, [searchOpts, prevSearchOpts, apiMeData]); 31 | 32 | const endOfPageRef = useRef(); 33 | const [indexToSnapTo, setSnapIndex] = useState(-1); 34 | useEffect(() => { 35 | if (endOfPageRef?.current) { 36 | endOfPageRef?.current?.focus(); 37 | } 38 | }, [indexToSnapTo]); 39 | 40 | if (!apiFullToken) { 41 | return ( 42 | <> 43 |
52 | You Are Not Logged In! 53 | Link your deck to your deckthemes.com account to sync Starred Themes 54 |
55 | 56 | ); 57 | } 58 | return ( 59 | <> 60 | 70 | 79 | {themeArr.items 80 | .filter((e) => e.manifestVersion <= backendVersion) 81 | .map((e, i) => ( 82 | 89 | ))} 90 | 91 |
100 |
101 | 109 |
110 |
111 | 112 | ); 113 | } 114 | -------------------------------------------------------------------------------- /src/pages/theme-manager/LogInPage.tsx: -------------------------------------------------------------------------------- 1 | import { DialogButton, Focusable, TextField, ToggleField } from "decky-frontend-lib"; 2 | import { SiWebauthn } from "react-icons/si"; 3 | import { useEffect, useMemo, useState, VFC } from "react"; 4 | import { logInWithShortToken, logOut } from "../../api"; 5 | import { useCssLoaderState } from "../../state"; 6 | import { enableServer, getServerState, storeWrite } from "../../python"; 7 | import { disableNavPatch, enableNavPatch } from "../../deckyPatches/NavPatch"; 8 | import { FaArrowRightToBracket } from "react-icons/fa6"; 9 | 10 | export const LogInPage: VFC = () => { 11 | const { apiShortToken, apiFullToken, apiMeData } = useCssLoaderState(); 12 | const [shortTokenInterimValue, setShortTokenIntValue] = useState(apiShortToken); 13 | 14 | return ( 15 | // The outermost div is to push the content down into the visible area 16 |
17 |
18 | {apiFullToken ? ( 19 |

Your Account

20 | ) : ( 21 |

22 | Log In 23 |

24 | )} 25 | {apiFullToken ? ( 26 | <> 27 | 28 |
29 | {apiMeData ? ( 30 | <> 31 | Logged In As {apiMeData.username} 32 | 33 | ) : ( 34 | Loading... 35 | )} 36 |
37 | 49 | Unlink My Deck 50 | 51 |
52 | 53 | ) : ( 54 | <> 55 | 56 |
57 | setShortTokenIntValue(e.target.value)} 63 | /> 64 |
65 | { 68 | logInWithShortToken(shortTokenInterimValue); 69 | }} 70 | style={{ 71 | maxWidth: "30%", 72 | height: "50%", 73 | marginLeft: "auto", 74 | display: "flex", 75 | alignItems: "center", 76 | justifyContent: "center", 77 | gap: "0.5em", 78 | }} 79 | > 80 | 81 | Log In 82 | 83 |
84 | 85 | )} 86 |

87 | Logging in gives you access to star themes, saving them to their own page where you can 88 | quickly find them. 89 |
90 | Create an account on deckthemes.com and generate an account key on your profile page. 91 |
92 |

93 |
94 |
95 | ); 96 | }; 97 | -------------------------------------------------------------------------------- /css_remoteinstall.py: -------------------------------------------------------------------------------- 1 | import asyncio, json, tempfile, os, aiohttp, zipfile, shutil 2 | from css_utils import Result, Log, get_theme_path, store_or_file_config 3 | from css_theme import CSS_LOADER_VER, Theme 4 | 5 | async def run(command : str) -> str: 6 | proc = await asyncio.create_subprocess_shell(command, 7 | stdout=asyncio.subprocess.PIPE, 8 | stderr=asyncio.subprocess.PIPE) 9 | stdout, stderr = await proc.communicate() 10 | 11 | if (proc.returncode != 0): 12 | raise Exception(f"Process exited with error code {proc.returncode}") 13 | 14 | return stdout.decode() 15 | 16 | async def install(id : str, base_url : str, local_themes : list) -> Result: 17 | if not base_url.endswith("/"): 18 | base_url = base_url + "/" 19 | 20 | url = f"{base_url}themes/{id}" 21 | 22 | async with aiohttp.ClientSession(headers={"User-Agent": f"SDH-CSSLoader/{CSS_LOADER_VER}"}, connector=aiohttp.TCPConnector(verify_ssl=False)) as session: 23 | try: 24 | async with session.get(url) as resp: 25 | if resp.status != 200: 26 | raise Exception(f"Invalid status code {resp.status}") 27 | 28 | data = await resp.json() 29 | except Exception as e: 30 | return Result(False, str(e)) 31 | 32 | if (data["manifestVersion"] > CSS_LOADER_VER): 33 | raise Exception("Manifest version of themedb entry is unsupported by this version of CSS_Loader") 34 | 35 | download_url = f"{base_url}blobs/{data['download']['id']}" 36 | tempDir = tempfile.TemporaryDirectory() 37 | 38 | Log(f"Downloading {download_url} to {tempDir.name}...") 39 | themeZipPath = os.path.join(tempDir.name, 'theme.zip') 40 | try: 41 | async with session.get(download_url) as resp: 42 | if resp.status != 200: 43 | raise Exception(f"Got {resp.status} code from '{download_url}'") 44 | 45 | with open(themeZipPath, "wb") as out: 46 | out.write(await resp.read()) 47 | 48 | except Exception as e: 49 | return Result(False, str(e)) 50 | 51 | Log(f"Unzipping {themeZipPath}") 52 | try: 53 | with zipfile.ZipFile(themeZipPath, 'r') as zip: 54 | zip.extractall(get_theme_path()) 55 | except Exception as e: 56 | return Result(False, str(e)) 57 | 58 | tempDir.cleanup() 59 | 60 | if not store_or_file_config("no_deps_install"): 61 | for x in data["dependencies"]: 62 | if x["name"] in local_themes: 63 | continue 64 | 65 | await install(x["id"], base_url, local_themes) 66 | 67 | return Result(True) 68 | 69 | async def upload(theme : Theme, base_url : str, bearer_token : str) -> Result: 70 | if not base_url.endswith("/"): 71 | base_url = base_url + "/" 72 | 73 | url = f"{base_url}blobs" 74 | 75 | with tempfile.TemporaryDirectory() as tmp: 76 | themePath = os.path.join(tmp, "theme.zip") 77 | print(themePath[:-4]) 78 | print(theme.themePath) 79 | shutil.make_archive(themePath[:-4], 'zip', theme.themePath) 80 | 81 | with open(themePath, "rb") as file: 82 | async with aiohttp.ClientSession(headers={"User-Agent": f"SDH-CSSLoader/{CSS_LOADER_VER}", "Authorization": f"Bearer {bearer_token}"}, connector=aiohttp.TCPConnector(verify_ssl=False)) as session: 83 | try: 84 | mp = aiohttp.FormData() 85 | mp.add_field("file", file) 86 | async with session.post(url, data=mp) as resp: 87 | if resp.status != 200: 88 | raise Exception(f"Invalid status code {resp.status}") 89 | 90 | data = await resp.json() 91 | return Result(True, data) 92 | except Exception as e: 93 | return Result(False, str(e)) -------------------------------------------------------------------------------- /src/pages/theme-manager/SubmissionBrowserPage.tsx: -------------------------------------------------------------------------------- 1 | import { Focusable } from "decky-frontend-lib"; 2 | import { useCssLoaderState } from "../../state"; 3 | import * as python from "../../python"; 4 | import { BrowserSearchFields, LoadMoreButton, VariableSizeCard } from "../../components"; 5 | import { useEffect, useRef, useState } from "react"; 6 | import { isEqual } from "lodash"; 7 | import { getThemes } from "../../api"; 8 | 9 | export function SubmissionsPage() { 10 | const { 11 | apiFullToken, 12 | submissionSearchOpts: searchOpts, 13 | submissionServerFilters: serverFilters, 14 | submissionThemeList: themeArr, 15 | browserCardSize, 16 | prevSubSearchOpts: prevSearchOpts, 17 | apiMeData, 18 | backendVersion, 19 | } = useCssLoaderState(); 20 | 21 | function reloadThemes() { 22 | getThemes(searchOpts, "/themes/awaiting_approval", "submissionThemeList", setSnapIndex, true); 23 | python.reloadBackend(); 24 | } 25 | 26 | useEffect(() => { 27 | if (!isEqual(prevSearchOpts, searchOpts) || themeArr.total === 0) { 28 | getThemes(searchOpts, "/themes/awaiting_approval", "submissionThemeList", setSnapIndex, true); 29 | } 30 | }, [searchOpts, prevSearchOpts, apiMeData]); 31 | 32 | const endOfPageRef = useRef(); 33 | const [indexToSnapTo, setSnapIndex] = useState(-1); 34 | useEffect(() => { 35 | if (endOfPageRef?.current) { 36 | endOfPageRef?.current?.focus(); 37 | } 38 | }, [indexToSnapTo]); 39 | 40 | if (!apiFullToken) { 41 | return ( 42 | <> 43 |
52 | You Are Not Logged In! 53 | Link your deck to your deckthemes.com account to sync Starred Themes 54 |
55 | 56 | ); 57 | } 58 | return ( 59 | <> 60 | 70 | 79 | {themeArr.items 80 | .filter((e) => e.manifestVersion <= backendVersion) 81 | .map((e, i) => ( 82 | 89 | ))} 90 | 91 |
100 |
101 | 109 |
110 |
111 | 112 | ); 113 | } 114 | -------------------------------------------------------------------------------- /src/components/ThemeSettings/FullscreenSingleThemeEntry.tsx: -------------------------------------------------------------------------------- 1 | import { DialogButton, Focusable, ToggleField, showModal } from "decky-frontend-lib"; 2 | import { LocalThemeStatus, Theme } from "../../ThemeTypes"; 3 | import { useCssLoaderState } from "../../state"; 4 | import * as python from "../../python"; 5 | import { ImCog } from "react-icons/im"; 6 | import { toggleTheme } from "../../backend/backendHelpers/toggleTheme"; 7 | import { ThemeSettingsModalRoot } from "../Modals/ThemeSettingsModal"; 8 | import { FaEye, FaEyeSlash, FaTrash } from "react-icons/fa"; 9 | import { BsGearFill } from "react-icons/bs"; 10 | 11 | export function FullscreenSingleThemeEntry({ 12 | data: e, 13 | showModalButtonPrompt = false, 14 | handleUninstall, 15 | handleUpdate, 16 | isInstalling, 17 | }: { 18 | data: Theme; 19 | showModalButtonPrompt?: boolean; 20 | handleUninstall: (e: Theme) => void; 21 | handleUpdate: (e: Theme) => void; 22 | isInstalling: boolean; 23 | }) { 24 | const { unpinnedThemes, updateStatuses } = useCssLoaderState(); 25 | const isPinned = !unpinnedThemes.includes(e.id); 26 | 27 | let [updateStatus]: [LocalThemeStatus] = ["installed"]; 28 | const themeArrPlace = updateStatuses.find((f) => f[0] === e.id); 29 | if (themeArrPlace) { 30 | updateStatus = themeArrPlace[1]; 31 | } 32 | 33 | // I extracted these here as doing conditional props inline sucks 34 | const modalButtonProps = showModalButtonPrompt 35 | ? { 36 | onOptionsActionDescription: "Expand Settings", 37 | onOptionsButton: () => { 38 | showModal(); 39 | }, 40 | } 41 | : {}; 42 | 43 | const updateButtonProps = 44 | updateStatus === "outdated" 45 | ? { 46 | onSecondaryButton: () => { 47 | handleUpdate(e); 48 | }, 49 | onSecondaryActionDescription: "Update Theme", 50 | } 51 | : {}; 52 | 53 | return ( 54 | <> 55 |
56 | {updateStatus === "outdated" && ( 57 |
69 | )} 70 | 75 | {e.display_name}} 79 | checked={e.enabled} 80 | onChange={(switchValue: boolean) => { 81 | toggleTheme(e, switchValue); 82 | }} 83 | /> 84 | 85 | { 89 | if (isPinned) { 90 | python.unpinTheme(e.id); 91 | } else { 92 | python.pinTheme(e.id); 93 | } 94 | }} 95 | > 96 | {isPinned ? ( 97 | 98 | ) : ( 99 | 100 | )} 101 | 102 | { 106 | showModal(); 107 | }} 108 | > 109 | 110 | 111 |
112 | 113 | ); 114 | } 115 | -------------------------------------------------------------------------------- /src/pages/theme-manager/ThemeBrowserPage.tsx: -------------------------------------------------------------------------------- 1 | import { Focusable } from "decky-frontend-lib"; 2 | import { useLayoutEffect, useState, FC, useEffect, useRef } from "react"; 3 | import * as python from "../../python"; 4 | import { getThemes } from "../../api"; 5 | import { logInWithShortToken } from "../../api"; 6 | import { isEqual } from "lodash"; 7 | 8 | // Interfaces for the JSON objects the lists work with 9 | import { useCssLoaderState } from "../../state"; 10 | import { BrowserSearchFields, VariableSizeCard, LoadMoreButton } from "../../components"; 11 | 12 | export const ThemeBrowserPage: FC = () => { 13 | const { 14 | browseThemeList: themeArr, 15 | themeSearchOpts: searchOpts, 16 | apiShortToken, 17 | apiFullToken, 18 | serverFilters, 19 | browserCardSize = 3, 20 | prevSearchOpts, 21 | backendVersion, 22 | forceScrollBackUp, 23 | setGlobalState, 24 | } = useCssLoaderState(); 25 | 26 | function reloadThemes() { 27 | getThemes(searchOpts, "/themes", "browseThemeList", setSnapIndex); 28 | python.reloadBackend(); 29 | } 30 | 31 | useEffect(() => { 32 | if (!isEqual(prevSearchOpts, searchOpts) || themeArr.total === 0) { 33 | getThemes(searchOpts, "/themes", "browseThemeList", setSnapIndex); 34 | } 35 | }, [searchOpts, prevSearchOpts]); 36 | 37 | // Runs upon opening the page every time 38 | useLayoutEffect(() => { 39 | python.getBackendVersion(); 40 | if (apiShortToken && !apiFullToken) { 41 | logInWithShortToken(); 42 | } 43 | // Installed themes aren't used on this page, but they are used on other pages, so fetching them here means that as you navigate to the others they will be already loaded 44 | python.getInstalledThemes(); 45 | }, []); 46 | 47 | const endOfPageRef = useRef(); 48 | const firstCardRef = useRef(); 49 | useLayoutEffect(() => { 50 | if (forceScrollBackUp) { 51 | // Valve would RE FOCUS THE ONE YOU LAST CLICKED ON after this ran, so i had to add a delay 52 | setTimeout(() => { 53 | firstCardRef?.current && firstCardRef.current?.focus(); 54 | setGlobalState("forceScrollBackUp", false); 55 | }, 100); 56 | } 57 | }, []); 58 | 59 | const [indexToSnapTo, setSnapIndex] = useState(-1); 60 | useEffect(() => { 61 | if (endOfPageRef?.current) { 62 | endOfPageRef?.current?.focus(); 63 | } 64 | }, [indexToSnapTo]); 65 | 66 | return ( 67 | <> 68 | { 76 | reloadThemes(); 77 | }} 78 | /> 79 | {/* I wrap everything in a Focusable, because that ensures that the dpad/stick navigation works correctly */} 80 | 89 | {themeArr.items 90 | .filter((e) => e.manifestVersion <= backendVersion) 91 | .map((e, i) => ( 92 | 101 | ))} 102 | 103 |
112 |
113 | 121 |
122 |
123 | 124 | ); 125 | }; 126 | -------------------------------------------------------------------------------- /src/components/ThemeManager/BrowserItemCard.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { useCssLoaderState } from "../../state"; 3 | import { Theme } from "../../ThemeTypes"; 4 | import { Focusable, Navigation } from "decky-frontend-lib"; 5 | import { AiOutlineDownload } from "react-icons/ai"; 6 | import { PartialCSSThemeInfo, ThemeQueryRequest } from "../../apiTypes"; 7 | import { FaBullseye, FaDownload, FaStar } from "react-icons/fa"; 8 | import { shortenNumber } from "../../logic/numbers"; 9 | 10 | const cardWidth = { 11 | 5: 152, 12 | 4: 195, 13 | 3: 260, 14 | }; 15 | 16 | export const VariableSizeCard: FC<{ 17 | data: PartialCSSThemeInfo; 18 | cols: number; 19 | searchOpts?: ThemeQueryRequest; 20 | prevSearchOptsVarName?: string; 21 | refPassthrough?: any; 22 | onClick?: () => void; 23 | }> = ({ 24 | data: e, 25 | cols: size, 26 | refPassthrough = undefined, 27 | searchOpts, 28 | prevSearchOptsVarName, 29 | onClick, 30 | }) => { 31 | const { localThemeList, apiUrl, setGlobalState } = useCssLoaderState(); 32 | function checkIfThemeInstalled(themeObj: PartialCSSThemeInfo) { 33 | const filteredArr: Theme[] = localThemeList.filter( 34 | (e: Theme) => e.name === themeObj.name && e.author === themeObj.specifiedAuthor 35 | ); 36 | if (filteredArr.length > 0) { 37 | if (filteredArr[0].version === themeObj.version) { 38 | return "installed"; 39 | } else { 40 | return "outdated"; 41 | } 42 | } else { 43 | return "uninstalled"; 44 | } 45 | } 46 | function imageURLCreator(): string { 47 | if (e?.images[0]?.id && e.images[0].id !== "MISSING") { 48 | return `url(${apiUrl}/blobs/${e?.images[0].id})`; 49 | } else { 50 | return `url(https://upload.wikimedia.org/wikipedia/commons/thumb/8/81/Steam_Deck_logo_%28blue_background%29.svg/2048px-Steam_Deck_logo_%28blue_background%29.svg.png)`; 51 | } 52 | } 53 | 54 | const installStatus = checkIfThemeInstalled(e); 55 | 56 | return ( 57 | <> 58 |
59 | {installStatus === "outdated" && ( 60 |
61 | 62 |
63 | )} 64 | { 69 | if (onClick) { 70 | onClick(); 71 | return; 72 | } 73 | if (searchOpts && prevSearchOptsVarName) { 74 | setGlobalState(prevSearchOptsVarName, searchOpts); 75 | } 76 | setGlobalState("currentExpandedTheme", e); 77 | Navigation.Navigate("/cssloader/expanded-view"); 78 | }} 79 | > 80 |
81 | 87 |
88 |
89 |
90 | 91 | {shortenNumber(e.download.downloadCount) ?? e.download.downloadCount} 92 |
93 |
94 | 95 | {shortenNumber(e.starCount) ?? e.starCount} 96 |
97 |
98 | 99 | {e.target} 100 |
101 |
102 |
103 |
104 | {e.displayName} 105 | 106 | {e.version} - Last Updated {new Date(e.updated).toLocaleDateString()} 107 | 108 | By {e.specifiedAuthor} 109 |
110 | 111 |
112 | 113 | ); 114 | }; 115 | -------------------------------------------------------------------------------- /src/pages/settings/PluginSettings.tsx: -------------------------------------------------------------------------------- 1 | import { DropdownItem, Focusable, ToggleField } from "decky-frontend-lib"; 2 | import { useMemo, useState, useEffect } from "react"; 3 | import { useCssLoaderState } from "../../state"; 4 | import { toast } from "../../python"; 5 | import { setNavPatch } from "../../deckyPatches/NavPatch"; 6 | import { 7 | getWatchState, 8 | getServerState, 9 | enableServer, 10 | toggleWatchState, 11 | getBetaTranslationsState, 12 | fetchClassMappings, 13 | } from "../../backend/pythonMethods/pluginSettingsMethods"; 14 | import { booleanStoreWrite, stringStoreWrite } from "../../backend/pythonMethods/storeUtils"; 15 | import { disableUnminifyMode, enableUnminifyMode } from "../../deckyPatches/UnminifyMode"; 16 | 17 | export function PluginSettings() { 18 | const { navPatchInstance, unminifyModeOn, setGlobalState } = useCssLoaderState(); 19 | const [serverOn, setServerOn] = useState(false); 20 | const [watchOn, setWatchOn] = useState(false); 21 | const [betaTranslationsOn, setBetaTranslationsOn] = useState("-1"); 22 | 23 | const navPatchEnabled = useMemo(() => !!navPatchInstance, [navPatchInstance]); 24 | 25 | async function fetchServerState() { 26 | const value = await getServerState(); 27 | setServerOn(value); 28 | } 29 | async function fetchWatchState() { 30 | const value = await getWatchState(); 31 | setWatchOn(value); 32 | } 33 | async function fetchBetaTranslationsState() { 34 | const value = await getBetaTranslationsState(); 35 | if (!["0", "1", "-1"].includes(value)) { 36 | setBetaTranslationsOn("-1"); 37 | return; 38 | } 39 | setBetaTranslationsOn(value); 40 | } 41 | 42 | useEffect(() => { 43 | void fetchServerState(); 44 | void fetchWatchState(); 45 | void fetchBetaTranslationsState(); 46 | }, []); 47 | 48 | function setUnminify(enabled: boolean) { 49 | setGlobalState("unminifyModeOn", enabled); 50 | if (enabled) { 51 | enableUnminifyMode(); 52 | return; 53 | } 54 | disableUnminifyMode(); 55 | } 56 | 57 | async function setWatch(enabled: boolean) { 58 | await toggleWatchState(enabled, false); 59 | await fetchWatchState(); 60 | } 61 | 62 | async function setServer(enabled: boolean) { 63 | if (enabled) await enableServer(); 64 | await booleanStoreWrite("server", enabled); 65 | await fetchServerState(); 66 | } 67 | 68 | async function setBetaTranslations(value: string) { 69 | await stringStoreWrite("beta_translations", value); 70 | await fetchClassMappings(); 71 | await fetchBetaTranslationsState(); 72 | } 73 | 74 | return ( 75 |
76 | 77 | setBetaTranslations(data.data)} 87 | /> 88 | 89 | 90 | { 95 | setServer(value); 96 | }} 97 | /> 98 | 99 | 100 | setNavPatch(value, true)} 105 | /> 106 | 107 | 108 | 114 | 115 | 116 | 122 | 123 |
124 | ); 125 | } 126 | -------------------------------------------------------------------------------- /src/components/Styles/ThemeBrowserCardStyles.tsx: -------------------------------------------------------------------------------- 1 | import { useCssLoaderState } from "../../state"; 2 | 3 | export function ThemeBrowserCardStyles({ customCardSize }: { customCardSize?: number }) { 4 | const { browserCardSize } = customCardSize 5 | ? { browserCardSize: customCardSize } 6 | : useCssLoaderState(); 7 | 8 | return ( 9 | 123 | ); 124 | } 125 | -------------------------------------------------------------------------------- /css_themepatch.py: -------------------------------------------------------------------------------- 1 | from css_inject import Inject, to_injects 2 | from css_themepatchcomponent import ThemePatchComponent 3 | from css_utils import Log, Result 4 | 5 | class ThemePatch: 6 | def __init__(self, theme, json : dict, name : str): 7 | self.json = json 8 | self.name = name 9 | self.default = json["default"] if "default" in json else None 10 | self.type = json["type"] if "type" in json else "dropdown" 11 | self.theme = theme 12 | self.value = self.default 13 | self.injects = [] 14 | self.options = {} 15 | self.patchVersion = None 16 | self.components = [] 17 | 18 | if "values" in json: # Do we have a v2 or a v1 format? 19 | self.patchVersion = 2 20 | for x in json["values"]: 21 | self.options[x] = [] 22 | else: 23 | self.patchVersion = 1 24 | for x in json: 25 | if (x == "default"): 26 | continue 27 | 28 | self.options[x] = [] 29 | 30 | if len(self.options) <= 0: 31 | raise Exception(f"In patch '{name}' there is less than 1 value present") 32 | 33 | if self.default is None: 34 | self.default = list(self.options.keys())[0] 35 | 36 | if self.default not in self.options: 37 | raise Exception(f"In patch '{self.name}', '{self.default}' does not exist as a patch option") 38 | 39 | self.load() 40 | 41 | def set_value(self, value): 42 | if isinstance(value, str): 43 | self.value = value 44 | elif isinstance(value, dict): 45 | if "value" in value: 46 | self.value = value["value"] 47 | 48 | if "components" in value: 49 | components = value["components"] 50 | 51 | if not isinstance(components, dict): 52 | raise Exception("???") 53 | 54 | for x in self.components: 55 | if x.name in components: 56 | x.value = components[x.name] 57 | x.generate() 58 | 59 | def get_value(self) -> str | dict: 60 | if len(self.components) <= 0: 61 | return self.value 62 | else: 63 | components = {} 64 | for x in self.components: 65 | components[x.name] = x.value 66 | 67 | return { 68 | "value": self.value, 69 | "components": components, 70 | } 71 | 72 | def check_value(self): 73 | if (self.value not in self.options): 74 | self.value = self.default 75 | 76 | if (self.type not in ["dropdown", "checkbox", "slider", "none"]): 77 | self.type = "dropdown" 78 | 79 | if (self.type == "checkbox"): 80 | if not ("No" in self.options and "Yes" in self.options): 81 | self.type = "dropdown" 82 | 83 | def load(self): 84 | for x in self.options: 85 | data = self.json[x] if self.patchVersion == 1 else self.json["values"][x] 86 | 87 | items = to_injects(data, self.theme.themePath, self.theme) 88 | self.injects.extend(items) 89 | self.options[x].extend(items) 90 | 91 | if "components" in self.json: 92 | for x in self.json["components"]: 93 | component = ThemePatchComponent(self, x) 94 | if component.on not in self.options: 95 | raise Exception("Component references non-existent value") 96 | 97 | self.components.append(component) 98 | self.injects.append(component.inject) 99 | self.options[component.on].append(component.inject) 100 | 101 | self.check_value() 102 | 103 | async def inject(self, inject_now : bool = True) -> Result: 104 | self.check_value() 105 | Log(f"Injecting patch '{self.name}' of theme '{self.theme.name}'") 106 | for x in self.options[self.value]: 107 | if inject_now: 108 | await x.inject() # Ignore result for now. It'll be logged but not acted upon 109 | else: 110 | x.enabled = True 111 | 112 | return Result(True) 113 | 114 | async def remove(self) -> Result: 115 | self.check_value() 116 | Log(f"Removing patch '{self.name}' of theme '{self.theme.name}'") 117 | for x in self.injects: 118 | result = await x.remove() 119 | if not result.success: 120 | return result 121 | 122 | return Result(True) 123 | 124 | def to_dict(self) -> dict: 125 | return { 126 | "name": self.name, 127 | "default": self.default, 128 | "value": self.value, 129 | "options": [x for x in self.options], 130 | "type": self.type, 131 | "components": [x.to_dict() for x in self.components] 132 | } -------------------------------------------------------------------------------- /src/components/Modals/AuthorViewModal.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import * as python from "../../python"; 3 | import { CssLoaderContextProvider, useCssLoaderState } from "../../state"; 4 | import { Focusable, ModalRoot } from "decky-frontend-lib"; 5 | import { genericGET } from "../../api"; 6 | import { PartialCSSThemeInfo, ThemeQueryResponse, UserInfo } from "../../apiTypes"; 7 | import { ImSpinner5 } from "react-icons/im"; 8 | import { VariableSizeCard } from "../ThemeManager"; 9 | import { ThemeBrowserCardStyles } from "../Styles"; 10 | import { SupporterIcon } from "../SupporterIcon"; 11 | 12 | export function AuthorViewModalRoot({ 13 | closeModal, 14 | authorData, 15 | }: { 16 | closeModal?: any; 17 | authorData: UserInfo; 18 | }) { 19 | return ( 20 | <> 21 | 22 | {/* @ts-ignore */} 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | 31 | function AuthorViewModal({ 32 | authorData, 33 | closeModal, 34 | }: { 35 | authorData: UserInfo; 36 | closeModal: () => {}; 37 | }) { 38 | const { setGlobalState } = useCssLoaderState(); 39 | 40 | const [loaded, setLoaded] = useState(false); 41 | const [themes, setThemes] = useState([]); 42 | 43 | const firstThemeRef = useRef(); 44 | 45 | async function fetchThemeData() { 46 | const data: ThemeQueryResponse = await genericGET( 47 | `/users/${authorData.id}/themes?page=1&perPage=50&filters=CSS&order=Most Downloaded` 48 | ); 49 | if (data?.total && data.total > 0) { 50 | setThemes(data.items); 51 | setLoaded(true); 52 | } 53 | } 54 | useEffect(() => { 55 | fetchThemeData(); 56 | }, []); 57 | 58 | useEffect(() => { 59 | if (firstThemeRef?.current) { 60 | setTimeout(() => { 61 | firstThemeRef?.current?.focus(); 62 | }, 10); 63 | } 64 | }, [loaded]); 65 | 66 | return ( 67 | 68 | {loaded ? ( 69 | <> 70 | 90 | 91 |
92 | 98 | {authorData.username} 99 |
100 | 101 |
102 |
103 | 106 | {themes.map((e, i) => { 107 | return ( 108 | { 110 | setGlobalState("currentExpandedTheme", e); 111 | closeModal(); 112 | }} 113 | refPassthrough={i === 0 ? firstThemeRef : null} 114 | cols={4} 115 | data={e} 116 | /> 117 | ); 118 | })} 119 | 120 | 121 | ) : ( 122 | <> 123 | 135 |
145 | 146 | Loading 147 |
148 | 149 | )} 150 |
151 | ); 152 | } 153 | -------------------------------------------------------------------------------- /css_utils.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | import os, platform, traceback 3 | 4 | HOME = os.getenv("HOME") 5 | 6 | if not HOME: 7 | HOME = os.path.expanduser("~") 8 | 9 | USER = os.getenv("USER", "user") # USER is just used for config name 10 | 11 | DECKY_HOME = os.getenv("DECKY_HOME", os.path.join(HOME, "homebrew")) 12 | DECKY_USER = os.getenv("DECKY_USER") 13 | 14 | if not DECKY_USER: 15 | DECKY_USER = os.getlogin() 16 | 17 | if not os.path.exists(DECKY_HOME): 18 | os.mkdir(DECKY_HOME) 19 | 20 | PLATFORM_WIN = platform.system() == "Windows" 21 | 22 | if not PLATFORM_WIN: 23 | import pwd 24 | 25 | Logger = getLogger("CSS_LOADER") 26 | 27 | FLAG_KEEP_DEPENDENCIES = "KEEP_DEPENDENCIES" 28 | FLAG_PRESET = "PRESET" 29 | 30 | def Log(text : str): 31 | Logger.info(f"[CSS_Loader] {text}") 32 | 33 | class Result: 34 | def __init__(self, success : bool, message : str = "Success", log : bool = True): 35 | self.success = success 36 | self.message = message 37 | 38 | stack = traceback.extract_stack() 39 | function_above = stack[-2] 40 | 41 | if log and not self.success: 42 | Log(f"[FAIL] [{os.path.basename(function_above.filename)}:{function_above.lineno}] {message}") 43 | 44 | def raise_on_failure(self): 45 | if not self.success: 46 | raise Exception(self.message) 47 | 48 | def to_dict(self): 49 | return {"success": self.success, "message": self.message} 50 | 51 | def create_dir(dirPath : str): 52 | if (os.path.exists(dirPath)): 53 | return 54 | 55 | os.mkdir(dirPath) 56 | 57 | if not PLATFORM_WIN: 58 | a = pwd.getpwnam(DECKY_USER) 59 | uid = a.pw_uid 60 | gid = a.pw_gid 61 | 62 | if (os.stat(dirPath).st_uid != uid): 63 | os.chown(dirPath, uid, gid) # Change to deck user 64 | 65 | def get_user_home() -> str: 66 | return HOME 67 | 68 | def get_theme_path() -> str: 69 | path = os.path.join(DECKY_HOME, "themes") 70 | 71 | if not os.path.exists(path): 72 | create_dir(path) 73 | 74 | return path 75 | 76 | def create_symlink(src : str, dst : str) -> Result: 77 | try: 78 | if not os.path.exists(dst): 79 | os.symlink(src, dst, True) 80 | except Exception as e: 81 | return Result(False, str(e)) 82 | 83 | return Result(True) 84 | 85 | def get_steam_path() -> str: 86 | if PLATFORM_WIN: 87 | try: 88 | import winreg 89 | conn = winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) 90 | key = winreg.OpenKey(conn, "SOFTWARE\\Wow6432Node\\Valve\\Steam") 91 | val, type = winreg.QueryValueEx(key, "InstallPath") 92 | if type != winreg.REG_SZ: 93 | raise Exception(f"Expected type {winreg.REG_SZ}, got {type}") 94 | 95 | Log(f"Got win steam install path: '{val}'") 96 | return val 97 | except Exception as e: 98 | return "C:\\Program Files (x86)\\Steam" # Taking a guess here 99 | else: 100 | return f"{get_user_home()}/.steam/steam" 101 | 102 | def is_steam_beta_active() -> bool: 103 | beta_path = os.path.join(get_steam_path(), "package", "beta") 104 | if not os.path.exists(beta_path): 105 | return False 106 | 107 | with open(beta_path, 'r') as fp: 108 | content = fp.read().strip() 109 | 110 | stable_branches = [ 111 | "steamdeck_stable", 112 | ] 113 | 114 | return content not in stable_branches 115 | 116 | def create_steam_symlink() -> Result: 117 | return create_symlink(get_theme_path(), os.path.join(get_steam_path(), "steamui", "themes_custom")) 118 | 119 | def create_cef_flag() -> Result: 120 | path = os.path.join(get_steam_path(), ".cef-enable-remote-debugging") 121 | if not os.path.exists(path): 122 | with open(path, 'w') as fp: 123 | pass 124 | 125 | def store_path() -> str: 126 | return os.path.join(get_theme_path(), "STORE") 127 | 128 | def store_reads() -> dict: 129 | path = store_path() 130 | items = {} 131 | 132 | if not os.path.exists(path): 133 | return items 134 | 135 | with open(path, 'r') as fp: 136 | for x in fp.readlines(): 137 | c = x.strip() 138 | if (c == ""): 139 | continue 140 | 141 | split = c.split(":", 1) 142 | 143 | if (len(split) <= 1): 144 | continue 145 | 146 | items[split[0]] = split[1] 147 | 148 | return items 149 | 150 | def store_read(key : str) -> str: 151 | items = store_reads() 152 | return items[key] if key in items else "" 153 | 154 | def store_write(key : str, val : str): 155 | path = store_path() 156 | items = store_reads() 157 | items[key] = val.replace('\n', '') 158 | with open(path, 'w') as fp: 159 | fp.write("\n".join([f"{x}:{items[x]}" for x in items])) 160 | 161 | def store_or_file_config(key : str) -> bool: 162 | if os.path.exists(os.path.join(get_theme_path(), key.upper())): 163 | return True 164 | 165 | read = store_read(key) 166 | return read == "True" or read == "1" -------------------------------------------------------------------------------- /src/pages/settings/ThemeSettings.tsx: -------------------------------------------------------------------------------- 1 | import { DialogButton, DialogCheckbox, Focusable, PanelSection } from "decky-frontend-lib"; 2 | import { useCssLoaderState } from "../../state"; 3 | import { useMemo, useState } from "react"; 4 | import { Flags, Theme } from "../../ThemeTypes"; 5 | import { FullscreenSingleThemeEntry } from "../../components/ThemeSettings/FullscreenSingleThemeEntry"; 6 | import { ThemeErrorCard } from "../../components/ThemeErrorCard"; 7 | import { installTheme } from "../../api"; 8 | import * as python from "../../python"; 9 | import { DeleteMenu } from "../../components/ThemeSettings/DeleteMenu"; 10 | import { UpdateAllThemesButton } from "../../components/ThemeSettings/UpdateAllThemesButton"; 11 | 12 | export function ThemeSettings() { 13 | const { localThemeList, unpinnedThemes, themeErrors, setGlobalState, updateStatuses } = 14 | useCssLoaderState(); 15 | 16 | const [isInstalling, setInstalling] = useState(false); 17 | const [mode, setMode] = useState<"view" | "delete">("view"); 18 | 19 | const sortedList = useMemo(() => { 20 | return localThemeList 21 | .filter((e) => !e.flags.includes(Flags.isPreset)) 22 | .sort((a, b) => { 23 | const aPinned = !unpinnedThemes.includes(a.id); 24 | const bPinned = !unpinnedThemes.includes(b.id); 25 | // This sorts the pinned themes alphabetically, then the non-pinned alphabetically 26 | if (aPinned === bPinned) { 27 | return a.name.localeCompare(b.name); 28 | } 29 | return Number(bPinned) - Number(aPinned); 30 | }); 31 | }, [localThemeList.length]); 32 | 33 | async function handleUpdate(e: Theme) { 34 | setInstalling(true); 35 | const unpinned = unpinnedThemes.includes(e.id); 36 | await installTheme(e.id); 37 | // This just updates the updateStatuses arr to know that this theme now is up to date, no need to re-fetch the API to know that 38 | setGlobalState( 39 | "updateStatuses", 40 | updateStatuses.map((f) => (f[0] === e.id ? [e.id, "installed", false] : e)) 41 | ); 42 | // Remove duplicate theme from unpinned list. 43 | if (unpinned) python.pinTheme(e.id); 44 | setInstalling(false); 45 | } 46 | 47 | async function handleUninstall(listEntry: Theme) { 48 | setInstalling(true); 49 | await python.deleteTheme(listEntry.name); 50 | if (unpinnedThemes.includes(listEntry.id)) { 51 | // This isn't really pinning it, it's just removing its name from the unpinned list. 52 | python.pinTheme(listEntry.id); 53 | } 54 | await python.reloadBackend(); 55 | setInstalling(false); 56 | } 57 | 58 | return ( 59 |
60 | 83 | 84 | 85 | 86 | (mode === "delete" ? setMode("view") : setMode("delete"))} 89 | > 90 | {mode === "delete" ? "Go Back" : "Delete Themes"} 91 | 92 | 93 | 94 | {mode === "view" && ( 95 | <> 96 | 97 | {sortedList.map((e) => ( 98 | 103 | ))} 104 | 105 | 106 | )} 107 | {mode === "delete" && ( 108 | setMode("view")} themeList={sortedList} /> 109 | )} 110 | 111 | 112 | {themeErrors.length > 0 && ( 113 | 114 | 117 | {themeErrors.map((e) => { 118 | return ; 119 | })} 120 | 121 | 122 | )} 123 |
124 | ); 125 | } 126 | -------------------------------------------------------------------------------- /src/components/ThemePatch.tsx: -------------------------------------------------------------------------------- 1 | import { DropdownItem, PanelSectionRow, SliderField, ToggleField } from "decky-frontend-lib"; 2 | import * as python from "../python"; 3 | import { useState, VFC } from "react"; 4 | import { Patch } from "../ThemeTypes"; 5 | import { PatchComponent } from "./PatchComponent"; 6 | import { useCssLoaderState } from "../state"; 7 | 8 | export const ThemePatch: VFC<{ 9 | data: Patch; 10 | index: number; 11 | fullArr: Patch[]; 12 | themeName: string; 13 | modal?: boolean; 14 | }> = ({ data, index, fullArr, themeName, modal = false }) => { 15 | const { selectedPreset } = useCssLoaderState(); 16 | const [selectedIndex, setIndex] = useState(data.options.indexOf(data.value)); 17 | 18 | const [selectedLabel, setLabel] = useState(data.value); 19 | 20 | const bottomSeparatorValue = fullArr.length - 1 === index ? "standard" : "none"; 21 | 22 | async function setPatchValue(value: string) { 23 | await python.setPatchOfTheme(themeName, data.name, value); 24 | // This was before all currently toggled themes were part of a dependency, this (and probably lots of the other preset code) can be changed to assume that by default 25 | if (selectedPreset && selectedPreset.dependencies.includes(themeName)) { 26 | return python.generatePresetFromThemeNames(selectedPreset.name, selectedPreset.dependencies); 27 | } 28 | return; 29 | } 30 | 31 | function ComponentWrapper() { 32 | return ( 33 | <> 34 | {data.components.length > 0 ? ( 35 | <> 36 | {data.components.map((e) => ( 37 | 44 | ))} 45 | 46 | ) : null} 47 | 48 | ); 49 | } 50 | 51 | switch (data.type) { 52 | case "slider": 53 | return ( 54 | <> 55 | 56 | } 59 | min={0} 60 | max={data.options.length - 1} 61 | value={selectedIndex} 62 | onChange={(value) => { 63 | setPatchValue(data.options[value]); 64 | setIndex(value); 65 | setLabel(data.options[value]); 66 | data.value = data.options[value]; 67 | }} 68 | notchCount={data.options.length} 69 | notchLabels={data.options.map((e, i) => ({ 70 | notchIndex: i, 71 | label: e, 72 | value: i, 73 | }))} 74 | /> 75 | 76 | 77 | 78 | ); 79 | case "checkbox": 80 | return ( 81 | <> 82 | 83 | } 86 | checked={data.value === "Yes"} 87 | onChange={(bool) => { 88 | const newValue = bool ? "Yes" : "No"; 89 | setPatchValue(newValue); 90 | setLabel(newValue); 91 | setIndex(data.options.findIndex((e) => e === newValue)); 92 | data.value = newValue; 93 | }} 94 | /> 95 | 96 | 97 | 98 | ); 99 | case "dropdown": 100 | return ( 101 | <> 102 | 103 | } 106 | menuLabel={`${data.name}`} 107 | rgOptions={data.options.map((x, i) => { 108 | return { data: i, label: x }; 109 | })} 110 | selectedOption={selectedIndex} 111 | onChange={(index) => { 112 | setIndex(index.data); 113 | data.value = index.label as string; 114 | setLabel(data.value); 115 | setPatchValue(data.value); 116 | }} 117 | /> 118 | 119 | 120 | 121 | ); 122 | case "none": 123 | return ( 124 | <> 125 | 126 | {modal ? ( 127 | {data.name} 128 | ) : ( 129 | 130 | )} 131 | 132 | 133 | 134 | ); 135 | default: 136 | return null; 137 | } 138 | }; 139 | 140 | const PatchLabel = ({ name }: { name: string }) => { 141 | return ( 142 |
148 | {name} 149 |
150 | ); 151 | }; 152 | -------------------------------------------------------------------------------- /src/components/Modals/ThemeSettingsModal/ThemeSettingsModalButtons.tsx: -------------------------------------------------------------------------------- 1 | import { DialogButton, Focusable, showModal } from "decky-frontend-lib"; 2 | import { LocalThemeStatus, Theme } from "../../../ThemeTypes"; 3 | import { FaDownload, FaEye, FaEyeSlash, FaRegStar, FaStar, FaTrashAlt } from "react-icons/fa"; 4 | import { DeleteConfirmationModalRoot } from "../DeleteConfirmationModal"; 5 | import { useCssLoaderState } from "../../../state"; 6 | import * as python from "../../../python"; 7 | import { 8 | genericGET, 9 | logInWithShortToken, 10 | refreshToken, 11 | toggleStar as apiToggleStar, 12 | installTheme, 13 | } from "../../../api"; 14 | import { useState, useEffect } from "react"; 15 | 16 | export function ThemeSettingsModalButtons({ 17 | themeData, 18 | closeModal, 19 | }: { 20 | themeData: Theme; 21 | closeModal: () => void; 22 | }) { 23 | const { unpinnedThemes, apiShortToken, apiFullToken, updateStatuses, setGlobalState } = 24 | useCssLoaderState(); 25 | const isPinned = !unpinnedThemes.includes(themeData.id); 26 | const [starFetchLoaded, setStarFetchLoaded] = useState(false); 27 | const [isStarred, setStarred] = useState(false); 28 | const [blurButtons, setBlurButtons] = useState(false); 29 | 30 | const [updateStatus, setUpdateStatus] = useState("installed"); 31 | useEffect(() => { 32 | if (!themeData) return; 33 | const themeArrPlace = updateStatuses.find((f) => f[0] === themeData.id); 34 | if (themeArrPlace) { 35 | setUpdateStatus(themeArrPlace[1]); 36 | } 37 | }, [themeData]); 38 | 39 | async function toggleStar() { 40 | if (apiFullToken) { 41 | setBlurButtons(true); 42 | const newToken = await refreshToken(); 43 | if (themeData && newToken) { 44 | apiToggleStar(themeData.id, isStarred, newToken).then((bool) => { 45 | if (bool) { 46 | setStarred((cur) => !cur); 47 | setBlurButtons(false); 48 | } 49 | }); 50 | } 51 | } else { 52 | python.toast("Not Logged In!", "You can only star themes if logged in."); 53 | } 54 | } 55 | 56 | async function getStarredStatus() { 57 | if (themeData && apiShortToken) { 58 | if (!apiFullToken) { 59 | await logInWithShortToken(); 60 | } 61 | const data = (await genericGET(`/users/me/stars/${themeData.id}`, true, undefined)) as { 62 | starred: boolean; 63 | }; 64 | if (data) { 65 | setStarFetchLoaded(true); 66 | setStarred(data.starred); 67 | } 68 | } 69 | } 70 | useEffect(() => { 71 | getStarredStatus(); 72 | }, []); 73 | 74 | return ( 75 | <> 76 | 77 | {updateStatus === "outdated" && ( 78 | { 82 | await installTheme(themeData.id); 83 | // This just updates the updateStatuses arr to know that this theme now is up to date, no need to re-fetch the API to know that 84 | setGlobalState( 85 | "updateStatuses", 86 | updateStatuses.map((e) => 87 | e[0] === themeData.id ? [themeData.id, "installed", false] : e 88 | ) 89 | ); 90 | }} 91 | > 92 | 93 | Update 94 | 95 | )} 96 | { 100 | if (isPinned) { 101 | python.unpinTheme(themeData.id); 102 | } else { 103 | python.pinTheme(themeData.id); 104 | } 105 | }} 106 | > 107 | {isPinned ? ( 108 | 109 | ) : ( 110 | 111 | )} 112 | 113 | {starFetchLoaded && ( 114 | 119 | {isStarred ? : } 120 | 121 | )} 122 | { 126 | showModal( 127 | 131 | ); 132 | }} 133 | > 134 | 135 | 136 | 137 | 138 | ); 139 | } 140 | -------------------------------------------------------------------------------- /src/components/PatchComponent.tsx: -------------------------------------------------------------------------------- 1 | import { VFC } from "react"; 2 | 3 | import * as python from "../python"; 4 | 5 | import Color from "color"; 6 | import { showModal, ButtonItem, PanelSectionRow } from "decky-frontend-lib"; 7 | 8 | import { ColorPickerModal } from "decky-frontend-lib"; 9 | import { ThemePatchComponent } from "../ThemeTypes"; 10 | import { FaFolder } from "react-icons/fa"; 11 | import { useCssLoaderState } from "../state"; 12 | 13 | export const PatchComponent: VFC<{ 14 | data: ThemePatchComponent; 15 | selectedLabel: string; 16 | themeName: string; 17 | patchName: string; 18 | bottomSeparatorValue: "standard" | "none"; 19 | }> = ({ data, selectedLabel, themeName, patchName, bottomSeparatorValue }) => { 20 | const { selectedPreset } = useCssLoaderState(); 21 | if (selectedLabel === data.on) { 22 | // The only value that changes from component to component is the value, so this can just be re-used 23 | async function setComponentAndReload(value: string) { 24 | await python.setComponentOfThemePatch(themeName, patchName, data.name, value); 25 | if (selectedPreset && selectedPreset.dependencies.includes(themeName)) { 26 | python.generatePresetFromThemeNames(selectedPreset.name, selectedPreset.dependencies); 27 | } 28 | python.getInstalledThemes(); 29 | } 30 | switch (data.type) { 31 | case "image-picker": 32 | // This makes things compatible with people using HoloISO or who don't have the user /deck/ 33 | function getRootPath() { 34 | python.resolve(python.fetchThemePath(), (path: string) => pickImage(path)); 35 | } 36 | // These have to 37 | async function pickImage(rootPath: string) { 38 | const res = await python.openFilePicker(rootPath); 39 | if (!res.path.includes(rootPath)) { 40 | python.toast("Invalid File", "Images must be within themes folder"); 41 | return; 42 | } 43 | if (!/\.(jpg|jpeg|png|webp|avif|gif|svg)$/.test(res.path)) { 44 | python.toast("Invalid File", "Must be an image file"); 45 | return; 46 | } 47 | const relativePath = res.path.split(`${rootPath}/`)[1]; 48 | setComponentAndReload(relativePath); 49 | } 50 | return ( 51 | 52 | getRootPath()} 55 | layout="below" 56 | > 57 |
58 | Open {data.name} 59 |
69 | 70 |
71 |
72 |
73 |
74 | ); 75 | case "color-picker": 76 | const colorObj = Color(data.value).hsl(); 77 | const curColorHSLArray = colorObj.array(); 78 | 79 | return ( 80 | <> 81 | 82 | 85 | showModal( 86 | // @ts-ignore -- showModal passes the closeModal function to this, but for some reason it's giving me a typescript error because I didn't explicitly pass it myself 87 | { 89 | setComponentAndReload(HSLString); 90 | }} 91 | defaultH={curColorHSLArray[0]} 92 | defaultS={curColorHSLArray[1]} 93 | defaultL={curColorHSLArray[2]} 94 | defaultA={curColorHSLArray[3] ?? 1} 95 | title={data.name} 96 | /> 97 | ) 98 | } 99 | layout={"below"} 100 | > 101 |
102 | Open {data.name} 103 |
114 |
121 |
122 |
123 | 124 | 125 | 126 | ); 127 | } 128 | } 129 | return null; 130 | }; 131 | -------------------------------------------------------------------------------- /src/components/Styles/ExpandedViewStyles.tsx: -------------------------------------------------------------------------------- 1 | export function ExpandedViewStyles({ 2 | gapBetweenCarouselAndImage, 3 | imageAreaPadding, 4 | imageAreaWidth, 5 | selectedImageHeight, 6 | selectedImageWidth, 7 | imageCarouselEntryHeight, 8 | imageCarouselEntryWidth, 9 | }: { 10 | gapBetweenCarouselAndImage: number; 11 | imageAreaPadding: number; 12 | imageAreaWidth: number; 13 | selectedImageHeight: number; 14 | selectedImageWidth: number; 15 | imageCarouselEntryHeight: number; 16 | imageCarouselEntryWidth: number; 17 | }) { 18 | return ( 19 | 213 | ); 214 | } 215 | -------------------------------------------------------------------------------- /src/components/Modals/ThemeSettingsModal/ThemeSettingsModal.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useMemo } from "react"; 2 | 3 | import { DialogButton, Focusable, ModalRoot, Toggle } from "decky-frontend-lib"; 4 | import { CssLoaderContextProvider, useCssLoaderState } from "../../../state"; 5 | import { Theme } from "../../../ThemeTypes"; 6 | import { globalState } from "../../../python"; 7 | import { ThemeSettingsModalButtons } from "./ThemeSettingsModalButtons"; 8 | import { toggleTheme } from "../../../backend/backendHelpers/toggleTheme"; 9 | import { ThemePatch } from "../../ThemePatch"; 10 | export function ThemeSettingsModalRoot({ 11 | closeModal, 12 | selectedTheme, 13 | }: { 14 | closeModal?: any; 15 | selectedTheme: string; 16 | }) { 17 | return ( 18 | 19 | {/* @ts-ignore */} 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | 27 | function ThemeSettingsModal({ 28 | closeModal, 29 | selectedTheme, 30 | }: { 31 | closeModal: any; 32 | selectedTheme: string; 33 | }) { 34 | const { localThemeList, updateStatuses } = useCssLoaderState(); 35 | const [themeData, setThemeData] = useState( 36 | localThemeList.find((e) => e.id === selectedTheme) 37 | ); 38 | 39 | useEffect(() => { 40 | setThemeData(localThemeList.find((e) => e.id === selectedTheme)); 41 | return () => { 42 | setThemeData(undefined); 43 | }; 44 | }, [selectedTheme, localThemeList]); 45 | 46 | return ( 47 | <> 48 | 122 | 123 | {themeData ? ( 124 | <> 125 | 126 |
127 | {themeData.display_name} 128 | 129 | {themeData.version} | {themeData.author} 130 | 131 |
132 | { 135 | toggleTheme(themeData, checked); 136 | }} 137 | /> 138 |
139 | {themeData.enabled && themeData.patches.length > 0 && ( 140 | <> 141 | 142 | {themeData.patches.map((x, i, arr) => ( 143 | 144 | ))} 145 | 146 | 147 | )} 148 | 149 | ) : ( 150 | No Theme Data 151 | )} 152 | 153 | { 156 | closeModal(); 157 | }} 158 | > 159 | Close 160 | 161 | {themeData && } 162 | 163 |
164 | 165 | ); 166 | } 167 | -------------------------------------------------------------------------------- /src/pages/settings/DonatePage.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DialogButton, 3 | Focusable, 4 | Navigation, 5 | Panel, 6 | PanelSection, 7 | ScrollPanelGroup, 8 | } from "decky-frontend-lib"; 9 | import { useEffect, useMemo, useState } from "react"; 10 | import { SiKofi, SiPatreon } from "react-icons/si"; 11 | import { server } from "../../python"; 12 | 13 | export function DonatePage() { 14 | const [loaded, setLoaded] = useState(false); 15 | const [supporters, setSupporters] = useState(""); 16 | 17 | const formattedSupporters = useMemo(() => { 18 | const numOfNamesPerPage = 10; 19 | const supportersArr = supporters.split("\n"); 20 | const newArr = []; 21 | for (let i = 0; i < supportersArr.length; i += numOfNamesPerPage) { 22 | newArr.push(supportersArr.slice(i, i + numOfNamesPerPage).join("\n")); 23 | } 24 | return newArr; 25 | }, [supporters]); 26 | 27 | function fetchSupData() { 28 | server! 29 | .fetchNoCors("https://api.deckthemes.com/patrons", { method: "GET" }) 30 | .then((deckyRes) => { 31 | if (deckyRes.success) { 32 | return deckyRes.result; 33 | } 34 | throw new Error("unsuccessful"); 35 | }) 36 | .then((res) => { 37 | if (res.status === 200) { 38 | return res.body; 39 | } 40 | throw new Error("Res not OK"); 41 | }) 42 | .then((text) => { 43 | if (text) { 44 | setLoaded(true); 45 | setSupporters(text); 46 | } 47 | }) 48 | .catch((err) => { 49 | console.error("CSS Loader - Error Fetching Supporter Data", err); 50 | }); 51 | } 52 | useEffect(() => { 53 | fetchSupData(); 54 | }, []); 55 | return ( 56 |
57 | 107 |

108 | Donations help to cover the costs of hosting the store, as well as funding development for 109 | CSS Loader and its related projects. 110 |

111 | 112 | Navigation.NavigateToExternalWeb("https://patreon.com/deckthemes")} 114 | focusWithinClassName="gpfocuswithin" 115 | className="patreon-or-kofi-container patreon" 116 | > 117 |
118 | 119 | Patreon 120 |
121 | Recurring Donation 122 | patreon.com/deckthemes 123 | Perks: 124 |
    125 |
  • 126 | {/* Potentially could expand this to add it to deckthemes and audioloader */} 127 | Your name in CSS Loader 128 |
  • 129 |
  • Patreon badge on deckthemes.com
  • 130 |
  • 131 | {/* Could also impl. this on deck store to make it more meaningful */} 132 | Colored name + VIP channel in the DeckThemes Discord 133 |
  • 134 |
135 |
136 | Navigation.NavigateToExternalWeb("https://ko-fi.com/suchmememanyskill")} 138 | focusWithinClassName="gpfocuswithin" 139 | className="patreon-or-kofi-container" 140 | > 141 |
142 | 143 | Ko-Fi 144 |
145 | One-time Donation 146 | ko-fi.com/suchmememanyskill 147 |
148 |
149 | {loaded ? ( 150 |
151 | 152 | {formattedSupporters.map((e) => { 153 | return ( 154 | {}} focusWithinClassName="gpfocuswithin"> 155 |

{e}

156 |
157 | ); 158 | })} 159 |
160 |
161 | ) : null} 162 |
163 | ); 164 | } 165 | -------------------------------------------------------------------------------- /src/components/ThemeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonItem, Focusable, PanelSectionRow, ToggleField, showModal } from "decky-frontend-lib"; 2 | import { VFC, useState, useMemo } from "react"; 3 | import { Flags, LocalThemeStatus, Theme, UpdateStatus } from "../ThemeTypes"; 4 | 5 | import { ThemePatch } from "./ThemePatch"; 6 | import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; 7 | import { useCssLoaderState } from "../state"; 8 | import { useRerender } from "../hooks"; 9 | // This has to be a direct import to avoid the circular dependency 10 | import { ThemeSettingsModalRoot } from "./Modals/ThemeSettingsModal"; 11 | import { installTheme } from "../api"; 12 | import { toggleTheme } from "../backend/backendHelpers/toggleTheme"; 13 | 14 | export const ThemeToggle: VFC<{ 15 | data: Theme; 16 | collapsible?: boolean; 17 | showModalButtonPrompt?: boolean; 18 | isFullscreen?: boolean; 19 | }> = ({ data, collapsible = false, showModalButtonPrompt = false, isFullscreen = false }) => { 20 | const { updateStatuses, setGlobalState, isInstalling } = useCssLoaderState(); 21 | const [collapsed, setCollapsed] = useState(true); 22 | 23 | const [render, rerender] = useRerender(); 24 | 25 | const isPreset = useMemo(() => { 26 | if (data.flags.includes(Flags.isPreset)) { 27 | return true; 28 | } 29 | return false; 30 | // This might not actually memoize it as data.flags is an array, so idk if it deep checks the values here 31 | }, [data.flags]); 32 | 33 | let [updateStatus]: [LocalThemeStatus] = ["installed"]; 34 | const themeArrPlace = updateStatuses.find((f) => f[0] === data.id); 35 | if (themeArrPlace) { 36 | updateStatus = themeArrPlace[1]; 37 | } 38 | 39 | // I extracted these here as doing conditional props inline sucks 40 | const modalButtonProps = showModalButtonPrompt 41 | ? { 42 | onOptionsActionDescription: "Expand Settings", 43 | onOptionsButton: () => { 44 | showModal( 45 | // @ts-ignore 46 | 47 | ); 48 | }, 49 | } 50 | : {}; 51 | 52 | const updateButtonProps = 53 | updateStatus === "outdated" 54 | ? { 55 | onSecondaryButton: async () => { 56 | await installTheme(data.id); 57 | // This just updates the updateStatuses arr to know that this theme now is up to date, no need to re-fetch the API to know that 58 | setGlobalState( 59 | "updateStatuses", 60 | updateStatuses.map((e) => (e[0] === data.id ? [data.id, "installed", false] : e)) 61 | ); 62 | }, 63 | onSecondaryActionDescription: "Update Theme", 64 | } 65 | : {}; 66 | 67 | return ( 68 | <> 69 | {render && ( 70 | <> 71 | 72 | 78 | {updateStatus === "outdated" && ( 79 |
93 | )} 94 | 0 ? "none" : "standard"} 97 | checked={data.enabled} 98 | label={data.display_name} 99 | description={ 100 | isPreset 101 | ? `Preset` 102 | : `${updateStatus === "outdated" ? "Update Available" : data.version} | ${ 103 | data.author 104 | }` 105 | } 106 | onChange={async (switchValue: boolean) => { 107 | toggleTheme(data, switchValue, rerender, setCollapsed); 108 | }} 109 | /> 110 |
111 |
112 | {data.enabled && data.patches.length > 0 && ( 113 | <> 114 | {collapsible && ( 115 |
116 | 117 | setCollapsed(!collapsed)} 121 | > 122 | {collapsed ? ( 123 | 126 | ) : ( 127 | 130 | )} 131 | 132 | 133 |
134 | )} 135 | {!collapsible || !collapsed 136 | ? data.patches.map((x, i, arr) => ( 137 | 138 | )) 139 | : null} 140 | 141 | )} 142 | 143 | )} 144 | 145 | ); 146 | }; 147 | -------------------------------------------------------------------------------- /css_themepatchcomponent.py: -------------------------------------------------------------------------------- 1 | from css_inject import Inject, to_inject 2 | from css_utils import Result, get_theme_path 3 | from os.path import join, exists 4 | 5 | def hex_to_rgb(hex_num : str) -> tuple[float, float, float]: 6 | vals = hex_num[1:] 7 | 8 | if len(vals) < 6: 9 | return (int(vals[0], 16), int(vals[1], 16), int(vals[2], 16)) 10 | else: 11 | return (int(vals[0:2], 16), int(vals[2:4], 16), int(vals[4:6], 16)) 12 | 13 | def get_value_from_masks(m1 : float, m2 : float, hue : float) -> int: 14 | ONE_SIXTH = 1.0/6.0 15 | TWO_THIRD = 2.0/3.0 16 | 17 | hue = hue % 1.0 18 | 19 | if hue < ONE_SIXTH: 20 | return m1 + (m2-m1)*hue*6.0 21 | 22 | if hue < 0.5: 23 | return m2 24 | 25 | if hue < TWO_THIRD: 26 | return m1 + (m2-m1)*(TWO_THIRD-hue)*6.0 27 | 28 | return m1 29 | 30 | def hsl_to_rgb(hue : int, saturation : int, lightness : int) -> tuple[int, int, int]: 31 | ONE_THIRD = 1.0/3.0 32 | 33 | h = float(hue) / 360.0 34 | l = float(lightness) / 100.0 35 | s = float(saturation) / 100.0 36 | 37 | if s == 0.0: 38 | return (int(l * 100.0), int(l * 100.0), int(l * 100.0)) 39 | 40 | m2 = l * (1.0 + s) if l <= 0.5 else l + s - (l * s) 41 | m1 = 2.0 * l - m2 42 | 43 | return ( 44 | int(get_value_from_masks(m1, m2, h + ONE_THIRD) * 255.0), 45 | int(get_value_from_masks(m1, m2, h) * 255.0), 46 | int(get_value_from_masks(m1, m2, h - ONE_THIRD) * 255.0) 47 | ) 48 | 49 | class ThemePatchComponent: 50 | def __init__(self, themePatch, component : dict): 51 | self.themePatch = themePatch 52 | 53 | # Intentionally not doing error checking here. This should error when loaded incorrectly 54 | self.name = component["name"] 55 | self.type = component["type"] 56 | 57 | if self.type not in ["color-picker", "image-picker"]: 58 | raise Exception(f"Unknown component type '{self.type}'") 59 | 60 | self.default = component["default"] 61 | 62 | if self.type == "color-picker": 63 | try: 64 | self.check_value_color_picker(self.default) 65 | except Exception as e: 66 | Result(False, str(e)) 67 | self.default = "#FFFFFF" 68 | elif self.type == "image-picker": 69 | self.check_path_image_picker(self.default) 70 | 71 | self.value = self.default 72 | self.on = component["on"] 73 | self.css_variable = component["css_variable"] 74 | 75 | if not self.css_variable.startswith("--"): 76 | self.css_variable = f"--{self.css_variable}" 77 | 78 | self.tabs = component["tabs"] 79 | self.inject = to_inject("", self.tabs, "", self.themePatch.theme) 80 | self.generate() 81 | 82 | def check_value_color_picker(self, value : str): 83 | '''Expected: #0123456''' 84 | if value[0] != "#": 85 | raise Exception("Color picker default is not a valid hex value") 86 | 87 | if len(value) not in [4,5,7,9]: 88 | raise Exception("Color picker default is not a valid hex value") 89 | 90 | for x in value[1:]: 91 | if x not in "1234567890ABCDEFabcdef": 92 | raise Exception("Color picker default is not a valid hex value") 93 | 94 | def check_path_image_picker(self, path : str): 95 | themePath = get_theme_path() 96 | if path.strip().startswith("/"): 97 | raise Exception(f"Image Picker path cannot be absolute") 98 | 99 | for x in [x.strip() for x in path.split("/")]: 100 | if (x == ".."): 101 | raise Exception("Going back in a relative path is not allowed") 102 | 103 | if not exists(join(themePath, path)): 104 | raise Exception("Image Picker specified image does not exist") 105 | 106 | def generate(self) -> Result: 107 | if (";" in self.css_variable or ";" in self.value): 108 | return Result(False, "???") 109 | 110 | if self.type == "color-picker": 111 | try: 112 | if self.value[0] == "#": # Try to parse as hex value 113 | (r, g, b) = hex_to_rgb(self.value) 114 | elif (self.value.startswith("hsla(") or self.value.startswith("hsl(")) and self.value.endswith(")"): # Try to parse as hsl(a) value 115 | hsl_vals = self.value[self.value.find("(") + 1:-1].split(",") 116 | # Start: hsla(39, 100%, 50%, 1) 117 | # .find: Removes hsla(. Result: '39, 100%, 50%, 1)' 118 | # -1: Removes ). Result: '39, 100%, 50%, 1' 119 | # Split Result: '39', ' 100%', ' 50%', ' 1' 120 | 121 | h = hsl_vals[0].strip() 122 | # .strip: Removes any whitespace, just in case 123 | 124 | s = hsl_vals[1].strip()[:-1] 125 | # .strip: Removes any whitespace (' 100%' -> '100%') 126 | # -1: Removes % ('100%' -> '100') 127 | 128 | l = hsl_vals[2].strip()[:-1] 129 | # .strip: Removes any whitespace (' 50%' -> '50%') 130 | # -1: Removes % ('50%' -> '50') 131 | 132 | (r, g, b) = hsl_to_rgb(h, s, l) 133 | else: 134 | raise Exception(f"Unable to parse color-picker value '{self.value}'") 135 | 136 | self.inject.css = f":root {{ {self.css_variable}: {self.value}; {self.css_variable}_r: {r}; {self.css_variable}_g: {g}; {self.css_variable}_b: {b}; {self.css_variable}_rgb: {r}, {g}, {b}; }}" 137 | except Exception as e: 138 | self.inject.css = f":root {{ {self.css_variable}: {self.value}; }}" 139 | elif self.type == "image-picker": 140 | try: 141 | self.check_path_image_picker(self.value) 142 | except Exception as e: 143 | return Result(False, str(e)) 144 | 145 | path = join('/themes_custom/', self.value.replace(' ', '%20').replace('\\', '/')) 146 | self.inject.css = f":root {{ {self.css_variable}: url({path}) }}" 147 | return Result(True) 148 | 149 | async def generate_and_reinject(self) -> Result: 150 | result = self.generate() 151 | if not result.success: 152 | return result 153 | 154 | if (self.inject.enabled): 155 | result = await self.inject.inject() 156 | if not result.success: 157 | return result 158 | 159 | return Result(True) 160 | 161 | def to_dict(self) -> dict: 162 | return { 163 | "name": self.name, 164 | "type": self.type, 165 | "on": self.on, 166 | "value": self.value, 167 | } -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ButtonItem, 3 | definePlugin, 4 | PanelSection, 5 | PanelSectionRow, 6 | ServerAPI, 7 | } from "decky-frontend-lib"; 8 | import { useEffect, useState } from "react"; 9 | import * as python from "./python"; 10 | import * as api from "./api"; 11 | import { RiPaintFill } from "react-icons/ri"; 12 | 13 | import { ThemeManagerRouter } from "./pages/theme-manager"; 14 | import { CssLoaderContextProvider, CssLoaderState, useCssLoaderState } from "./state"; 15 | import { MOTDDisplay, PresetSelectionDropdown, QAMThemeToggleList, TitleView } from "./components"; 16 | import { ExpandedViewPage } from "./pages/theme-manager/ExpandedView"; 17 | import { Flags, Theme } from "./ThemeTypes"; 18 | import { dummyFunction, getInstalledThemes, reloadBackend } from "./python"; 19 | import { bulkThemeUpdateCheck } from "./logic/bulkThemeUpdateCheck"; 20 | import { disableNavPatch, enableNavPatch } from "./deckyPatches/NavPatch"; 21 | import { SettingsPageRouter } from "./pages/settings/SettingsPageRouter"; 22 | import { disableUnminifyMode } from "./deckyPatches/UnminifyMode"; 23 | 24 | function Content() { 25 | const { localThemeList, setGlobalState } = useCssLoaderState(); 26 | 27 | const [dummyFuncResult, setDummyResult] = useState(false); 28 | 29 | function dummyFuncTest() { 30 | dummyFunction().then((res) => { 31 | if (res.success) { 32 | setDummyResult(res.result); 33 | return; 34 | } 35 | setDummyResult(false); 36 | }); 37 | } 38 | 39 | function reload() { 40 | reloadBackend(); 41 | dummyFuncTest(); 42 | bulkThemeUpdateCheck().then((data) => setGlobalState("updateStatuses", data)); 43 | } 44 | 45 | useEffect(() => { 46 | setGlobalState( 47 | "selectedPreset", 48 | localThemeList.find((e) => e.flags.includes(Flags.isPreset) && e.enabled) 49 | ); 50 | }, [localThemeList]); 51 | 52 | useEffect(() => { 53 | dummyFuncTest(); 54 | getInstalledThemes(); 55 | }, []); 56 | 57 | return ( 58 | <> 59 | 60 | 61 | {dummyFuncResult ? ( 62 | <> 63 | 87 | {localThemeList.length > 0 && } 88 | 89 | 90 | ) : ( 91 | 92 | 93 | CssLoader failed to initialize, try reloading, and if that doesn't work, try 94 | restarting your deck. 95 | 96 | 97 | )} 98 | 99 | 100 | reload()}> 101 | Refresh 102 | 103 | 104 | 105 | 106 | ); 107 | } 108 | 109 | export default definePlugin((serverApi: ServerAPI) => { 110 | const state: CssLoaderState = new CssLoaderState(); 111 | python.setServer(serverApi); 112 | python.setStateClass(state); 113 | api.setServer(serverApi); 114 | api.setStateClass(state); 115 | 116 | python.resolve(python.getThemes(), async (allThemes: Theme[]) => { 117 | // Set selectedPreset 118 | state.setGlobalState( 119 | "selectedPreset", 120 | allThemes.find((e) => e.flags.includes(Flags.isPreset) && e.enabled) 121 | ); 122 | 123 | // Check for updates, and schedule a check 24 hours from now 124 | bulkThemeUpdateCheck(allThemes).then((data) => { 125 | state.setGlobalState("updateStatuses", data); 126 | }); 127 | python.scheduleCheckForUpdates(); 128 | 129 | // If a user has magically deleted a theme in the unpinnedList and the store wasn't updated, this fixes that 130 | python.resolve(python.storeRead("unpinnedThemes"), (unpinnedJsonStr: string) => { 131 | const unpinnedThemes: string[] = unpinnedJsonStr ? JSON.parse(unpinnedJsonStr) : []; 132 | const allIds = allThemes.map((e) => e.id); 133 | 134 | // If a theme is in the unpinned store but no longer exists, remove it from the unpinned store 135 | let unpinnedClone = [...unpinnedThemes]; 136 | unpinnedThemes.forEach((e) => { 137 | if (!allIds.includes(e)) { 138 | unpinnedClone = unpinnedClone.filter((id) => id !== e); 139 | } 140 | }); 141 | 142 | state.setGlobalState("unpinnedThemes", unpinnedClone); 143 | if (JSON.stringify(unpinnedClone) !== unpinnedJsonStr) { 144 | python.storeWrite("unpinnedThemes", JSON.stringify(unpinnedClone)); 145 | } 146 | }); 147 | }); 148 | 149 | // Api Token 150 | python.resolve(python.storeRead("shortToken"), (token: string) => { 151 | if (token) { 152 | state.setGlobalState("apiShortToken", token); 153 | } 154 | }); 155 | 156 | // Hidden MOTD 157 | python.resolve(python.storeRead("hiddenMotd"), (id: string) => { 158 | if (id) { 159 | state.setGlobalState("hiddenMotd", id); 160 | } 161 | }); 162 | 163 | // Nav Patch 164 | python.resolve(python.storeRead("enableNavPatch"), (value: string) => { 165 | if (value === "true") { 166 | enableNavPatch(); 167 | } 168 | }); 169 | 170 | serverApi.routerHook.addRoute("/cssloader/theme-manager", () => ( 171 | 172 | 173 | 174 | )); 175 | 176 | serverApi.routerHook.addRoute("/cssloader/settings", () => ( 177 | 178 | 179 | 180 | )); 181 | 182 | serverApi.routerHook.addRoute("/cssloader/expanded-view", () => ( 183 | 184 | 185 | 186 | )); 187 | 188 | return { 189 | titleView: ( 190 | 191 | 192 | 193 | ), 194 | title:
CSSLoader
, 195 | alwaysRender: true, 196 | content: ( 197 | 198 | 199 | 200 | ), 201 | icon: , 202 | onDismount: () => { 203 | const { updateCheckTimeout } = state.getPublicState(); 204 | if (updateCheckTimeout) clearTimeout(updateCheckTimeout); 205 | disableUnminifyMode(); 206 | disableNavPatch(); 207 | }, 208 | }; 209 | }); 210 | -------------------------------------------------------------------------------- /css_inject.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | import os 4 | import aiohttp 5 | import asyncio 6 | from typing import List 7 | from css_utils import Result, Log, store_read, get_theme_path 8 | from css_browserhook import BrowserTabHook as CssTab, inject, remove 9 | 10 | CLASS_MAPPINGS = {} 11 | 12 | def initialize_class_mappings(): 13 | css_translations_path = os.path.join(get_theme_path(), "css_translations.json") 14 | 15 | if not os.path.exists(css_translations_path): 16 | Log("Failed to get css translations from local file") 17 | return 18 | 19 | try: 20 | with open(css_translations_path, "r", encoding="utf-8") as fp: 21 | data : dict = json.load(fp) 22 | except Exception as e: 23 | Log(f"Failed to load css translations from local file: {str(e)}") 24 | return 25 | 26 | CLASS_MAPPINGS.clear() 27 | 28 | # Data is in the format of { "uid": ["ver1", "ver2", "ver3"]} 29 | for uid in data: 30 | latest_value = data[uid][-1] 31 | for y in data[uid][:-1]: 32 | CLASS_MAPPINGS[y] = latest_value 33 | 34 | Log(f"Loaded {len(CLASS_MAPPINGS)} css translations from local file") 35 | 36 | ALL_INJECTS = [] 37 | 38 | def helper_get_tab_from_list(tab_list : List[str], cssTab : CssTab) -> str|None: 39 | for x in tab_list: 40 | if cssTab.compare(x): 41 | return x 42 | 43 | return None 44 | 45 | class Inject: 46 | def __init__(self, cssPath : str, tabs : List[str], theme): 47 | self.css = None 48 | self.cssPath = cssPath 49 | self.tabs = tabs 50 | self.uuids = {} 51 | self.theme = theme 52 | self.enabled = False 53 | for x in self.tabs: 54 | self.uuids[x] = [] 55 | 56 | async def load(self) -> Result: 57 | try: 58 | with open(self.cssPath, "r") as fp: 59 | self.css = fp.read() 60 | 61 | split_css = re.split(r"(\.[_a-zA-Z]+[_a-zA-Z0-9-]*)", self.css) 62 | 63 | for x in range(len(split_css)): 64 | if split_css[x].startswith(".") and split_css[x][1:] in CLASS_MAPPINGS: 65 | split_css[x] = "." + CLASS_MAPPINGS[split_css[x][1:]] 66 | 67 | self.css = ("".join(split_css)).replace("\\", "\\\\").replace("`", "\\`") 68 | 69 | split_css = re.split(r"(\[class[*^|~]=\"[_a-zA-Z0-9-]*\"\])", self.css) 70 | 71 | for x in range(len(split_css)): 72 | if split_css[x].startswith("[class") and split_css[x].endswith("\"]") and split_css[x][9:-2] in CLASS_MAPPINGS: 73 | split_css[x] = split_css[x][0:9] + CLASS_MAPPINGS[split_css[x][9:-2]] + split_css[x][-2:] 74 | 75 | self.css = ("".join(split_css)).replace("\\", "\\\\").replace("`", "\\`") 76 | Log(f"Loaded css at {self.cssPath}") 77 | 78 | return Result(True) 79 | except Exception as e: 80 | return Result(False, str(e)) 81 | 82 | async def inject(self) -> Result: 83 | for tab_name in self.tabs: 84 | for uuid in self.uuids[tab_name]: 85 | res = await remove(tab_name, uuid) 86 | 87 | if (self.css is None): 88 | result = await self.load() 89 | if not result.success: 90 | return result 91 | 92 | try: 93 | res = await inject(tab_name, self.css) 94 | if not res.success: 95 | return res 96 | 97 | # Log(f"+{str(res.message)} @ {tab_name}") 98 | self.uuids[tab_name].append(str(res.message)) 99 | except Exception as e: 100 | return Result(False, str(e)) 101 | 102 | self.enabled = True 103 | return Result(True) 104 | 105 | async def inject_with_tab(self, tab : CssTab) -> Result: 106 | tab_name = helper_get_tab_from_list(self.tabs, tab) 107 | 108 | if tab_name == None: 109 | return 110 | 111 | if (self.css is None): 112 | result = await self.load() 113 | if not result.success: 114 | return result 115 | 116 | try: 117 | res = await tab.inject_css(self.css) 118 | if not res.success: 119 | return res 120 | 121 | # Log(f"+{str(res.message)} @ {tab_name}") 122 | self.uuids[tab_name].append(str(res.message)) 123 | except Exception as e: 124 | return Result(False, str(e)) 125 | 126 | return Result(True) 127 | 128 | async def remove(self) -> Result: 129 | for tab_name in self.tabs: 130 | if (len(self.uuids[tab_name]) <= 0): 131 | return Result(True) # this is kind of cheating but 132 | 133 | try: 134 | for x in self.uuids[tab_name]: 135 | res = await remove(tab_name, x) 136 | #if not res["success"]: 137 | # return Result(False, res["result"]) 138 | # Silently ignore error. If any page gets reloaded, and there was css loaded. this will fail as it will fail to remove the css 139 | 140 | self.uuids[tab_name] = [] 141 | except Exception as e: 142 | return Result(False, str(e)) 143 | 144 | self.enabled = False 145 | return Result(True) 146 | 147 | DEFAULT_MAPPINGS = { 148 | "desktop": ["Steam|SteamLibraryWindow"], 149 | "desktopchat": ["!friendsui-container"], 150 | "desktoppopup": ["OverlayBrowser_Browser", "SP Overlay:.*", "notificationtoasts_.*", "SteamBrowser_Find", "OverlayTab\\d+_Find", "!ModalDialogPopup", "!FullModalOverlay"], 151 | "desktopoverlay": ["desktoppopup"], 152 | "desktopcontextmenu": [".*Menu", ".*Supernav"], 153 | "bigpicture": ["~Valve Steam Gamepad/default~", "~Valve%20Steam%20Gamepad~"], 154 | "bigpictureoverlay": ["QuickAccess", "MainMenu"], 155 | "store": ["~https://store.steampowered.com~", "~https://steamcommunity.com~"], 156 | 157 | # Legacy 158 | "SP": ["bigpicture"], 159 | "Steam Big Picture Mode": ["bigpicture"], 160 | "MainMenu": ["MainMenu.*"], 161 | "MainMenu_.*": ["MainMenu"], 162 | "QuickAccess": ["QuickAccess.*"], 163 | "QuickAccess_.*": ["QuickAccess"], 164 | "Steam": ["desktop"], 165 | "SteamLibraryWindow": ["desktop"], 166 | "All": ["bigpicture", "bigpictureoverlay"] 167 | } 168 | 169 | def extend_tabs(tabs : list, theme) -> list: 170 | new_tabs = [] 171 | 172 | if len(tabs) <= 0: 173 | return extend_tabs(theme.tab_mappings["default"], theme) if ("default" in theme.tab_mappings) else [] 174 | 175 | for x in tabs: 176 | if x in theme.tab_mappings: 177 | new_tabs.extend(extend_tabs(theme.tab_mappings[x], theme)) 178 | elif x in DEFAULT_MAPPINGS: 179 | new_tabs.extend(extend_tabs(DEFAULT_MAPPINGS[x], theme)) 180 | else: 181 | new_tabs.append(x) 182 | 183 | return new_tabs 184 | 185 | def to_inject(key : str, tabs : list, basePath : str, theme) -> Inject: 186 | if key.startswith("--"): 187 | value = tabs[0] 188 | inject = Inject("", extend_tabs(tabs[1:], theme), theme) 189 | inject.css = f":root {{ {key}: {value}; }}" 190 | else: 191 | inject = Inject(basePath + "/" + key, extend_tabs(tabs, theme), theme) 192 | 193 | return inject 194 | 195 | def to_injects(items : dict, basePath : str, theme) -> list: 196 | return [to_inject(x, items[x], basePath, theme) for x in items] -------------------------------------------------------------------------------- /css_theme.py: -------------------------------------------------------------------------------- 1 | import os, json, shutil, time 2 | from os import path 3 | from typing import List 4 | from css_inject import Inject, to_injects 5 | from css_utils import Result, Log, create_dir, USER 6 | from css_themepatch import ThemePatch 7 | from css_sfp_compat import is_folder_sfp_theme, convert_to_css_theme 8 | 9 | CSS_LOADER_VER = 9 10 | 11 | class Theme: 12 | def __init__(self, themePath : str, json : dict, configPath : str = None): 13 | self.configPath = configPath if (configPath is not None) else themePath 14 | self.display_name = None 15 | self.configJsonPath = self.configPath + "/config" + ("_ROOT.json" if USER == "root" else "_USER.json") 16 | self.patches = [] 17 | self.injects = [] 18 | self.tab_mappings = {} 19 | self.flags = [] 20 | self.themePath = themePath 21 | self.bundled = self.configPath != self.themePath 22 | self.enabled = False 23 | self.json = json 24 | self.priority_mod = 0 25 | self.created = None 26 | self.modified = path.getmtime(self.configJsonPath) if path.exists(self.configJsonPath) else None 27 | 28 | try: 29 | if os.path.exists(os.path.join(themePath, "PRIORITY")): 30 | with open(os.path.join(themePath, "PRIORITY")) as fp: 31 | self.priority_mod = int(fp.readline().strip()) 32 | except: 33 | pass 34 | 35 | if (json is None): 36 | if os.path.exists(os.path.join(themePath, "theme.css")): 37 | self.name = os.path.basename(themePath) 38 | self.id = self.name 39 | self.version = "v1.0" 40 | self.author = "" 41 | self.require = 1 42 | self.injects = [Inject(os.path.join(themePath, "theme.css"), [".*"], self)] 43 | self.dependencies = [] 44 | return 45 | elif is_folder_sfp_theme(themePath): 46 | convert_to_css_theme(themePath, self) 47 | return 48 | else: 49 | raise Exception("Folder does not look like a theme?") 50 | 51 | 52 | jsonPath = path.join(self.themePath, "theme.json") 53 | 54 | if path.exists(jsonPath): 55 | self.created = path.getmtime(jsonPath) 56 | 57 | self.name = json["name"] 58 | self.display_name = json["display_name"] if ("display_name" in json) else None 59 | self.id = json["id"] if ("id" in json) else self.name 60 | self.version = json["version"] if ("version" in json) else "v1.0" 61 | self.author = json["author"] if ("author" in json) else "" 62 | self.require = int(json["manifest_version"]) if ("manifest_version" in json) else 1 63 | self.flags = [x.upper() for x in list(json["flags"])] if ("flags" in json) else [] 64 | self.tab_mappings = json["tabs"] if ("tabs" in json) else {} 65 | 66 | if (CSS_LOADER_VER < self.require): 67 | raise Exception(f"A newer version of the CssLoader is required to load this theme (Read manifest version {self.require} but only up to {CSS_LOADER_VER} is supported)") 68 | 69 | self.dependencies = json["dependencies"] if "dependencies" in json else {} 70 | 71 | if "inject" in self.json: 72 | self.injects = to_injects(self.json["inject"], self.themePath, self) 73 | 74 | if "patches" in self.json: 75 | self.patches = [ThemePatch(self, self.json["patches"][x], x) for x in self.json["patches"]] 76 | 77 | async def load(self, inject_now : bool = True) -> Result: 78 | if not path.exists(self.configJsonPath): 79 | return Result(True) 80 | 81 | try: 82 | with open(self.configJsonPath, "r") as fp: 83 | config = json.load(fp) 84 | except Exception as e: 85 | return Result(False, str(e)) 86 | 87 | activate = False 88 | 89 | for x in config: 90 | if x == "active" and config["active"]: 91 | activate = True 92 | else: 93 | for y in self.patches: 94 | if y.name == x: 95 | y.set_value(config[x]) 96 | 97 | if activate: 98 | result = await self.inject(inject_now) 99 | if not result.success: 100 | return result 101 | 102 | return Result(True) 103 | 104 | async def save(self) -> Result: 105 | create_dir(self.configPath) 106 | 107 | try: 108 | config = {"active": self.enabled} 109 | for x in self.patches: 110 | config[x.name] = x.get_value() 111 | 112 | with open(self.configJsonPath, "w") as fp: 113 | json.dump(config, fp) 114 | 115 | self.modified = time.time() 116 | except Exception as e: 117 | return Result(False, str(e)) 118 | 119 | return Result(True) 120 | 121 | async def inject(self, inject_now : bool = True) -> Result: 122 | Log(f"Injecting theme '{self.name}'") 123 | for x in self.injects: 124 | if inject_now: 125 | await x.inject() # Ignore result for now. It'll be logged but not acted upon 126 | else: 127 | x.enabled = True 128 | 129 | for x in self.patches: 130 | result = await x.inject(inject_now) 131 | if not result.success: 132 | return result 133 | 134 | self.enabled = True 135 | await self.save() 136 | return Result(True) 137 | 138 | async def remove(self) -> Result: 139 | Log(f"Removing theme '{self.name}'") 140 | for x in self.get_all_injects(): 141 | result = await x.remove() 142 | if not result.success: 143 | return result 144 | 145 | self.enabled = False 146 | await self.save() 147 | return Result(True) 148 | 149 | async def delete(self) -> Result: 150 | if (self.bundled): 151 | return Result(False, "Can't delete a bundled theme") 152 | 153 | result = await self.remove() 154 | if not result.success: 155 | return result 156 | 157 | try: 158 | shutil.rmtree(self.themePath) 159 | except Exception as e: 160 | return Result(False, str(e)) 161 | 162 | return Result(True) 163 | 164 | def get_all_injects(self) -> List[Inject]: 165 | injects = [] 166 | injects.extend(self.injects) 167 | for x in self.patches: 168 | injects.extend(x.injects) 169 | 170 | return injects 171 | 172 | def to_dict(self) -> dict: 173 | return { 174 | "id": self.id, 175 | "name": self.name, 176 | "display_name": self.get_display_name(), 177 | "version": self.version, 178 | "author": self.author, 179 | "enabled": self.enabled, 180 | "patches": [x.to_dict() for x in self.patches], 181 | "bundled": self.bundled, 182 | "require": self.require, 183 | "dependencies": [x for x in self.dependencies], 184 | "flags": self.flags, 185 | "created": self.created, 186 | "modified": self.modified, 187 | } 188 | 189 | def get_display_name(self) -> str: 190 | return self.display_name if (self.display_name is not None) else self.name 191 | 192 | def add_prefix(self, id : int): 193 | if self.display_name is None: 194 | self.display_name = self.name 195 | 196 | self.name += f"_{id}" -------------------------------------------------------------------------------- /src/components/ThemeManager/BrowserSearchFields.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DialogButton, 3 | Dropdown, 4 | DropdownOption, 5 | Focusable, 6 | gamepadDialogClasses, 7 | gamepadSliderClasses, 8 | PanelSectionRow, 9 | SliderField, 10 | TextField, 11 | } from "decky-frontend-lib"; 12 | import { useEffect, useMemo, memo } from "react"; 13 | import { TiRefreshOutline } from "react-icons/ti"; 14 | import { FaRotate } from "react-icons/fa6"; 15 | import { ThemeQueryRequest } from "../../apiTypes"; 16 | import { genericGET } from "../../api"; 17 | import { useCssLoaderState } from "../../state"; 18 | import { FilterDropdownCustomLabel } from "./FilterDropdownCustomLabel"; 19 | 20 | export function BrowserSearchFields({ 21 | searchOpts, 22 | searchOptsVarName, 23 | prevSearchOptsVarName, 24 | unformattedFilters, 25 | unformattedFiltersVarName, 26 | onReload, 27 | requiresAuth = false, 28 | getTargetsPath, 29 | }: { 30 | searchOpts: ThemeQueryRequest; 31 | searchOptsVarName: string; 32 | prevSearchOptsVarName: string; 33 | unformattedFilters: { filters: string[]; order: string[] }; 34 | unformattedFiltersVarName: string; 35 | getTargetsPath: string; 36 | requiresAuth?: boolean; 37 | onReload: () => void; 38 | }) { 39 | const { browserCardSize, setGlobalState } = useCssLoaderState(); 40 | 41 | async function getThemeTargets() { 42 | genericGET(`${getTargetsPath}`, requiresAuth).then((data) => { 43 | if (data?.filters) { 44 | setGlobalState(unformattedFiltersVarName, { 45 | filters: data.filters, 46 | order: data.order, 47 | }); 48 | } 49 | }); 50 | } 51 | 52 | const formattedFilters = useMemo<{ filters: DropdownOption[]; order: DropdownOption[] }>( 53 | () => ({ 54 | filters: [ 55 | { 56 | data: "All", 57 | label: ( 58 | prev + Number(cur), 63 | 0 64 | ) || "" 65 | } 66 | /> 67 | ), 68 | }, 69 | ...Object.entries(unformattedFilters.filters) 70 | .filter(([_, itemCount]) => Number(itemCount) > 0) 71 | .map(([filterValue, itemCount]) => ({ 72 | data: filterValue, 73 | label: , 74 | })), 75 | ], 76 | order: unformattedFilters.order.map((e) => ({ data: e, label: e })), 77 | }), 78 | [unformattedFilters] 79 | ); 80 | useEffect(() => { 81 | if (unformattedFilters.filters.length < 2) { 82 | getThemeTargets(); 83 | } 84 | }, []); 85 | 86 | const repoOptions: never[] = []; 87 | return ( 88 | <> 89 | 90 | 91 |
99 | Sort 100 | { 106 | setGlobalState(prevSearchOptsVarName, searchOpts); 107 | setGlobalState(searchOptsVarName, { ...searchOpts, order: e.data }); 108 | }} 109 | /> 110 |
111 |
121 | Filter 122 | { 128 | setGlobalState(prevSearchOptsVarName, searchOpts); 129 | setGlobalState(searchOptsVarName, { ...searchOpts, filters: e.data }); 130 | }} 131 | /> 132 | 145 |
146 |
147 |
148 |
149 | 150 |
151 | { 155 | setGlobalState(prevSearchOptsVarName, searchOpts); 156 | setGlobalState(searchOptsVarName, { ...searchOpts, search: e.target.value }); 157 | }} 158 | /> 159 |
160 | 171 | 172 | Refresh 173 | 174 |
178 | { 184 | setGlobalState("browserCardSize", num); 185 | }} 186 | /> 187 |
188 | 212 |
213 |
214 | 215 | ); 216 | } 217 | 218 | export const MemoizedSearchFields = memo(BrowserSearchFields); 219 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import { ServerAPI } from "decky-frontend-lib"; 2 | import { CssLoaderState } from "./state"; 3 | import { toast, storeWrite, downloadThemeFromUrl, reloadBackend } from "./python"; 4 | import { ThemeQueryRequest } from "./apiTypes"; 5 | import { generateParamStr } from "./logic"; 6 | 7 | var server: ServerAPI | undefined = undefined; 8 | var globalState: CssLoaderState | undefined = undefined; 9 | 10 | export function setServer(s: ServerAPI): void { 11 | server = s; 12 | } 13 | export function setStateClass(s: CssLoaderState): void { 14 | globalState = s; 15 | } 16 | 17 | export function logOut(): void { 18 | const setGlobalState = globalState!.setGlobalState.bind(globalState); 19 | setGlobalState("apiShortToken", ""); 20 | setGlobalState("apiFullToken", ""); 21 | setGlobalState("apiTokenExpireDate", undefined); 22 | setGlobalState("apiMeData", undefined); 23 | storeWrite("shortToken", ""); 24 | } 25 | 26 | export async function logInWithShortToken( 27 | shortTokenInterimValue?: string | undefined 28 | ): Promise { 29 | const { apiUrl, apiShortToken } = globalState!.getPublicState(); 30 | const shortTokenValue = shortTokenInterimValue ? shortTokenInterimValue : apiShortToken; 31 | const setGlobalState = globalState!.setGlobalState.bind(globalState); 32 | if (shortTokenValue.length === 12) { 33 | return server! 34 | .fetchNoCors(`${apiUrl}/auth/authenticate_token`, { 35 | method: "POST", 36 | headers: { "Content-Type": "application/json" }, 37 | body: JSON.stringify({ token: shortTokenValue }), 38 | }) 39 | .then((deckyRes) => { 40 | if (deckyRes.success) { 41 | return deckyRes.result; 42 | } 43 | throw new Error(`Fetch not successful!`); 44 | }) 45 | .then((res) => { 46 | // @ts-ignore 47 | return JSON.parse(res?.body || ""); 48 | }) 49 | .then((json) => { 50 | if (json) { 51 | return json; 52 | } 53 | throw new Error(`No json returned!`); 54 | }) 55 | .then((data) => { 56 | if (data && data?.token) { 57 | storeWrite("shortToken", shortTokenValue); 58 | setGlobalState("apiShortToken", shortTokenValue); 59 | setGlobalState("apiFullToken", data.token); 60 | setGlobalState("apiTokenExpireDate", new Date().valueOf() + 1000 * 60 * 10); 61 | genericGET(`/auth/me`, true, data.token).then((meData) => { 62 | if (meData?.username) { 63 | setGlobalState("apiMeData", meData); 64 | toast("Logged In!", `Logged in as ${meData.username}`); 65 | } 66 | }); 67 | } else { 68 | toast("Error Authenticating", JSON.stringify(data)); 69 | } 70 | }) 71 | .catch((err) => { 72 | console.error(`Error authenticating from short token.`, err); 73 | }); 74 | } else { 75 | toast("Invalid Token", "Token must be 12 characters long."); 76 | } 77 | } 78 | 79 | // This returns the token that is intended to be used in whatever call 80 | export function refreshToken(onError: () => void = () => {}): Promise { 81 | const { apiFullToken, apiTokenExpireDate, apiUrl } = globalState!.getPublicState(); 82 | const setGlobalState = globalState!.setGlobalState.bind(globalState); 83 | if (!apiFullToken) { 84 | return Promise.resolve(undefined); 85 | } 86 | if (apiTokenExpireDate === undefined) { 87 | return Promise.resolve(apiFullToken); 88 | } 89 | // @ts-ignore 90 | if (new Date().valueOf() < apiTokenExpireDate) { 91 | return Promise.resolve(apiFullToken); 92 | } 93 | return server! 94 | .fetchNoCors(`${apiUrl}/auth/refresh_token`, { 95 | method: "POST", 96 | headers: { 97 | Authorization: `Bearer ${apiFullToken}`, 98 | }, 99 | }) 100 | .then((deckyRes) => { 101 | if (deckyRes.success) { 102 | return deckyRes.result; 103 | } 104 | throw new Error(`Fetch not successful!`); 105 | }) 106 | .then((res) => { 107 | if (res.status >= 200 && res.status <= 300 && res.body) { 108 | // @ts-ignore 109 | return JSON.parse(res.body || ""); 110 | } 111 | throw new Error(`Res not OK!, code ${res.status}`); 112 | }) 113 | .then((json) => { 114 | if (json.token) { 115 | return json.token; 116 | } 117 | throw new Error(`No token returned!`); 118 | }) 119 | .then((token) => { 120 | setGlobalState("apiFullToken", token); 121 | setGlobalState("apiTokenExpireDate", new Date().valueOf() + 1000 * 10 * 60); 122 | return token; 123 | }) 124 | .catch((err) => { 125 | console.error(`Error Refreshing Token!`, err); 126 | onError(); 127 | }); 128 | } 129 | 130 | export async function genericGET( 131 | fetchPath: string, 132 | requiresAuth: boolean = false, 133 | customAuthToken: string | undefined = undefined, 134 | onError: () => void = () => {}, 135 | failSilently: boolean = false 136 | ) { 137 | const { apiUrl } = globalState!.getPublicState(); 138 | function doTheFetching(authToken: string | undefined = undefined) { 139 | return server! 140 | .fetchNoCors(`${apiUrl}${fetchPath}`, { 141 | method: "GET", 142 | headers: authToken 143 | ? { 144 | Authorization: `Bearer ${authToken}`, 145 | } 146 | : {}, 147 | }) 148 | .then((deckyRes) => { 149 | if (deckyRes.success) { 150 | return deckyRes.result; 151 | } 152 | throw new Error(`Fetch not successful!`); 153 | }) 154 | .then((res) => { 155 | if (res.status >= 200 && res.status <= 300 && res.body) { 156 | // @ts-ignore 157 | return JSON.parse(res.body || ""); 158 | } 159 | throw new Error(`Res not OK!, code ${res.status}`); 160 | }) 161 | .then((json) => { 162 | if (json) { 163 | return json; 164 | } 165 | throw new Error(`No json returned!`); 166 | }) 167 | .catch((err) => { 168 | if (!failSilently) { 169 | console.error(`Error fetching ${fetchPath}`, err); 170 | } 171 | onError(); 172 | }); 173 | } 174 | if (requiresAuth) { 175 | if (customAuthToken) { 176 | return doTheFetching(customAuthToken); 177 | } 178 | return refreshToken(onError).then((token) => { 179 | if (token) { 180 | return doTheFetching(token); 181 | } else { 182 | toast("Error Refreshing Token!", ""); 183 | return; 184 | } 185 | }); 186 | } else { 187 | return doTheFetching(); 188 | } 189 | } 190 | 191 | export function getThemes( 192 | searchOpts: ThemeQueryRequest, 193 | apiPath: string, 194 | globalStateVarName: string, 195 | setSnapIndex: (i: number) => void, 196 | requiresAuth: boolean = false 197 | ) { 198 | const setGlobalState = globalState!.setGlobalState.bind(globalState); 199 | // TODO: Refactor, this works now, just jank 200 | const prependString = 201 | // If the user searches for desktop themes, show desktop themes, otherwise only show BPM themes 202 | (searchOpts.filters.includes("Desktop") 203 | ? "-Preset" 204 | : // If the user searches for presets, show presets, otherwise exclude them 205 | searchOpts.filters === "Preset" 206 | ? "BPM-CSS" 207 | : "BPM-CSS.-Preset") + 208 | // If there are other filters after the prepend, add a ".", otherwise don't 209 | (searchOpts.filters !== "All" ? "." : ""); 210 | 211 | const queryStr = generateParamStr( 212 | searchOpts.filters !== "All" ? searchOpts : { ...searchOpts, filters: "" }, 213 | prependString 214 | ); 215 | genericGET(`${apiPath}${queryStr}`, requiresAuth).then((data) => { 216 | if (data.total > 0) { 217 | setGlobalState(globalStateVarName, data); 218 | } else { 219 | setGlobalState(globalStateVarName, { total: 0, items: [] }); 220 | } 221 | setSnapIndex(-1); 222 | }); 223 | } 224 | 225 | export function toggleStar(themeId: string, isStarred: boolean, authToken: string) { 226 | const { apiUrl } = globalState!.getPublicState(); 227 | return server! 228 | .fetchNoCors(`${apiUrl}/users/me/stars/${themeId}`, { 229 | method: isStarred ? "DELETE" : "POST", 230 | headers: { 231 | Authorization: `Bearer ${authToken}`, 232 | }, 233 | }) 234 | .then((deckyRes) => { 235 | if (deckyRes.success) { 236 | return deckyRes.result; 237 | } 238 | throw new Error(`Fetch not successful!`); 239 | }) 240 | .then((res) => { 241 | if (res.status >= 200 && res.status <= 300) { 242 | // @ts-ignore 243 | return true; 244 | } 245 | throw new Error(`Res not OK!, code ${res.status}`); 246 | }) 247 | .catch((err) => { 248 | console.error(`Error starring theme`, err); 249 | }); 250 | } 251 | 252 | export async function installTheme(themeId: string) { 253 | const setGlobalState = globalState!.setGlobalState.bind(globalState); 254 | setGlobalState("isInstalling", true); 255 | await downloadThemeFromUrl(themeId); 256 | await reloadBackend(); 257 | setGlobalState("isInstalling", false); 258 | return; 259 | } 260 | --------------------------------------------------------------------------------