diff --git a/src/equicordplugins/consoleViewer/components/ConsoleIcon.tsx b/src/equicordplugins/consoleViewer/components/ConsoleIcon.tsx new file mode 100644 index 00000000..47ec3de5 --- /dev/null +++ b/src/equicordplugins/consoleViewer/components/ConsoleIcon.tsx @@ -0,0 +1,14 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export function ConsoleLogIcon() { + return ` + + + + `; +} diff --git a/src/equicordplugins/consoleViewer/components/ConsoleModal.tsx b/src/equicordplugins/consoleViewer/components/ConsoleModal.tsx new file mode 100644 index 00000000..7bb6600d --- /dev/null +++ b/src/equicordplugins/consoleViewer/components/ConsoleModal.tsx @@ -0,0 +1,323 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import ErrorBoundary from "@components/ErrorBoundary"; +import { Button, React, ReactDOM } from "@webpack/common"; + +import { clearLogs, downloadLogs, getCapturedLogs, LogEntry } from "../utils/consoleLogger"; + +const timeFormat = new Intl.DateTimeFormat(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false +}); +const ErrorFallback = () => ( +
Failed to load Console Viewer
+); +function ConsoleViewerModal() { + const [logs, setLogs] = React.useState([]); + const [search, setSearch] = React.useState(""); + const [filter, setFilter] = React.useState("all"); + const [autoScroll, setAutoScroll] = React.useState(true); + const logContainerRef = React.useRef(null); + const modalRef = React.useRef(null); + React.useEffect(() => { + function updateLogs() { + setLogs([...getCapturedLogs()]); + } + updateLogs(); + window.addEventListener("console-captured", updateLogs); + return () => { + window.removeEventListener("console-captured", updateLogs); + }; + }, []); + React.useEffect(() => { + if (autoScroll && logContainerRef.current) { + logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; + } + }, [logs, autoScroll]); + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + closeConsoleViewer(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + React.useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (modalRef.current && !modalRef.current.contains(e.target as Node)) { + closeConsoleViewer(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + const filteredLogs = React.useMemo(() => { + return logs.filter(log => { + if (filter !== "all" && log.type !== filter) return false; + + if (search) { + const searchLower = search.toLowerCase(); + const contentStr = typeof log.content === "string" + ? log.content.toLowerCase() + : JSON.stringify(log.content || "").toLowerCase(); + + return contentStr.includes(searchLower); + } + + return true; + }); + }, [logs, filter, search]); + const handleScroll = React.useCallback((e: React.UIEvent) => { + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + const atBottom = scrollHeight - scrollTop - clientHeight < 50; + setAutoScroll(atBottom); + }, []); + const renderLogEntry = (log: LogEntry, index: number) => { + let contentDisplay: React.ReactNode; + + if (typeof log.content === "string") { + contentDisplay = log.content; + } else if (Array.isArray(log.content)) { + contentDisplay = log.content.map((item, i) => ( + + {typeof item === "object" && item !== null + ? JSON.stringify(item, null, 2) + : String(item ?? "")} + + )); + } else if (typeof log.content === "object" && log.content !== null) { + try { + contentDisplay = JSON.stringify(log.content, null, 2); + } catch { + contentDisplay = "[Object]"; + } + } else { + contentDisplay = String(log.content ?? ""); + } + + return ( +
+
+ {timeFormat.format(log.timestamp)} +
+
{log.type.toUpperCase()}
+
{contentDisplay}
+
+ ); + }; + const toggleAutoScroll = React.useCallback(() => { + setAutoScroll(prev => !prev); + }, []); + const handleClear = React.useCallback(() => { + clearLogs(); + setLogs([]); + }, []); + const clearSearch = React.useCallback(() => { + setSearch(""); + }, []); + + return ( + <> +
+
+
+
Console Viewer
+
+ + + +
+
+ +
+
+ {["all", "log", "info", "warn", "error"].map(type => ( + + ))} +
+ +
+ setSearch(e.target.value)} + /> + {search && ( + + )} +
+
+ +
+ {filteredLogs.length > 0 ? ( + filteredLogs.map(renderLogEntry) + ) : ( +
+ {search || filter !== "all" ? "No logs match your filters" : "No console logs captured yet"} +
+ )} +
+ +
+
+ {filteredLogs.length} {filteredLogs.length === 1 ? "log" : "logs"} + {(search || filter !== "all") ? " (filtered)" : ""} +
+
+ +
+
+
+ + ); +} +let modalContainer: HTMLDivElement | null = null; +const ReactRender = { + canUseCreateRoot() { + return typeof ReactDOM.createRoot === "function"; + }, + render(element: React.ReactElement, container: HTMLElement) { + if (this.canUseCreateRoot()) { + try { + const root = ReactDOM.createRoot(container); + root.render(element); + return { + unmount: () => { + try { + root.unmount(); + } catch (e) { + console.error("[Console Viewer] Error unmounting with createRoot:", e); + this.fallbackUnmount(container); + } + } + }; + } catch (e) { + console.error("[Console Viewer] Error using createRoot:", e); + } + } + try { + const root = ReactDOM.hydrateRoot(container, element); + return { + unmount: () => root.unmount() + }; + } catch (e) { + console.error("[Console Viewer] Error rendering component:", e); + throw e; + } + }, + fallbackUnmount(container: HTMLElement) { + try { + if (ReactRender.canUseCreateRoot()) { + console.warn("[Console Viewer] Attempting to unmount using root.unmount"); + } else { + console.warn("[Console Viewer] Falling back to container removal for unmounting."); + try { + container.remove(); + } catch (e) { + console.error("[Console Viewer] Failed to remove container:", e); + } + } + } catch (e) { + console.error("[Console Viewer] Error in fallback unmount:", e); + } + } +}; +let currentRenderer: { unmount: () => void; } | null = null; +export function openConsoleViewer() { + try { + if (!modalContainer) { + modalContainer = document.createElement("div"); + modalContainer.className = "console-viewer-root"; + document.body.appendChild(modalContainer); + } + currentRenderer = ReactRender.render( + + + , + modalContainer + ); + document.body.classList.add("console-viewer-open"); + } catch (error) { + console.error("[Console Viewer] Failed to open modal:", error); + } +} +export function closeConsoleViewer() { + try { + if (currentRenderer) { + currentRenderer.unmount(); + currentRenderer = null; + } + if (modalContainer) { + document.body.removeChild(modalContainer); + modalContainer = null; + } + document.body.classList.remove("console-viewer-open"); + document.querySelectorAll(".console-viewer-backdrop").forEach(el => el.remove()); + } catch (error) { + console.error("[Console Viewer] Failed to close modal:", error); + document.querySelectorAll(".console-viewer-backdrop, .console-viewer-root").forEach(el => el.remove()); + document.body.classList.remove("console-viewer-open"); + modalContainer = null; + currentRenderer = null; + } +} diff --git a/src/equicordplugins/consoleViewer/index.tsx b/src/equicordplugins/consoleViewer/index.tsx new file mode 100644 index 00000000..1a7b4ad6 --- /dev/null +++ b/src/equicordplugins/consoleViewer/index.tsx @@ -0,0 +1,88 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./styles.css"; + +import { EquicordDevs } from "@utils/constants"; +import { LazyComponent } from "@utils/react"; +import definePlugin from "@utils/types"; +import { findByCode } from "@webpack"; + +import { ConsoleLogIcon } from "./components/ConsoleIcon"; +import { openConsoleViewer } from "./components/ConsoleModal"; +import settings from "./settings"; +import { startConsoleCapture, stopConsoleCapture } from "./utils/consoleLogger"; + +type CommandReturnValue = any; +type Argument = any; +type CommandContext = any; + +const HeaderBarIcon = LazyComponent(() => { + const filter = ".Icon"; + return findByCode(filter); +}); + +export default definePlugin({ + name: "Console Viewer", + description: "View and search console logs in a clean UI modal", + authors: [EquicordDevs.SteelTech], + dependencies: [], + + settings, + + commands: [{ + name: "console", + description: "Open the console viewer", + execute: (args: Argument[], ctx: CommandContext) => { + openConsoleViewer(); + return { + send: false, + result: "Opened console viewer" + } as CommandReturnValue; + } + }], + + patches: [], + + start() { + setTimeout(() => { + startConsoleCapture(); + + if (settings.store?.iconLocation === "toolbar") { + this.showToolbarIcon(); + } else { + this.showToolbarIcon(); + } + }, 0); + }, + + stop() { + stopConsoleCapture(); + this.removeToolbarIcon(); + }, + + showToolbarIcon() { + const toolbar = document.querySelector(".toolbar-3_r2xA"); + if (!toolbar || document.querySelector(".console-viewer-btn")) return; + + const button = document.createElement("div"); + button.classList.add("console-viewer-btn", "iconWrapper-2awDjA", "clickable-ZD7xvu"); + button.innerHTML = ConsoleLogIcon(); + button.addEventListener("click", openConsoleViewer); + button.setAttribute("role", "button"); + button.setAttribute("aria-label", "Console Viewer"); + button.setAttribute("tabindex", "0"); + + toolbar.prepend(button); + }, + + removeToolbarIcon() { + document.querySelector(".console-viewer-btn")?.remove(); + }, + + showChatIcon() { }, + removeChatIcon() { } +}); diff --git a/src/equicordplugins/consoleViewer/settings.tsx b/src/equicordplugins/consoleViewer/settings.tsx new file mode 100644 index 00000000..f33c9263 --- /dev/null +++ b/src/equicordplugins/consoleViewer/settings.tsx @@ -0,0 +1,40 @@ +import { definePluginSettings } from "@api/Settings"; +import { OptionType } from "@utils/types"; + +const settings = definePluginSettings({ + maxLogEntries: { + type: OptionType.NUMBER, + description: "Maximum number of log entries to keep (0 for unlimited)", + default: 1000, + }, + + iconLocation: { + description: "Where to show the Console Log icon", + type: OptionType.SELECT, + options: [ + { label: "Toolbar", value: "toolbar", default: true }, + { label: "Chat input", value: "chat" } + ], + restartNeeded: true + }, + + groupSimilarLogs: { + type: OptionType.BOOLEAN, + description: "Group similar consecutive logs", + default: true + }, + + preserveLogsBetweenSessions: { + type: OptionType.BOOLEAN, + description: "Save logs between plugin restarts or Discord sessions", + default: false + }, + + syntaxHighlighting: { + type: OptionType.BOOLEAN, + description: "Enable syntax highlighting for objects and code", + default: true + } +}); + +export default settings; diff --git a/src/equicordplugins/consoleViewer/styles.css b/src/equicordplugins/consoleViewer/styles.css new file mode 100644 index 00000000..dae33831 --- /dev/null +++ b/src/equicordplugins/consoleViewer/styles.css @@ -0,0 +1,289 @@ +.console-viewer-btn { + cursor: pointer; + color: var(--interactive-normal); + margin: 0 8px; +} + +.console-viewer-btn:hover { + color: var(--interactive-hover); +} + +.console-viewer-root { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 10000; +} + +.console-viewer-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; +} + +.console-viewer-backdrop { + position: fixed; + inset: 0; + background: rgba(0 0 0 / 85%); + z-index: 1000; +} + +.console-viewer-modal { + width: 800px; + height: 600px; + max-width: 90vw; + max-height: 90vh; + background-color: var(--background-primary); + border-radius: 8px; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 0 20px rgb(0 0 0 / 50%); + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 10001; + animation: console-viewer-slide-in 250ms ease; +} + +body.console-viewer-open { + overflow: hidden; +} + +.console-viewer-close-button { + background: none; + border: none; + color: var(--interactive-normal); + font-size: 24px; + font-weight: bold; + cursor: pointer; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + margin-left: 8px; +} + +.console-viewer-close-button:hover { + background-color: var(--background-modifier-hover); + color: var(--interactive-hover); +} + +.console-viewer-header { + padding: 16px; + border-bottom: 1px solid var(--background-modifier-accent); + display: flex; + justify-content: space-between; + align-items: center; +} + +.console-viewer-title { + font-size: 20px; + font-weight: 600; +} + +.console-viewer-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.console-viewer-toolbar { + padding: 8px; + border-bottom: 1px solid var(--background-modifier-accent); + display: flex; + justify-content: space-between; + align-items: center; +} + +.console-viewer-filters { + display: flex; + gap: 4px; +} + +.console-viewer-search { + width: 200px; + position: relative; +} + +.console-viewer-search-input { + width: 100%; + background-color: var(--background-tertiary); + border: none; + border-radius: 4px; + padding: 8px 10px; + color: var(--text-normal); + font-size: 14px; + outline: none; +} + +.console-viewer-search-input:focus { + box-shadow: 0 0 0 1px var(--brand-experiment); +} + +.console-viewer-search-clear { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--interactive-normal); + cursor: pointer; + font-size: 16px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.console-viewer-search-clear:hover { + color: var(--interactive-hover); +} + +.console-viewer-logs { + flex: 1; + overflow-y: auto; + padding: 8px; + font-family: Consolas, 'Courier New', monospace; + font-size: 14px; + background-color: var(--background-secondary); +} + +.console-viewer-log-entry { + padding: 4px 8px; + border-radius: 4px; + margin-bottom: 4px; + display: flex; + align-items: flex-start; + white-space: pre-wrap; + overflow-wrap: break-word; +} + +.console-viewer-log-time { + color: var(--text-muted); + margin-right: 8px; + flex-shrink: 0; +} + +.console-viewer-log-badge { + margin-right: 8px; + padding: 1px 4px; + border-radius: 4px; + font-size: 11px; + font-weight: 700; + flex-shrink: 0; + min-width: 50px; + text-align: center; +} + +.console-viewer-log-content { + flex: 1; + font-family: Consolas, 'Courier New', monospace; + overflow-wrap: break-word; +} + +.console-viewer-log-log { + background-color: var(--background-tertiary); +} + +.console-viewer-log-log .console-viewer-log-badge { + background-color: var(--interactive-normal); + color: var(--interactive-active); +} + +.console-viewer-log-info { + background-color: var(--info-warning-background); +} + +.console-viewer-log-info .console-viewer-log-badge { + background-color: var(--info-positive-foreground); + color: var(--info-positive-text); +} + +.console-viewer-log-warn { + background-color: var(--info-warning-background); +} + +.console-viewer-log-warn .console-viewer-log-badge { + background-color: var(--info-warning-foreground); + color: var(--info-warning-text); +} + +.console-viewer-log-error { + background-color: var(--info-danger-background); +} + +.console-viewer-log-error .console-viewer-log-badge { + background-color: var(--info-danger-foreground); + color: var(--info-danger-text); +} + +.console-viewer-arg { + margin-right: 6px; +} + +.console-viewer-no-logs { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + color: var(--text-muted); + font-style: italic; +} + +.console-viewer-status { + padding: 8px; + border-top: 1px solid var(--background-modifier-accent); + display: flex; + justify-content: space-between; + align-items: center; +} + +.console-viewer-count { + color: var(--text-muted); + font-size: 12px; +} + +.console-viewer-auto-scroll { + display: flex; + align-items: center; + gap: 4px; +} + +.console-viewer-error { + padding: 16px; + color: var(--text-danger); + text-align: center; + font-size: 16px; + background-color: var(--background-primary); + border-radius: 8px; +} + +@keyframes console-viewer-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes console-viewer-slide-in { + from { + transform: translateY(20px); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} diff --git a/src/equicordplugins/consoleViewer/utils/consoleLogger.ts b/src/equicordplugins/consoleViewer/utils/consoleLogger.ts new file mode 100644 index 00000000..ef777cb3 --- /dev/null +++ b/src/equicordplugins/consoleViewer/utils/consoleLogger.ts @@ -0,0 +1,116 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import settings from "../settings"; + +export interface LogEntry { + timestamp: number; + type: "log" | "info" | "warn" | "error"; + content: any; + stack?: string; +} + +const logs: LogEntry[] = []; +const originalConsole = { + log: console.log, + info: console.info, + warn: console.warn, + error: console.error +}; + +const consoleLogEvent = new Event("console-captured"); + +const DEFAULT_MAX_LOG_ENTRIES = 1000; +let isInitialized = false; + +export function startConsoleCapture() { + isInitialized = true; + + // Wrap console methods + console.log = function (...args) { + captureLog("log", ...args); + return originalConsole.log.apply(console, args); + }; + + console.info = function (...args) { + captureLog("info", ...args); + return originalConsole.info.apply(console, args); + }; + + console.warn = function (...args) { + captureLog("warn", ...args); + return originalConsole.warn.apply(console, args); + }; + + console.error = function (...args) { + captureLog("error", ...args); + return originalConsole.error.apply(console, args); + }; +} + +export function stopConsoleCapture() { + console.log = originalConsole.log; + console.info = originalConsole.info; + console.warn = originalConsole.warn; + console.error = originalConsole.error; +} + +function getSetting(key: string, defaultValue: T): T { + try { + if (!isInitialized || !settings.store) return defaultValue; + return settings.store[key] ?? defaultValue; + } catch (e) { + return defaultValue; + } +} + +function captureLog(type: "log" | "info" | "warn" | "error", ...args) { + // Create log entry + const entry: LogEntry = { + timestamp: Date.now(), + type, + content: args.length === 1 ? args[0] : args, + }; + + if (type === "error" && args[0] instanceof Error) { + entry.stack = args[0].stack; + } + + logs.push(entry); + + const maxEntries = getSetting("maxLogEntries", DEFAULT_MAX_LOG_ENTRIES); + if (maxEntries > 0 && logs.length > maxEntries) { + logs.splice(0, logs.length - maxEntries); + } + + window.dispatchEvent(consoleLogEvent); +} + +export function getCapturedLogs(): LogEntry[] { + return logs; +} + +export function clearLogs() { + logs.length = 0; + window.dispatchEvent(consoleLogEvent); +} + +export function downloadLogs() { + const content = JSON.stringify(logs, null, 2); + const blob = new Blob([content], { type: "application/json" }); + const url = URL.createObjectURL(blob); + + const filename = `console-logs-${new Date().toISOString().replace(/:/g, "-")}.json`; + + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + + setTimeout(() => { + URL.revokeObjectURL(url); + }, 60000); +}