Add files via upload

This commit is contained in:
StealTech 2025-03-18 16:53:18 -05:00 committed by GitHub
parent 7c5f01bf11
commit 04f3a22c9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 870 additions and 0 deletions

View file

@ -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 `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H20C21.1 20 22 19.1 22 18V6C22 4.9 21.1 4 20 4Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 12L10.5 14.5L8 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 17H13.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>`;
}

View file

@ -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 = () => (
<div className="console-viewer-error">Failed to load Console Viewer</div>
);
function ConsoleViewerModal() {
const [logs, setLogs] = React.useState<LogEntry[]>([]);
const [search, setSearch] = React.useState("");
const [filter, setFilter] = React.useState("all");
const [autoScroll, setAutoScroll] = React.useState(true);
const logContainerRef = React.useRef<HTMLDivElement>(null);
const modalRef = React.useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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) => (
<span key={i} className="console-viewer-arg">
{typeof item === "object" && item !== null
? JSON.stringify(item, null, 2)
: String(item ?? "")}
</span>
));
} 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 (
<div key={index} className={`console-viewer-log-entry console-viewer-log-${log.type}`}>
<div className="console-viewer-log-time">
{timeFormat.format(log.timestamp)}
</div>
<div className="console-viewer-log-badge">{log.type.toUpperCase()}</div>
<div className="console-viewer-log-content">{contentDisplay}</div>
</div>
);
};
const toggleAutoScroll = React.useCallback(() => {
setAutoScroll(prev => !prev);
}, []);
const handleClear = React.useCallback(() => {
clearLogs();
setLogs([]);
}, []);
const clearSearch = React.useCallback(() => {
setSearch("");
}, []);
return (
<>
<div
className="console-viewer-backdrop"
onClick={closeConsoleViewer}
/>
<div className="console-viewer-modal" ref={modalRef}>
<div className="console-viewer-header">
<div className="console-viewer-title">Console Viewer</div>
<div className="console-viewer-actions">
<Button
color={Button.Colors.PRIMARY}
look={Button.Looks.OUTLINED}
size={Button.Sizes.SMALL}
onClick={downloadLogs}
>
Export
</Button>
<Button
color={Button.Colors.RED}
look={Button.Looks.OUTLINED}
size={Button.Sizes.SMALL}
onClick={handleClear}
>
Clear
</Button>
<button
className="console-viewer-close-button"
onClick={closeConsoleViewer}
aria-label="Close"
>
×
</button>
</div>
</div>
<div className="console-viewer-toolbar">
<div className="console-viewer-filters">
{["all", "log", "info", "warn", "error"].map(type => (
<Button
key={type}
size={Button.Sizes.SMALL}
color={filter === type ? Button.Colors.PRIMARY : Button.Colors.TRANSPARENT}
onClick={() => setFilter(type)}
>
{type === "all" ? "All" :
type === "log" ? "Log" :
type === "info" ? "Info" :
type === "warn" ? "Warning" : "Error"}
</Button>
))}
</div>
<div className="console-viewer-search">
<input
className="console-viewer-search-input"
type="text"
placeholder="Search logs..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
{search && (
<button
className="console-viewer-search-clear"
onClick={clearSearch}
>
×
</button>
)}
</div>
</div>
<div
className="console-viewer-logs"
ref={logContainerRef}
onScroll={handleScroll}
>
{filteredLogs.length > 0 ? (
filteredLogs.map(renderLogEntry)
) : (
<div className="console-viewer-no-logs">
{search || filter !== "all" ? "No logs match your filters" : "No console logs captured yet"}
</div>
)}
</div>
<div className="console-viewer-status">
<div className="console-viewer-count">
{filteredLogs.length} {filteredLogs.length === 1 ? "log" : "logs"}
{(search || filter !== "all") ? " (filtered)" : ""}
</div>
<div className="console-viewer-auto-scroll">
<label className="auto-scroll-label">
<input
type="checkbox"
checked={autoScroll}
onChange={toggleAutoScroll}
/>
Auto-scroll
</label>
</div>
</div>
</div>
</>
);
}
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(
<ErrorBoundary fallback={ErrorFallback}>
<ConsoleViewerModal />
</ErrorBoundary>,
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;
}
}

View file

@ -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() { }
});

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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<T>(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);
}