mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-07-11 23:32:00 +00:00
Added log viewer as side-tab in settings
This commit is contained in:
+120
-94
@@ -1,5 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from os import stat_result
|
from os import stat_result, listdir
|
||||||
import uuid
|
import uuid
|
||||||
from json.decoder import JSONDecodeError
|
from json.decoder import JSONDecodeError
|
||||||
from os.path import splitext
|
from os.path import splitext
|
||||||
@@ -15,6 +15,7 @@ from logging import getLogger
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .browser import PluginInstallRequest, PluginInstallType
|
from .browser import PluginInstallRequest, PluginInstallType
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .main import PluginManager
|
from .main import PluginManager
|
||||||
from .injector import inject_to_tab, get_gamepadui_tab, close_old_tabs, get_tab
|
from .injector import inject_to_tab, get_gamepadui_tab, close_old_tabs, get_tab
|
||||||
@@ -22,11 +23,13 @@ from .localplatform import ON_WINDOWS
|
|||||||
from . import helpers
|
from . import helpers
|
||||||
from .localplatform import service_stop, service_start, get_home_path, get_username
|
from .localplatform import service_stop, service_start, get_home_path, get_username
|
||||||
|
|
||||||
|
|
||||||
class FilePickerObj(TypedDict):
|
class FilePickerObj(TypedDict):
|
||||||
file: Path
|
file: Path
|
||||||
filest: stat_result
|
filest: stat_result
|
||||||
is_dir: bool
|
is_dir: bool
|
||||||
|
|
||||||
|
|
||||||
class Utilities:
|
class Utilities:
|
||||||
def __init__(self, context: PluginManager) -> None:
|
def __init__(self, context: PluginManager) -> None:
|
||||||
self.context = context
|
self.context = context
|
||||||
@@ -50,6 +53,10 @@ class Utilities:
|
|||||||
"enable_rdt": self.enable_rdt,
|
"enable_rdt": self.enable_rdt,
|
||||||
"get_tab_id": self.get_tab_id,
|
"get_tab_id": self.get_tab_id,
|
||||||
"get_user_info": self.get_user_info,
|
"get_user_info": self.get_user_info,
|
||||||
|
"get_plugin_logs": self.get_plugin_logs,
|
||||||
|
"get_plugins_with_logs": self.get_plugins_with_logs,
|
||||||
|
"get_plugin_log_text": self.get_plugin_log_text,
|
||||||
|
"upload_log": self.upload_log
|
||||||
}
|
}
|
||||||
|
|
||||||
self.logger = getLogger("Utilities")
|
self.logger = getLogger("Utilities")
|
||||||
@@ -59,9 +66,9 @@ class Utilities:
|
|||||||
self.rdt_proxy_task = None
|
self.rdt_proxy_task = None
|
||||||
|
|
||||||
if context:
|
if context:
|
||||||
context.web_app.add_routes([
|
context.web_app.add_routes(
|
||||||
web.post("/methods/{method_name}", self._handle_server_method_call)
|
[web.post("/methods/{method_name}", self._handle_server_method_call)]
|
||||||
])
|
)
|
||||||
|
|
||||||
async def _handle_server_method_call(self, request: web.Request):
|
async def _handle_server_method_call(self, request: web.Request):
|
||||||
method_name = request.match_info["method_name"]
|
method_name = request.match_info["method_name"]
|
||||||
@@ -79,13 +86,20 @@ class Utilities:
|
|||||||
res["success"] = False
|
res["success"] = False
|
||||||
return web.json_response(res)
|
return web.json_response(res)
|
||||||
|
|
||||||
async def install_plugin(self, artifact: str="", name: str="No name", version: str="dev", hash: str="", install_type: PluginInstallType=PluginInstallType.INSTALL):
|
async def install_plugin(
|
||||||
|
self,
|
||||||
|
artifact: str = "",
|
||||||
|
name: str = "No name",
|
||||||
|
version: str = "dev",
|
||||||
|
hash: str = "",
|
||||||
|
install_type: PluginInstallType = PluginInstallType.INSTALL,
|
||||||
|
):
|
||||||
return await self.context.plugin_browser.request_plugin_install(
|
return await self.context.plugin_browser.request_plugin_install(
|
||||||
artifact=artifact,
|
artifact=artifact,
|
||||||
name=name,
|
name=name,
|
||||||
version=version,
|
version=version,
|
||||||
hash=hash,
|
hash=hash,
|
||||||
install_type=install_type
|
install_type=install_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def install_plugins(self, requests: List[PluginInstallRequest]):
|
async def install_plugins(self, requests: List[PluginInstallRequest]):
|
||||||
@@ -102,15 +116,13 @@ class Utilities:
|
|||||||
async def uninstall_plugin(self, name: str):
|
async def uninstall_plugin(self, name: str):
|
||||||
return await self.context.plugin_browser.uninstall_plugin(name)
|
return await self.context.plugin_browser.uninstall_plugin(name)
|
||||||
|
|
||||||
async def http_request(self, method: str="", url: str="", **kwargs: Any):
|
async def http_request(self, method: str = "", url: str = "", **kwargs: Any):
|
||||||
async with ClientSession() as web:
|
async with ClientSession() as web:
|
||||||
res = await web.request(method, url, ssl=helpers.get_ssl_context(), **kwargs)
|
res = await web.request(
|
||||||
|
method, url, ssl=helpers.get_ssl_context(), **kwargs
|
||||||
|
)
|
||||||
text = await res.text()
|
text = await res.text()
|
||||||
return {
|
return {"status": res.status, "headers": dict(res.headers), "body": text}
|
||||||
"status": res.status,
|
|
||||||
"headers": dict(res.headers),
|
|
||||||
"body": text
|
|
||||||
}
|
|
||||||
|
|
||||||
async def ping(self, **kwargs: Any):
|
async def ping(self, **kwargs: Any):
|
||||||
return "pong"
|
return "pong"
|
||||||
@@ -120,26 +132,18 @@ class Utilities:
|
|||||||
result = await inject_to_tab(tab, code, run_async)
|
result = await inject_to_tab(tab, code, run_async)
|
||||||
assert result
|
assert result
|
||||||
if "exceptionDetails" in result["result"]:
|
if "exceptionDetails" in result["result"]:
|
||||||
return {
|
return {"success": False, "result": result["result"]}
|
||||||
"success": False,
|
|
||||||
"result": result["result"]
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {"success": True, "result": result["result"]["result"].get("value")}
|
||||||
"success": True,
|
|
||||||
"result": result["result"]["result"].get("value")
|
|
||||||
}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {"success": False, "result": e}
|
||||||
"success": False,
|
|
||||||
"result": e
|
|
||||||
}
|
|
||||||
|
|
||||||
async def inject_css_into_tab(self, tab: str, style: str):
|
async def inject_css_into_tab(self, tab: str, style: str):
|
||||||
try:
|
try:
|
||||||
css_id = str(uuid.uuid4())
|
css_id = str(uuid.uuid4())
|
||||||
|
|
||||||
result = await inject_to_tab(tab,
|
result = await inject_to_tab(
|
||||||
|
tab,
|
||||||
f"""
|
f"""
|
||||||
(function() {{
|
(function() {{
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
@@ -147,27 +151,21 @@ class Utilities:
|
|||||||
document.head.append(style);
|
document.head.append(style);
|
||||||
style.textContent = `{style}`;
|
style.textContent = `{style}`;
|
||||||
}})()
|
}})()
|
||||||
""", False)
|
""",
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
if result and "exceptionDetails" in result["result"]:
|
if result and "exceptionDetails" in result["result"]:
|
||||||
return {
|
return {"success": False, "result": result["result"]}
|
||||||
"success": False,
|
|
||||||
"result": result["result"]
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {"success": True, "result": css_id}
|
||||||
"success": True,
|
|
||||||
"result": css_id
|
|
||||||
}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {"success": False, "result": e}
|
||||||
"success": False,
|
|
||||||
"result": e
|
|
||||||
}
|
|
||||||
|
|
||||||
async def remove_css_from_tab(self, tab: str, css_id: str):
|
async def remove_css_from_tab(self, tab: str, css_id: str):
|
||||||
try:
|
try:
|
||||||
result = await inject_to_tab(tab,
|
result = await inject_to_tab(
|
||||||
|
tab,
|
||||||
f"""
|
f"""
|
||||||
(function() {{
|
(function() {{
|
||||||
let style = document.getElementById("{css_id}");
|
let style = document.getElementById("{css_id}");
|
||||||
@@ -175,22 +173,16 @@ class Utilities:
|
|||||||
if (style.nodeName.toLowerCase() == 'style')
|
if (style.nodeName.toLowerCase() == 'style')
|
||||||
style.parentNode.removeChild(style);
|
style.parentNode.removeChild(style);
|
||||||
}})()
|
}})()
|
||||||
""", False)
|
""",
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
if result and "exceptionDetails" in result["result"]:
|
if result and "exceptionDetails" in result["result"]:
|
||||||
return {
|
return {"success": False, "result": result}
|
||||||
"success": False,
|
|
||||||
"result": result
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {"success": True}
|
||||||
"success": True
|
|
||||||
}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {"success": False, "result": e}
|
||||||
"success": False,
|
|
||||||
"result": e
|
|
||||||
}
|
|
||||||
|
|
||||||
async def get_setting(self, key: str, default: Any):
|
async def get_setting(self, key: str, default: Any):
|
||||||
return self.context.settings.getSetting(key, default)
|
return self.context.settings.getSetting(key, default)
|
||||||
@@ -206,8 +198,9 @@ class Utilities:
|
|||||||
await service_stop(helpers.REMOTE_DEBUGGER_UNIT)
|
await service_stop(helpers.REMOTE_DEBUGGER_UNIT)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def filepicker_ls(self,
|
async def filepicker_ls(
|
||||||
path : str | None = None,
|
self,
|
||||||
|
path: str | None = None,
|
||||||
include_files: bool = True,
|
include_files: bool = True,
|
||||||
include_folders: bool = True,
|
include_folders: bool = True,
|
||||||
include_ext: list[str] = [],
|
include_ext: list[str] = [],
|
||||||
@@ -215,7 +208,8 @@ class Utilities:
|
|||||||
order_by: str = "name_asc",
|
order_by: str = "name_asc",
|
||||||
filter_for: str | None = None,
|
filter_for: str | None = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
max: int = 1000):
|
max: int = 1000,
|
||||||
|
):
|
||||||
|
|
||||||
if path == None:
|
if path == None:
|
||||||
path = get_home_path()
|
path = get_home_path()
|
||||||
@@ -225,11 +219,11 @@ class Utilities:
|
|||||||
files: List[FilePickerObj] = []
|
files: List[FilePickerObj] = []
|
||||||
folders: List[FilePickerObj] = []
|
folders: List[FilePickerObj] = []
|
||||||
|
|
||||||
#Resolving all files/folders in the requested directory
|
# Resolving all files/folders in the requested directory
|
||||||
for file in path_obj.iterdir():
|
for file in path_obj.iterdir():
|
||||||
if file.exists():
|
if file.exists():
|
||||||
filest = file.stat()
|
filest = file.stat()
|
||||||
is_hidden = file.name.startswith('.')
|
is_hidden = file.name.startswith(".")
|
||||||
if ON_WINDOWS and not is_hidden:
|
if ON_WINDOWS and not is_hidden:
|
||||||
is_hidden = bool(filest.st_file_attributes & FILE_ATTRIBUTE_HIDDEN) # type: ignore
|
is_hidden = bool(filest.st_file_attributes & FILE_ATTRIBUTE_HIDDEN) # type: ignore
|
||||||
if include_folders and file.is_dir():
|
if include_folders and file.is_dir():
|
||||||
@@ -240,54 +234,66 @@ class Utilities:
|
|||||||
if len(include_ext) == 0 or 'all_files' in include_ext \
|
if len(include_ext) == 0 or 'all_files' in include_ext \
|
||||||
or splitext(file.name)[1].lstrip('.').upper() in (ext.upper() for ext in include_ext):
|
or splitext(file.name)[1].lstrip('.').upper() in (ext.upper() for ext in include_ext):
|
||||||
if (is_hidden and include_hidden) or not is_hidden:
|
if (is_hidden and include_hidden) or not is_hidden:
|
||||||
files.append({"file": file, "filest": filest, "is_dir": False})
|
files.append(
|
||||||
|
{"file": file, "filest": filest, "is_dir": False}
|
||||||
|
)
|
||||||
# Filter logic
|
# Filter logic
|
||||||
if filter_for is not None:
|
if filter_for is not None:
|
||||||
try:
|
try:
|
||||||
if re.compile(filter_for):
|
if re.compile(filter_for):
|
||||||
files = list(filter(lambda file: re.search(filter_for, file["file"].name) != None, files))
|
files = list(
|
||||||
|
filter(
|
||||||
|
lambda file: re.search(filter_for, file["file"].name)
|
||||||
|
!= None,
|
||||||
|
files,
|
||||||
|
)
|
||||||
|
)
|
||||||
except re.error:
|
except re.error:
|
||||||
files = list(filter(lambda file: file["file"].name.find(filter_for) != -1, files))
|
files = list(
|
||||||
|
filter(lambda file: file["file"].name.find(filter_for) != -1, files)
|
||||||
|
)
|
||||||
|
|
||||||
# Ordering logic
|
# Ordering logic
|
||||||
ord_arg = order_by.split("_")
|
ord_arg = order_by.split("_")
|
||||||
ord = ord_arg[0]
|
ord = ord_arg[0]
|
||||||
rev = True if ord_arg[1] == "asc" else False
|
rev = True if ord_arg[1] == "asc" else False
|
||||||
match ord:
|
match ord:
|
||||||
case 'name':
|
case "name":
|
||||||
files.sort(key=lambda x: x['file'].name.casefold(), reverse = rev)
|
files.sort(key=lambda x: x["file"].name.casefold(), reverse=rev)
|
||||||
folders.sort(key=lambda x: x['file'].name.casefold(), reverse = rev)
|
folders.sort(key=lambda x: x["file"].name.casefold(), reverse=rev)
|
||||||
case 'modified':
|
case "modified":
|
||||||
files.sort(key=lambda x: x['filest'].st_mtime, reverse = not rev)
|
files.sort(key=lambda x: x["filest"].st_mtime, reverse=not rev)
|
||||||
folders.sort(key=lambda x: x['filest'].st_mtime, reverse = not rev)
|
folders.sort(key=lambda x: x["filest"].st_mtime, reverse=not rev)
|
||||||
case 'created':
|
case "created":
|
||||||
files.sort(key=lambda x: x['filest'].st_ctime, reverse = not rev)
|
files.sort(key=lambda x: x["filest"].st_ctime, reverse=not rev)
|
||||||
folders.sort(key=lambda x: x['filest'].st_ctime, reverse = not rev)
|
folders.sort(key=lambda x: x["filest"].st_ctime, reverse=not rev)
|
||||||
case 'size':
|
case "size":
|
||||||
files.sort(key=lambda x: x['filest'].st_size, reverse = not rev)
|
files.sort(key=lambda x: x["filest"].st_size, reverse=not rev)
|
||||||
# Folders has no file size, order by name instead
|
# Folders has no file size, order by name instead
|
||||||
folders.sort(key=lambda x: x['file'].name.casefold())
|
folders.sort(key=lambda x: x["file"].name.casefold())
|
||||||
case _:
|
case _:
|
||||||
files.sort(key=lambda x: x['file'].name.casefold(), reverse = rev)
|
files.sort(key=lambda x: x["file"].name.casefold(), reverse=rev)
|
||||||
folders.sort(key=lambda x: x['file'].name.casefold(), reverse = rev)
|
folders.sort(key=lambda x: x["file"].name.casefold(), reverse=rev)
|
||||||
|
|
||||||
#Constructing the final file list, folders first
|
# Constructing the final file list, folders first
|
||||||
all = [{
|
all = [
|
||||||
"isdir": x['is_dir'],
|
{
|
||||||
"name": str(x['file'].name),
|
"isdir": x["is_dir"],
|
||||||
"realpath": str(x['file']),
|
"name": str(x["file"].name),
|
||||||
"size": x['filest'].st_size,
|
"realpath": str(x["file"]),
|
||||||
"modified": x['filest'].st_mtime,
|
"size": x["filest"].st_size,
|
||||||
"created": x['filest'].st_ctime,
|
"modified": x["filest"].st_mtime,
|
||||||
} for x in folders + files ]
|
"created": x["filest"].st_ctime,
|
||||||
|
}
|
||||||
|
for x in folders + files
|
||||||
|
]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"realpath": str(path),
|
"realpath": str(path),
|
||||||
"files": all[(page-1)*max:(page)*max],
|
"files": all[(page - 1) * max : (page) * max],
|
||||||
"total": len(all),
|
"total": len(all),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Based on https://stackoverflow.com/a/46422554/13174603
|
# Based on https://stackoverflow.com/a/46422554/13174603
|
||||||
def start_rdt_proxy(self, ip: str, port: int):
|
def start_rdt_proxy(self, ip: str, port: int):
|
||||||
async def pipe(reader: StreamReader, writer: StreamWriter):
|
async def pipe(reader: StreamReader, writer: StreamWriter):
|
||||||
@@ -296,10 +302,10 @@ class Utilities:
|
|||||||
writer.write(await reader.read(2048))
|
writer.write(await reader.read(2048))
|
||||||
finally:
|
finally:
|
||||||
writer.close()
|
writer.close()
|
||||||
|
|
||||||
async def handle_client(local_reader: StreamReader, local_writer: StreamWriter):
|
async def handle_client(local_reader: StreamReader, local_writer: StreamWriter):
|
||||||
try:
|
try:
|
||||||
remote_reader, remote_writer = await open_connection(
|
remote_reader, remote_writer = await open_connection(ip, port)
|
||||||
ip, port)
|
|
||||||
pipe1 = pipe(local_reader, remote_writer)
|
pipe1 = pipe(local_reader, remote_writer)
|
||||||
pipe2 = pipe(remote_reader, local_writer)
|
pipe2 = pipe(remote_reader, local_writer)
|
||||||
await gather(pipe1, pipe2)
|
await gather(pipe1, pipe2)
|
||||||
@@ -324,8 +330,11 @@ class Utilities:
|
|||||||
if ip != None:
|
if ip != None:
|
||||||
self.logger.info("Connecting to React DevTools at " + ip)
|
self.logger.info("Connecting to React DevTools at " + ip)
|
||||||
async with ClientSession() as web:
|
async with ClientSession() as web:
|
||||||
res = await web.request("GET", "http://" + ip + ":8097", ssl=helpers.get_ssl_context())
|
res = await web.request(
|
||||||
script = """
|
"GET", "http://" + ip + ":8097", ssl=helpers.get_ssl_context()
|
||||||
|
)
|
||||||
|
script = (
|
||||||
|
"""
|
||||||
if (!window.deckyHasConnectedRDT) {
|
if (!window.deckyHasConnectedRDT) {
|
||||||
window.deckyHasConnectedRDT = true;
|
window.deckyHasConnectedRDT = true;
|
||||||
// This fixes the overlay when hovering over an element in RDT
|
// This fixes the overlay when hovering over an element in RDT
|
||||||
@@ -336,7 +345,10 @@ class Utilities:
|
|||||||
return (GamepadNavTree?.m_context?.m_controller || FocusNavController)?.m_ActiveContext?.ActiveWindow || window;
|
return (GamepadNavTree?.m_context?.m_controller || FocusNavController)?.m_ActiveContext?.ActiveWindow || window;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
""" + await res.text() + "\n}"
|
"""
|
||||||
|
+ await res.text()
|
||||||
|
+ "\n}"
|
||||||
|
)
|
||||||
if res.status != 200:
|
if res.status != 200:
|
||||||
self.logger.error("Failed to connect to React DevTools at " + ip)
|
self.logger.error("Failed to connect to React DevTools at " + ip)
|
||||||
return False
|
return False
|
||||||
@@ -364,10 +376,24 @@ class Utilities:
|
|||||||
self.logger.info("React DevTools disabled")
|
self.logger.info("React DevTools disabled")
|
||||||
|
|
||||||
async def get_user_info(self) -> Dict[str, str]:
|
async def get_user_info(self) -> Dict[str, str]:
|
||||||
return {
|
return {"username": get_username(), "path": get_home_path()}
|
||||||
"username": get_username(),
|
|
||||||
"path": get_home_path()
|
|
||||||
}
|
|
||||||
|
|
||||||
async def get_tab_id(self, name: str):
|
async def get_tab_id(self, name: str):
|
||||||
return (await get_tab(name)).id
|
return (await get_tab(name)).id
|
||||||
|
|
||||||
|
async def get_plugins_with_logs(self):
|
||||||
|
return list(self.context.plugin_loader.plugins.keys())
|
||||||
|
|
||||||
|
async def get_plugin_logs(self, plugin_name: str):
|
||||||
|
log_path = Path(helpers.get_homebrew_path()) / "logs" / \
|
||||||
|
self.context.plugin_loader.plugins[plugin_name].plugin_directory
|
||||||
|
return listdir(log_path)
|
||||||
|
|
||||||
|
async def get_plugin_log_text(self, plugin_name: str, log_name: str):
|
||||||
|
log_path = Path(helpers.get_homebrew_path()) / "logs" / \
|
||||||
|
self.context.plugin_loader.plugins[plugin_name].plugin_directory / log_name
|
||||||
|
with open(log_path, "r") as log_file:
|
||||||
|
return log_file.read()
|
||||||
|
|
||||||
|
async def upload_log(self, plugin_name: str, log_name: str):
|
||||||
|
raise RuntimeError("Not Implemented")
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
DialogButton,
|
||||||
|
Focusable,
|
||||||
|
showModal,
|
||||||
|
} from "decky-frontend-lib";
|
||||||
|
import { FC, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import LogViewModal from "./LogViewModal";
|
||||||
|
|
||||||
|
const LogList: FC<{ plugin: string }> = ({ plugin }) => {
|
||||||
|
const [logList, setLogList] = useState([]);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.DeckyPluginLoader.callServerMethod("get_plugin_logs", {
|
||||||
|
plugin_name: plugin,
|
||||||
|
}).then((log_list) => {
|
||||||
|
setLogList(log_list.result || []);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Focusable>
|
||||||
|
{logList.map((log_file) => (
|
||||||
|
<DialogButton
|
||||||
|
style={{ marginBottom: "0.5rem" }}
|
||||||
|
onOKActionDescription={t("LogViewer.viewLog", "View Log")}
|
||||||
|
onOKButton={() =>
|
||||||
|
showModal(
|
||||||
|
<LogViewModal name={log_file} plugin={plugin}></LogViewModal>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
showModal(
|
||||||
|
<LogViewModal name={log_file} plugin={plugin}></LogViewModal>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||||||
|
<div>{log_file}</div>
|
||||||
|
</div>
|
||||||
|
</DialogButton>
|
||||||
|
))}
|
||||||
|
</Focusable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LogList;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Focusable } from "decky-frontend-lib";
|
||||||
|
import { VFC, useEffect, useState } from "react";
|
||||||
|
import { ScrollableWindowRelative } from "./ScrollableWindow";
|
||||||
|
|
||||||
|
interface LogFileProps {
|
||||||
|
plugin: string;
|
||||||
|
name: string;
|
||||||
|
closeModal?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LogViewModal: VFC<LogFileProps> = ({ name, plugin, closeModal }) => {
|
||||||
|
const [logText, setLogText] = useState("Loading text....");
|
||||||
|
useEffect(() => {
|
||||||
|
window.DeckyPluginLoader.callServerMethod("get_plugin_log_text", {
|
||||||
|
plugin_name: plugin,
|
||||||
|
log_name: name,
|
||||||
|
}).then((text) => {
|
||||||
|
setLogText(text.result || "Error loading text");
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Focusable
|
||||||
|
style={{
|
||||||
|
padding: "0 15px",
|
||||||
|
display: "flex",
|
||||||
|
position: "absolute",
|
||||||
|
top: "var(--basicui-header-height)",
|
||||||
|
bottom: "var(--gamepadui-current-footer-height)",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
}}
|
||||||
|
onSecondaryActionDescription={"Upload Log"}
|
||||||
|
onSecondaryButton={() => console.log("Uploading...")}
|
||||||
|
>
|
||||||
|
<ScrollableWindowRelative alwaysFocus={true} onCancel={closeModal}>
|
||||||
|
<div style={{ whiteSpace: "pre-wrap", padding: "12px 0" }}>
|
||||||
|
{logText}
|
||||||
|
</div>
|
||||||
|
</ScrollableWindowRelative>
|
||||||
|
</Focusable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LogViewModal;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Focusable } from "decky-frontend-lib";
|
||||||
|
import { VFC, useState } from "react";
|
||||||
|
import { FaArrowDown, FaArrowUp } from "react-icons/fa";
|
||||||
|
import LogList from "./LogList";
|
||||||
|
|
||||||
|
interface LoggedPluginProps {
|
||||||
|
plugin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusableStyle = {
|
||||||
|
background: "rgba(255,255,255,.15)",
|
||||||
|
borderRadius: "var(--round-radius-size)",
|
||||||
|
padding: "10px 24px",
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoggedPlugin: VFC<LoggedPluginProps> = ({ plugin }) => {
|
||||||
|
const [isOpen, setOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={focusableStyle}>
|
||||||
|
<Focusable onOKButton={() => setOpen(!isOpen)}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||||
|
<div style={{ flexGrow: 1, textAlign: "left" }}>{plugin}</div>
|
||||||
|
<div style={{ textAlign: "right" }}>
|
||||||
|
{isOpen ? <FaArrowUp /> : <FaArrowDown />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Focusable>
|
||||||
|
{isOpen && <LogList plugin={plugin} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoggedPlugin;
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/*
|
||||||
|
Big thanks to @jessebofil for this
|
||||||
|
https://discord.com/channels/960281551428522045/960284327445418044/1209253688363716648
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Focusable, ModalPosition, GamepadButton, ScrollPanelGroup, gamepadDialogClasses, scrollPanelClasses, FooterLegendProps } from "decky-frontend-lib";
|
||||||
|
import { FC, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export interface ScrollableWindowProps extends FooterLegendProps {
|
||||||
|
height: string;
|
||||||
|
fadeAmount?: string;
|
||||||
|
scrollBarWidth?: string;
|
||||||
|
alwaysFocus?: boolean;
|
||||||
|
noScrollDescription?: boolean;
|
||||||
|
|
||||||
|
onActivate?: (e: CustomEvent) => void;
|
||||||
|
onCancel?: (e: CustomEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScrollableWindow: FC<ScrollableWindowProps> = ({ height, fadeAmount, scrollBarWidth, alwaysFocus, noScrollDescription, children, actionDescriptionMap, ...focusableProps }) => {
|
||||||
|
const fade = fadeAmount === undefined || fadeAmount === '' ? '10px' : fadeAmount;
|
||||||
|
const barWidth = scrollBarWidth === undefined || scrollBarWidth === '' ? '4px' : scrollBarWidth;
|
||||||
|
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||||
|
const scrollPanelRef = useRef<HTMLElement>();
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const { current } = scrollPanelRef;
|
||||||
|
const trigger = () => {
|
||||||
|
if (current) {
|
||||||
|
const hasOverflow = current.scrollHeight > current.clientHeight;
|
||||||
|
setIsOverflowing(hasOverflow);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (current) trigger();
|
||||||
|
}, [children, height]);
|
||||||
|
|
||||||
|
const panel = (
|
||||||
|
<ScrollPanelGroup
|
||||||
|
//@ts-ignore
|
||||||
|
ref={scrollPanelRef} focusable={false} style={{ flex: 1, minHeight: 0 }}>
|
||||||
|
<Focusable
|
||||||
|
//@ts-ignore
|
||||||
|
focusable={alwaysFocus || isOverflowing}
|
||||||
|
key={'scrollable-window-focusable-element'}
|
||||||
|
noFocusRing={true}
|
||||||
|
actionDescriptionMap={Object.assign(noScrollDescription ? {} :
|
||||||
|
{
|
||||||
|
[GamepadButton.DIR_UP]: 'Scroll Up',
|
||||||
|
[GamepadButton.DIR_DOWN]: 'Scroll Down'
|
||||||
|
},
|
||||||
|
actionDescriptionMap ?? {}
|
||||||
|
)}
|
||||||
|
{...focusableProps}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Focusable>
|
||||||
|
</ScrollPanelGroup>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>
|
||||||
|
{`.modal-position-container .${gamepadDialogClasses.ModalPosition} {
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.modal-position-container .${scrollPanelClasses.ScrollPanel}::-webkit-scrollbar {
|
||||||
|
display: initial !important;
|
||||||
|
width: ${barWidth};
|
||||||
|
}
|
||||||
|
.modal-position-container .${scrollPanelClasses.ScrollPanel}::-webkit-scrollbar-thumb {
|
||||||
|
border: 0;
|
||||||
|
}`}
|
||||||
|
</style>
|
||||||
|
<div
|
||||||
|
className='modal-position-container'
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
height: height,
|
||||||
|
WebkitMask: `linear-gradient(to right , transparent, transparent calc(100% - ${barWidth}), white calc(100% - ${barWidth})), linear-gradient(to bottom, transparent, black ${fade}, black calc(100% - ${fade}), transparent 100%)`
|
||||||
|
}}>
|
||||||
|
{isOverflowing ? (
|
||||||
|
<ModalPosition key={'scrollable-window-modal-position'}>
|
||||||
|
{panel}
|
||||||
|
</ModalPosition>
|
||||||
|
) : (
|
||||||
|
<div className={`${gamepadDialogClasses.ModalPosition} ${gamepadDialogClasses.WithStandardPadding} Panel`} key={'modal-position'}>
|
||||||
|
{panel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ScrollableWindowAutoProps extends Omit<ScrollableWindowProps, 'height'> {
|
||||||
|
heightPercent?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScrollableWindowRelative: FC<ScrollableWindowAutoProps> = ({ heightPercent, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 'auto' }}>
|
||||||
|
<ScrollableWindow height={`${heightPercent ?? 100}%`} {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { DialogBody } from 'decky-frontend-lib';
|
||||||
|
import { FC, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import LoggedPlugin from './LoggedPlugin';
|
||||||
|
|
||||||
|
const LogViewerPage: FC<{}> = () => {
|
||||||
|
const [plugins, setPlugins] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
window.DeckyPluginLoader.callServerMethod('get_plugins_with_logs').then((plugins) => {
|
||||||
|
setPlugins(plugins.result || []);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<DialogBody>
|
||||||
|
{plugins.map((plugin) => <LoggedPlugin plugin={plugin} />)}
|
||||||
|
</DialogBody>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LogViewerPage;
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import { SidebarNavigation } from 'decky-frontend-lib';
|
import { SidebarNavigation } from 'decky-frontend-lib';
|
||||||
import { lazy } from 'react';
|
import { lazy } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { FaCode, FaFlask, FaPlug } from 'react-icons/fa';
|
import { FaCode, FaFileCode, FaFlask, FaPlug } from 'react-icons/fa';
|
||||||
|
|
||||||
import { useSetting } from '../../utils/hooks/useSetting';
|
import { useSetting } from '../../utils/hooks/useSetting';
|
||||||
import DeckyIcon from '../DeckyIcon';
|
import DeckyIcon from '../DeckyIcon';
|
||||||
import WithSuspense from '../WithSuspense';
|
import WithSuspense from '../WithSuspense';
|
||||||
import GeneralSettings from './pages/general';
|
import GeneralSettings from './pages/general';
|
||||||
import PluginList from './pages/plugin_list';
|
import PluginList from './pages/plugin_list';
|
||||||
|
import LogViewerPage from '../logviewer';
|
||||||
|
|
||||||
const DeveloperSettings = lazy(() => import('./pages/developer'));
|
const DeveloperSettings = lazy(() => import('./pages/developer'));
|
||||||
const TestingMenu = lazy(() => import('./pages/testing'));
|
const TestingMenu = lazy(() => import('./pages/testing'));
|
||||||
@@ -29,6 +30,16 @@ export default function SettingsPage() {
|
|||||||
route: '/decky/settings/plugins',
|
route: '/decky/settings/plugins',
|
||||||
icon: <FaPlug />,
|
icon: <FaPlug />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('SettingsIndex.log_viewer', "Log Viewer"),
|
||||||
|
content: (
|
||||||
|
<WithSuspense>
|
||||||
|
<LogViewerPage/>
|
||||||
|
</WithSuspense>
|
||||||
|
),
|
||||||
|
route: '/decky/settings/logs',
|
||||||
|
icon: <FaFileCode />
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t('SettingsIndex.developer_title'),
|
title: t('SettingsIndex.developer_title'),
|
||||||
content: (
|
content: (
|
||||||
@@ -50,7 +61,7 @@ export default function SettingsPage() {
|
|||||||
route: '/decky/settings/testing',
|
route: '/decky/settings/testing',
|
||||||
icon: <FaFlask />,
|
icon: <FaFlask />,
|
||||||
visible: isDeveloper,
|
visible: isDeveloper,
|
||||||
},
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return <SidebarNavigation pages={pages} />;
|
return <SidebarNavigation pages={pages} />;
|
||||||
|
|||||||
Reference in New Issue
Block a user