diff --git a/css_browserhook.py b/css_browserhook.py index c945536..eb4615d 100644 --- a/css_browserhook.py +++ b/css_browserhook.py @@ -1,6 +1,6 @@ import os, re, uuid, asyncio, json, aiohttp, time from typing import List -from css_utils import get_theme_path, Log, Result +from css_utils import get_theme_path, Log, Result, PLATFORM_WIN import css_inject MAX_QUEUE_SIZE = 500 @@ -414,7 +414,7 @@ async def health_check(self): while True: await asyncio.sleep(3) try: - async with aiohttp.ClientSession() as web: + async with aiohttp.ClientSession(trust_env=PLATFORM_WIN) as web: res = await web.get(f"http://127.0.0.1:8080/json/version", timeout=3) if (res.status != 200): diff --git a/css_inject.py b/css_inject.py index 6d0b27a..b2d9003 100644 --- a/css_inject.py +++ b/css_inject.py @@ -64,6 +64,14 @@ async def load(self) -> Result: if split_css[x].startswith(".") and split_css[x][1:] in CLASS_MAPPINGS: split_css[x] = "." + CLASS_MAPPINGS[split_css[x][1:]] + self.css = ("".join(split_css)).replace("\\", "\\\\").replace("`", "\\`") + + split_css = re.split(r"(\[class[*^|~]=\"[_a-zA-Z0-9-]*\"\])", self.css) + + for x in range(len(split_css)): + if split_css[x].startswith("[class") and split_css[x].endswith("\"]") and split_css[x][9:-2] in CLASS_MAPPINGS: + split_css[x] = split_css[x][0:9] + CLASS_MAPPINGS[split_css[x][9:-2]] + split_css[x][-2:] + self.css = ("".join(split_css)).replace("\\", "\\\\").replace("`", "\\`") Log(f"Loaded css at {self.cssPath}") @@ -142,7 +150,7 @@ async def remove(self) -> Result: "desktoppopup": ["OverlayBrowser_Browser", "SP Overlay:.*", "notificationtoasts_.*", "SteamBrowser_Find", "OverlayTab\\d+_Find", "!ModalDialogPopup", "!FullModalOverlay"], "desktopoverlay": ["desktoppopup"], "desktopcontextmenu": [".*Menu", ".*Supernav"], - "bigpicture": ["~Valve Steam Gamepad/default~", "~Valve%20Steam%20Gamepad/default~"], + "bigpicture": ["~Valve Steam Gamepad/default~", "~Valve%20Steam%20Gamepad~"], "bigpictureoverlay": ["QuickAccess", "MainMenu"], "store": ["~https://store.steampowered.com~", "~https://steamcommunity.com~"], diff --git a/css_utils.py b/css_utils.py index 59882c6..a5a0fb8 100644 --- a/css_utils.py +++ b/css_utils.py @@ -99,6 +99,20 @@ def get_steam_path() -> str: else: return f"{get_user_home()}/.steam/steam" +def is_steam_beta_active() -> bool: + beta_path = os.path.join(get_steam_path(), "package", "beta") + if not os.path.exists(beta_path): + return False + + with open(beta_path, 'r') as fp: + content = fp.read().strip() + + stable_branches = [ + "steamdeck_stable", + ] + + return content not in stable_branches + def create_steam_symlink() -> Result: return create_symlink(get_theme_path(), os.path.join(get_steam_path(), "steamui", "themes_custom")) diff --git a/main.py b/main.py index 72bd0fc..dec1d1c 100644 --- a/main.py +++ b/main.py @@ -5,7 +5,7 @@ sys.path.append(os.path.dirname(__file__)) -from css_utils import Log, create_steam_symlink, Result, get_theme_path, store_read as util_store_read, store_write as util_store_write, store_or_file_config +from css_utils import Log, create_steam_symlink, Result, get_theme_path, store_read as util_store_read, store_write as util_store_write, store_or_file_config, is_steam_beta_active from css_inject import ALL_INJECTS, initialize_class_mappings from css_theme import CSS_LOADER_VER from css_remoteinstall import install @@ -34,7 +34,13 @@ async def fetch_class_mappings(css_translations_path : str, loader : Loader): return setting = util_store_read("beta_translations") - css_translations_url = "https://api.deckthemes.com/beta.json" if (setting == "1" or setting == "true") else "https://api.deckthemes.com/stable.json" + + if ((len(setting.strip()) <= 0 or setting == "-1" or setting == "auto") and is_steam_beta_active()) or (setting == "1" or setting == "true"): + css_translations_url = "https://api.deckthemes.com/beta.json" + else: + css_translations_url = "https://api.deckthemes.com/stable.json" + + Log(f"Fetching CSS mappings from {css_translations_url}") try: async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(verify_ssl=False), timeout=aiohttp.ClientTimeout(total=2)) as session: @@ -180,6 +186,20 @@ async def get_last_load_errors(self): async def upload_theme(self, name : str, base_url : str, bearer_token : str) -> dict: return (await self.loader.upload_theme(name, base_url, bearer_token)).to_dict() + async def fetch_class_mappings(self): + await self._fetch_class_mappings(self) + return Result(True).to_dict() + + async def _fetch_class_mappings(self, run_in_bg : bool = False): + global SUCCESSFUL_FETCH_THIS_RUN + + SUCCESSFUL_FETCH_THIS_RUN = False + css_translations_path = os.path.join(get_theme_path(), "css_translations.json") + if run_in_bg: + asyncio.get_event_loop().create_task(every(60, fetch_class_mappings, css_translations_path, self.loader)) + else: + await fetch_class_mappings(css_translations_path, self.loader) + async def _main(self): global Initialized if Initialized: @@ -208,8 +228,7 @@ async def _main(self): if (ALWAYS_RUN_SERVER or store_or_file_config("server")): await self.enable_server(self) - css_translations_path = os.path.join(get_theme_path(), "css_translations.json") - asyncio.get_event_loop().create_task(every(60, fetch_class_mappings, css_translations_path, self.loader)) + await self._fetch_class_mappings(self, True) await initialize() if __name__ == '__main__': diff --git a/package.json b/package.json index e868ec8..270bd5d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "SDH-CssLoader", - "version": "2.1.0", + "version": "2.1.1", "description": "A css loader", "scripts": { "build": "shx rm -rf dist && rollup -c", @@ -43,7 +43,7 @@ }, "dependencies": { "color": "^4.2.3", - "decky-frontend-lib": "^3.24.3", + "decky-frontend-lib": "^3.25.0", "lodash": "^4.17.21", "react-icons": "^4.12.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af6d5dc..a7e1df3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ dependencies: specifier: ^4.2.3 version: 4.2.3 decky-frontend-lib: - specifier: ^3.24.3 - version: 3.24.3 + specifier: ^3.25.0 + version: 3.25.0 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -711,8 +711,8 @@ packages: resolution: {integrity: sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==} dev: true - /decky-frontend-lib@3.24.3: - resolution: {integrity: sha512-293oUaAgLrezvoz+TOQkarjwAlVlejkelB1WjtxQV4Y5qMpUZhNUtfpQAscGhwg9oQy6UGpZ5urkdPzLiVY52w==} + /decky-frontend-lib@3.25.0: + resolution: {integrity: sha512-2lBoHS2AIRmuluq/bGdHBz+uyToQE7k3K/vDq1MQbDZ4eC+8CGDuh2T8yZOj3D0yjGP2MdikNNAWPA9Z5l2qDg==} dev: false /decode-uri-component@0.2.2: diff --git a/src/backend/pythonMethods/pluginSettingsMethods.ts b/src/backend/pythonMethods/pluginSettingsMethods.ts index 4663694..20f35bf 100644 --- a/src/backend/pythonMethods/pluginSettingsMethods.ts +++ b/src/backend/pythonMethods/pluginSettingsMethods.ts @@ -1,6 +1,6 @@ -import { toast } from "../../python"; +import { storeRead, toast } from "../../python"; import { server, globalState } from "../pythonRoot"; -import { booleanStoreRead } from "./storeUtils"; +import { booleanStoreRead, stringStoreRead } from "./storeUtils"; export function enableServer() { return server!.callPluginMethod("enable_server", {}); @@ -25,7 +25,7 @@ export async function getWatchState() { } export async function getBetaTranslationsState() { - return booleanStoreRead("beta_translations"); + return stringStoreRead("beta_translations"); } export function toggleWatchState(bool: boolean, onlyThisSession: boolean = false) { @@ -50,3 +50,7 @@ export function getHiddenMotd() { key: "hiddenMotd", }); } + +export function fetchClassMappings() { + return server!.callPluginMethod<{}>("fetch_class_mappings", {}); +} diff --git a/src/backend/pythonMethods/storeUtils.ts b/src/backend/pythonMethods/storeUtils.ts index a5789a0..585e75a 100644 --- a/src/backend/pythonMethods/storeUtils.ts +++ b/src/backend/pythonMethods/storeUtils.ts @@ -16,7 +16,27 @@ export async function booleanStoreWrite(key: string, value: boolean) { key, val: value ? "1" : "0", }); + if (!deckyRes.success) { + toast(`Error setting ${key}`, deckyRes.result); + } +} + +export async function stringStoreRead(key: string) { + const deckyRes = await server!.callPluginMethod<{ key: string }, string>("store_read", { + key, + }); if (!deckyRes.success) { toast(`Error fetching ${key}`, deckyRes.result); + return ""; + } + return deckyRes.result; +} +export async function stringStoreWrite(key: string, value: string) { + const deckyRes = await server!.callPluginMethod<{ key: string; val: string }>("store_write", { + key, + val: value, + }); + if (!deckyRes.success) { + toast(`Error setting ${key}`, deckyRes.result); } } diff --git a/src/deckyPatches/ClassHashMap.tsx b/src/deckyPatches/ClassHashMap.tsx new file mode 100644 index 0000000..156e395 --- /dev/null +++ b/src/deckyPatches/ClassHashMap.tsx @@ -0,0 +1,47 @@ +import { classMap } from "decky-frontend-lib"; + +export var classHashMap = new Map(); + +export function initializeClassHashMap() { + const withoutLocalizationClasses = classMap.filter((module) => Object.keys(module).length < 1000); + + const allClasses = withoutLocalizationClasses + .map((module) => { + let filteredModule = {}; + Object.entries(module).forEach(([propertyName, value]) => { + // Filter out things that start with a number (eg: Breakpoints like 800px) + // I have confirmed the new classes don't start with numbers + if (isNaN(Number(value.charAt(0)))) { + filteredModule[propertyName] = value; + } + }); + return filteredModule; + }) + .filter((module) => { + // Some modules will be empty after the filtering, remove those + return Object.keys(module).length > 0; + }); + + const mappings = allClasses.reduce((acc, cur) => { + Object.entries(cur).forEach(([property, value]) => { + if (acc[property]) { + acc[property].push(value); + } else { + acc[property] = [value]; + } + }); + return acc; + }, {}); + + const hashMapNoDupes = Object.entries(mappings).reduce>( + (acc, entry) => { + if (entry[1].length === 1) { + acc.set(entry[1][0], entry[0]); + } + return acc; + }, + new Map() + ); + + classHashMap = hashMapNoDupes; +} diff --git a/src/deckyPatches/SteamTabElementsFinder.tsx b/src/deckyPatches/SteamTabElementsFinder.tsx new file mode 100644 index 0000000..3bdeda0 --- /dev/null +++ b/src/deckyPatches/SteamTabElementsFinder.tsx @@ -0,0 +1,21 @@ +import { getGamepadNavigationTrees } from "decky-frontend-lib"; + +export function getElementFromNavID(navID: string) { + const all = getGamepadNavigationTrees(); + if (!all) return null; + const tree = all?.find((e: any) => e.m_ID == navID); + if (!tree) return null; + return tree.Root.Element; +} +export function getSP() { + return getElementFromNavID("root_1_"); +} +export function getQAM() { + return getElementFromNavID("QuickAccess-NA"); +} +export function getMainMenu() { + return getElementFromNavID("MainNavMenuContainer"); +} +export function getRootElements() { + return [getSP(), getQAM(), getMainMenu()].filter((e) => e); +} diff --git a/src/deckyPatches/UnminifyMode.tsx b/src/deckyPatches/UnminifyMode.tsx new file mode 100644 index 0000000..d104ead --- /dev/null +++ b/src/deckyPatches/UnminifyMode.tsx @@ -0,0 +1,63 @@ +import { classHashMap, initializeClassHashMap } from "./ClassHashMap"; +import { getRootElements } from "./SteamTabElementsFinder"; + +export function unminifyElement(element: Element) { + if (element.classList.length === 0) return; + + const classList = Array.from(element.classList); + const unminifiedClassList = classList.map((c) => classHashMap.get(c) || c); + element.setAttribute("unminified-class", unminifiedClassList.join(" ")); +} + +export function recursivelyUnminifyElement(element: Element) { + unminifyElement(element); + Array.from(element.children).forEach(recursivelyUnminifyElement); +} + +export function initialUnminification(rootElement: any) { + const allElements = rootElement.ownerDocument.all as HTMLAllCollection; + Array.from(allElements).forEach(unminifyElement); +} + +var mutationObservers: MutationObserver[] = []; + +export function disconnectMutationObservers() { + mutationObservers.forEach((observer) => observer.disconnect()); + mutationObservers = []; +} + +export function mutationObserverCallback(mutations: MutationRecord[]) { + mutations.forEach((mutation) => { + if (mutation.type === "childList" && mutation.addedNodes.length > 0) { + mutation.addedNodes.forEach((node) => { + recursivelyUnminifyElement(node as Element); + }); + } + if (mutation.type === "attributes" && mutation.attributeName === "class") { + unminifyElement(mutation.target as HTMLElement); + } + }); +} + +export function setUpMutationObserver(rootElement: any) { + const mutationObserver = new MutationObserver(mutationObserverCallback); + mutationObserver.observe(rootElement.ownerDocument.documentElement, { + attributes: true, + attributeFilter: ["class"], + childList: true, + subtree: true, + }); + mutationObservers.push(mutationObserver); +} + +export function enableUnminifyMode() { + if (mutationObservers.length > 0) disconnectMutationObservers(); + initializeClassHashMap(); + const roots = getRootElements(); + roots.forEach(initialUnminification); + roots.forEach(setUpMutationObserver); +} + +export function disableUnminifyMode() { + disconnectMutationObservers(); +} diff --git a/src/index.tsx b/src/index.tsx index c0eb4b0..4cd407c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -4,11 +4,8 @@ import { PanelSection, PanelSectionRow, ServerAPI, - DialogButton, - Focusable, - Navigation, } from "decky-frontend-lib"; -import { useEffect, useState, FC } from "react"; +import { useEffect, useState } from "react"; import * as python from "./python"; import * as api from "./api"; import { RiPaintFill } from "react-icons/ri"; @@ -21,8 +18,8 @@ import { Flags, Theme } from "./ThemeTypes"; import { dummyFunction, getInstalledThemes, reloadBackend } from "./python"; import { bulkThemeUpdateCheck } from "./logic/bulkThemeUpdateCheck"; import { disableNavPatch, enableNavPatch } from "./deckyPatches/NavPatch"; -import { FaCog, FaStore } from "react-icons/fa"; import { SettingsPageRouter } from "./pages/settings/SettingsPageRouter"; +import { disableUnminifyMode } from "./deckyPatches/UnminifyMode"; function Content() { const { localThemeList, setGlobalState } = useCssLoaderState(); @@ -205,6 +202,7 @@ export default definePlugin((serverApi: ServerAPI) => { onDismount: () => { const { updateCheckTimeout } = state.getPublicState(); if (updateCheckTimeout) clearTimeout(updateCheckTimeout); + disableUnminifyMode(); disableNavPatch(); }, }; diff --git a/src/pages/settings/PluginSettings.tsx b/src/pages/settings/PluginSettings.tsx index ae5d89e..a929ded 100644 --- a/src/pages/settings/PluginSettings.tsx +++ b/src/pages/settings/PluginSettings.tsx @@ -1,7 +1,7 @@ -import { Focusable, ToggleField } from "decky-frontend-lib"; +import { DropdownItem, Focusable, ToggleField } from "decky-frontend-lib"; import { useMemo, useState, useEffect } from "react"; import { useCssLoaderState } from "../../state"; -import { storeWrite, toast } from "../../python"; +import { toast } from "../../python"; import { setNavPatch } from "../../deckyPatches/NavPatch"; import { getWatchState, @@ -9,14 +9,16 @@ import { enableServer, toggleWatchState, getBetaTranslationsState, + fetchClassMappings, } from "../../backend/pythonMethods/pluginSettingsMethods"; -import { booleanStoreWrite } from "../../backend/pythonMethods/storeUtils"; +import { booleanStoreWrite, stringStoreWrite } from "../../backend/pythonMethods/storeUtils"; +import { disableUnminifyMode, enableUnminifyMode } from "../../deckyPatches/UnminifyMode"; export function PluginSettings() { - const { navPatchInstance } = useCssLoaderState(); + const { navPatchInstance, unminifyModeOn, setGlobalState } = useCssLoaderState(); const [serverOn, setServerOn] = useState(false); const [watchOn, setWatchOn] = useState(false); - const [betaTranslationsOn, setBetaTranslationsOn] = useState(false); + const [betaTranslationsOn, setBetaTranslationsOn] = useState("-1"); const navPatchEnabled = useMemo(() => !!navPatchInstance, [navPatchInstance]); @@ -30,6 +32,10 @@ export function PluginSettings() { } async function fetchBetaTranslationsState() { const value = await getBetaTranslationsState(); + if (!["0", "1", "-1"].includes(value)) { + setBetaTranslationsOn("-1"); + return; + } setBetaTranslationsOn(value); } @@ -39,6 +45,15 @@ export function PluginSettings() { void fetchBetaTranslationsState(); }, []); + function setUnminify(enabled: boolean) { + setGlobalState("unminifyModeOn", enabled); + if (enabled) { + enableUnminifyMode(); + return; + } + disableUnminifyMode(); + } + async function setWatch(enabled: boolean) { await toggleWatchState(enabled, false); await fetchWatchState(); @@ -50,23 +65,25 @@ export function PluginSettings() { await fetchServerState(); } - async function setBetaTranslations(enabled: boolean) { - await booleanStoreWrite("beta_translations", enabled); + async function setBetaTranslations(value: string) { + await stringStoreWrite("beta_translations", value); + await fetchClassMappings(); await fetchBetaTranslationsState(); - toast( - "Beta translations " + (enabled ? "enabled" : "disabled") + ".", - "Please restart your Deck to apply changes." - ); } return (
- setBetaTranslations(data.data)} /> @@ -95,6 +112,14 @@ export function PluginSettings() { onChange={setWatch} /> + + +
); } diff --git a/src/state/CssLoaderState.tsx b/src/state/CssLoaderState.tsx index 1f46f63..057d510 100644 --- a/src/state/CssLoaderState.tsx +++ b/src/state/CssLoaderState.tsx @@ -42,6 +42,7 @@ interface PublicCssLoaderState { nextUpdateCheckTime: number; updateCheckTimeout: NodeJS.Timeout | undefined; + unminifyModeOn: boolean; navPatchInstance: Patch | undefined; updateStatuses: UpdateStatus[]; selectedPreset: Theme | undefined; @@ -152,6 +153,7 @@ export class CssLoaderState { private submissionThemeList: ThemeQueryResponse = { total: 0, items: [] }; private backendVersion: number = 6; private hiddenMotd: string = ""; + private unminifyModeOn: boolean = false; // You can listen to this eventBus' 'stateUpdate' event and use that to trigger a useState or other function that causes a re-render public eventBus = new EventTarget(); @@ -175,6 +177,7 @@ export class CssLoaderState { unpinnedThemes: this.unpinnedThemes, isInstalling: this.isInstalling, hiddenMotd: this.hiddenMotd, + unminifyModeOn: this.unminifyModeOn, navPatchInstance: this.navPatchInstance, selectedRepo: this.selectedRepo,