diff --git a/backend/helpers.py b/backend/helpers.py index a1877fb85..1dd82b845 100644 --- a/backend/helpers.py +++ b/backend/helpers.py @@ -32,7 +32,7 @@ def get_csrf_token(): @middleware async def csrf_middleware(request, handler): - if str(request.method) == "OPTIONS" or request.headers.get('Authentication') == csrf_token or str(request.rel_url) == "/auth/token" or str(request.rel_url).startswith("/plugins/load_main/") or str(request.rel_url).startswith("/static/") or str(request.rel_url).startswith("/legacy/") or str(request.rel_url).startswith("/steam_resource/") or str(request.rel_url).startswith("/frontend/") or assets_regex.match(str(request.rel_url)) or frontend_regex.match(str(request.rel_url)): + if str(request.method) == "OPTIONS" or request.headers.get('Authentication') == csrf_token or str(request.rel_url) == "/auth/token" or str(request.rel_url).startswith("/plugins/load_main/") or str(request.rel_url).startswith("/static/") or str(request.rel_url).startswith("/legacy/") or str(request.rel_url).startswith("/steam_resource/") or str(request.rel_url).startswith("/frontend/") or str(request.rel_url).startswith("/docs/") or assets_regex.match(str(request.rel_url)) or frontend_regex.match(str(request.rel_url)): return await handler(request) return Response(text='Forbidden', status='403') diff --git a/backend/loader.py b/backend/loader.py index d07b1c088..39f164ada 100644 --- a/backend/loader.py +++ b/backend/loader.py @@ -4,6 +4,7 @@ from os import listdir, path from pathlib import Path from traceback import print_exc +from json import load from aiohttp import web from os.path import exists @@ -13,6 +14,8 @@ from injector import get_tab, get_gamepadui_tab from plugin import PluginWrapper +import frontmatter + class FileChangeHandler(RegexMatchingEventHandler): def __init__(self, queue, plugin_path) -> None: super().__init__(regexes=[r'^.*?dist\/index\.js$', r'^.*?main\.py$']) @@ -78,6 +81,7 @@ def __init__(self, server_instance, plugin_path, loop, live_reload=False) -> Non server_instance.add_routes([ web.get("/frontend/{path:.*}", self.handle_frontend_assets), web.get("/locales/{path:.*}", self.handle_frontend_locales), + web.get("/docs/{plugin_name}/{language}", self.get_plugin_documentation), web.get("/plugins", self.get_plugins), web.get("/plugins/{plugin_name}/frontend_bundle", self.handle_frontend_bundle), web.post("/plugins/{plugin_name}/methods/{method_name}", self.handle_plugin_method_call), @@ -119,6 +123,72 @@ def handle_plugin_frontend_assets(self, request): file = path.join(self.plugin_path, plugin.plugin_directory, "dist/assets", request.match_info["path"]) return web.FileResponse(file, headers={"Cache-Control": "no-cache"}) + + def get_plugin_documentation(self, request): + plugin_name, language = request.match_info["plugin_name"], request.match_info["language"] + plugin_path = path.join(self.plugin_path, self.plugins[plugin_name].plugin_directory) + docs_path = path.join(plugin_path, "docs") + self.logger.info(f"Loading docs for {plugin_name} in {language}") + + if not exists(docs_path): + try: + with open(path.join(plugin_path, "README.md")) as f: + return web.json_response([{"title":"readme","text":f.read()}]) + except: + logger.error(f"Failed to load readme file for {plugin_name} at {plugin_path}") + + docs = [] # [{"title":"readable name", "text":"marked up file"},'separator',...] + + config = {"default_language": "en-US", "include_readme": "False", "file_list":None, "use_translation":None} + try: + with open(path.join(docs_path, "docs.json")) as f: + config_file = load(f) + for key in config: + if key in config_file: + config[key] = config_file[key] + except: + self.logger.warning(f"unable to load docs.json for {plugin_name} at {plugin_path}") + + if config["use_translation"] == None: + if exists(path.join(docs_path, config["default_language"])): + config["use_translation"] = "True" + else: + config["use_translation"] = "False" + if config["use_translation"] == "True": docs_file_path = path.join(docs_path, language) + elif config["use_translation"] == "False": docs_file_path = docs_path + + if config["file_list"] == None: + files = listdir(docs_file_path) + config["file_list"] = filter(lambda x: (x[-3:] == ".md"),files) + + + for filename in config["file_list"]: + if filename == "seperator": + docs.append('separator') + else: + try: + if config["use_translation"] == "True" and not exists(path.join(docs_file_path,filename)): + data = frontmatter.load(path.join(docs_path, config["default_language"], filename)) + else: + data = frontmatter.load(path.join(docs_file_path,filename)) + text = data.content.replace("/decky/assets", f"http://127.0.0.1:1337/plugins/{plugin_name.replace(' ', '%20')}/assets") + docs.append({ + "title": data.get("title", filename[:-3]), + "text": text + }) + except: + self.logger.warning(f"unable to load file {filename} for {plugin_name} at {docs_file_path}") + + if config["include_readme"] == "True": + try: + with open(path.join(plugin_path, "README.md")) as f: + text = f.read().replace("/decky/assets", f"http://127.0.0.1:1337/plugins/{plugin_name.replace(' ', '%20')}/assets") + docs.append({"title":"readme","text": text}) + except: + self.logger.warning(f"unable to load the readme for {plugin_name} at {plugin_path}") + + return web.json_response(docs) + def handle_frontend_bundle(self, request): plugin = self.plugins[request.match_info["plugin_name"]] @@ -225,4 +295,4 @@ async def handle_backend_reload_request(self, request): await self.reload_queue.put((plugin.file, plugin.plugin_directory)) - return web.Response(status=200) \ No newline at end of file + return web.Response(status=200) diff --git a/frontend/src/components/Docs.tsx b/frontend/src/components/Docs.tsx new file mode 100644 index 000000000..333b52eaf --- /dev/null +++ b/frontend/src/components/Docs.tsx @@ -0,0 +1,99 @@ +import { SidebarNavigation, SteamSpinner, useParams } from 'decky-frontend-lib'; +import i18n from 'i18next'; +import { VFC, useEffect, useState } from 'react'; +import { lazy } from 'react'; + +import { ScrollArea, Scrollable, scrollableRef } from './Scrollable'; + +const MarkdownRenderer = lazy(() => import('./Markdown')); + +const DocsPage: VFC<{ content: string }> = ({ content }) => { + const ref = scrollableRef(); + + return ( + <> + + + + + + + + ); +}; + +interface DocsPage { + title: string; + text: string; +} + +const StorePage: VFC<{}> = () => { + const [docs, setDocs] = useState<(DocsPage | 'separator')[] | null>(null); + const { plugin } = useParams<{ plugin: string }>(); + + useEffect(() => { + (async () => { + setDocs( + await ( + await fetch(`http://127.0.0.1:1337/docs/${plugin}/${i18n.resolvedLanguage}`, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + Authentication: window.deckyAuthToken, + }, + }) + ).json(), + ); + })(); + }, []); + + return ( + <> + {!docs ? ( +
+ +
+ ) : docs.length == 1 ? ( +
+ +
+ ) : ( + + file == 'separator' + ? 'separator' + : { + title: file['title'], + content: , + route: `/decky/docs/${plugin}/${file['title']}`, + hideTitle: true, + }, + )} + /> + )} + + ); +}; + +export default StorePage; diff --git a/frontend/src/components/Markdown.tsx b/frontend/src/components/Markdown.tsx index 917765996..71045a884 100644 --- a/frontend/src/components/Markdown.tsx +++ b/frontend/src/components/Markdown.tsx @@ -26,7 +26,7 @@ const Markdown: FunctionComponent = (props) => { }} style={{ display: 'inline' }} > - + {nodeProps.children} diff --git a/frontend/src/components/Scrollable.tsx b/frontend/src/components/Scrollable.tsx new file mode 100644 index 000000000..4fd948361 --- /dev/null +++ b/frontend/src/components/Scrollable.tsx @@ -0,0 +1,115 @@ +import { Focusable, FocusableProps, GamepadButton, GamepadEvent, ServerAPI } from 'decky-frontend-lib'; +// this file was mostly (the good bits) made by davocarli#7308 +// it should probably be moved to DFL eventually +import { FC, ForwardRefExoticComponent } from 'react'; +import React, { useRef } from 'react'; + +const DEFAULTSCROLLSPEED = 50; + +export interface ScrollableElement extends HTMLDivElement {} + +export function scrollableRef() { + return useRef(null); +} + +export const Scrollable: ForwardRefExoticComponent = React.forwardRef((props, ref) => { + if (!props.style) { + props.style = {}; + } + // props.style.minHeight = '100%'; + // props.style.maxHeight = '80%'; + props.style.height = '85vh'; // was 95vh previously! + props.style.overflowY = 'scroll'; + + return ( + +
+ + ); +}); + +interface ScrollAreaProps extends FocusableProps { + scrollable: React.RefObject; + scrollSpeed?: number; + serverApi?: ServerAPI; + noFocusRing?: boolean; +} + +// const writeLog = async (serverApi: ServerAPI, content: any) => { +// let text = `${content}` +// serverApi.callPluginMethod<{ content: string }>("log", { content: text }) +// } + +const scrollOnDirection = ( + e: GamepadEvent, + ref: React.RefObject, + amt: number, + prev: React.RefObject, + next: React.RefObject, +) => { + let childNodes = ref.current?.childNodes; + let currentIndex = null; + childNodes?.forEach((node, i) => { + if (node == e.currentTarget) { + currentIndex = i; + } + }); + + // @ts-ignore + let pos = e.currentTarget?.getBoundingClientRect(); + let out = ref.current?.getBoundingClientRect(); + + if (e.detail.button == GamepadButton.DIR_DOWN) { + if ( + out?.bottom != undefined && + pos.bottom <= out.bottom && + currentIndex != null && + childNodes != undefined && + currentIndex + 1 < childNodes.length + ) { + next.current?.focus(); + } else { + ref.current?.scrollBy({ top: amt, behavior: 'auto' }); + } + } else if (e.detail.button == GamepadButton.DIR_UP) { + if ( + out?.top != undefined && + pos.top >= out.top && + currentIndex != null && + childNodes != undefined && + currentIndex - 1 >= 0 + ) { + prev.current?.focus(); + } else { + ref.current?.scrollBy({ top: -amt, behavior: 'auto' }); + } + } else if (e.detail.button == GamepadButton.DIR_LEFT) { + throw 'this is not a real error, just a (temporary?) workaround to make navigation work in docs'; + } +}; + +export const ScrollArea: FC = (props) => { + let scrollSpeed = DEFAULTSCROLLSPEED; + if (props.scrollSpeed) { + scrollSpeed = props.scrollSpeed; + } + + const prevFocus = useRef(null); + const nextFocus = useRef(null); + + props.onActivate = (e) => { + const ele = e.currentTarget as HTMLElement; + ele.focus(); + }; + props.onGamepadDirection = (e) => { + scrollOnDirection(e, props.scrollable, scrollSpeed, prevFocus, nextFocus); + }; + + return ( + + {}} /> + + {}} /> + + ); +}; diff --git a/frontend/src/components/TitleView.tsx b/frontend/src/components/TitleView.tsx index 111f8c807..82386339d 100644 --- a/frontend/src/components/TitleView.tsx +++ b/frontend/src/components/TitleView.tsx @@ -1,8 +1,8 @@ -import { DialogButton, Focusable, Router, staticClasses } from 'decky-frontend-lib'; -import { CSSProperties, VFC } from 'react'; +import { DialogButton, Focusable, Navigation, staticClasses } from 'decky-frontend-lib'; +import { CSSProperties, VFC, cloneElement } from 'react'; import { useTranslation } from 'react-i18next'; import { BsGearFill } from 'react-icons/bs'; -import { FaArrowLeft, FaStore } from 'react-icons/fa'; +import { FaArrowLeft, FaInfo, FaStore } from 'react-icons/fa'; import { useDeckyState } from './DeckyState'; @@ -17,13 +17,18 @@ const TitleView: VFC = () => { const { t } = useTranslation(); const onSettingsClick = () => { - Router.CloseSideMenus(); - Router.Navigate('/decky/settings'); + Navigation.CloseSideMenus(); + Navigation.Navigate('/decky/settings'); }; const onStoreClick = () => { - Router.CloseSideMenus(); - Router.Navigate('/decky/store'); + Navigation.CloseSideMenus(); + Navigation.Navigate('/decky/store'); + }; + + const onInfoClick = () => { + Navigation.CloseSideMenus(); + Navigation.Navigate(`/decky/docs/${activePlugin?.name}`); }; if (activePlugin === null) { @@ -48,6 +53,10 @@ const TitleView: VFC = () => { ); } + const CustomTitleView = activePlugin?.titleView + ? cloneElement(activePlugin.titleView, { onDocsClick: onInfoClick }) + : null; + return ( { > - {activePlugin?.titleView ||
{activePlugin.name}
} + {CustomTitleView || ( + <> +
{activePlugin.name}
+ + + + + )}
); }; diff --git a/frontend/src/plugin-loader.tsx b/frontend/src/plugin-loader.tsx index b27a19bb0..01a010a4b 100644 --- a/frontend/src/plugin-loader.tsx +++ b/frontend/src/plugin-loader.tsx @@ -38,6 +38,7 @@ import TranslationHelper, { TranslationClass } from './utils/TranslationHelper'; const StorePage = lazy(() => import('./components/store/Store')); const SettingsPage = lazy(() => import('./components/settings')); +const DocsPage = lazy(() => import('./components/Docs')); const FilePicker = lazy(() => import('./components/modals/filepicker')); @@ -98,6 +99,11 @@ class PluginLoader extends Logger { ); }); + this.routerHook.addRoute('/decky/docs/:plugin', () => ( + + + + )); initSteamFixes(); diff --git a/requirements.txt b/requirements.txt index 326a924cf..2c38182b9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ aiohttp-jinja2==1.5.1 aiohttp_cors==0.7.0 watchdog==2.1.7 certifi==2023.7.22 +python-frontmatter==1.0.0