WebSocket logger with auto-discovery of new message types
// ==UserScript==
// @name MWI WebSocket Logger
// @namespace http://tampermonkey.net/
// @version 0.0.3
// @description WebSocket logger with auto-discovery of new message types
// @author Star
// @license CC-BY-NC-SA-4.0
// @match https://www.milkywayidle.com/*
// @match https://test.milkywayidle.com/*
// @grant GM_registerMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addStyle
// @grant unsafeWindow
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
// --- STYLES ---
GM_addStyle(`
:root { --ws-bg: #181f2e; --ws-bg-alt: #232b3a; --ws-border: #3a3a7a; --ws-text: #dde; --ws-accent: #a0a8ff; --ws-recv: #a0c8ff; --ws-send: #ffb86c; }
.ws-btn { padding: 5px 12px; border-radius: 4px; border: 1px solid #4a4aaa; background: #1a1a4a; color: var(--ws-accent); cursor: pointer; font-size: 12px; font-weight: bold; }
.ws-btn:hover { background: #2a2a6a; }
#wslogger-settings-overlay { position: fixed; inset: 0; z-index: 99999; background: rgba(0,0,0,0.65); display: flex; align-items: center; justify-content: center; }
#wslogger-settings-modal { background: #111828; border: 1px solid var(--ws-border); border-radius: 8px; padding: 20px 24px; width: 380px; font: 13px/1.5 'Segoe UI', sans-serif; color: var(--ws-text); box-shadow: 0 8px 32px rgba(0,0,0,0.7); }
#wslogger-types-list { list-style: none; padding: 8px; margin: 0 0 10px 0; max-height: 160px; overflow-y: auto; background: #0d1420; border: 1px solid var(--ws-border); border-radius: 4px; }
/* Auto-gray out unchecked types */
.ws-type-label { flex: 1; cursor: pointer; display: flex; align-items: center; gap: 8px; }
.ws-type-label span { font-family: monospace; font-size: 12px; transition: color 0.15s ease; }
.ws-type-label input:not(:checked) + span { color: #6b7280; }
.ws-type-label input:checked + span { color: #fff; }
#wslogger-log-modal { position: fixed; z-index: 99999; width: 420px; height: 420px; background: var(--ws-bg); border: 2px solid var(--ws-border); border-radius: 10px; display: flex; flex-direction: column; resize: both; overflow: hidden; box-shadow: -4px 0 24px #000a; }
.wslogger-header { padding: 12px 16px; background: var(--ws-bg-alt); border-bottom: 1px solid #2a2a5a; display: flex; align-items: center; justify-content: space-between; cursor: move; user-select: none; }
#wslogger-log-icon { position: fixed; z-index: 99999; width: 44px; height: 44px; background: var(--ws-bg-alt); border: 2px solid var(--ws-send); border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 12px #000a; cursor: pointer; }
.ws-log-item { margin: 0 12px 10px; padding: 8px 10px; background: var(--ws-bg-alt); border-radius: 6px; border: 1px solid #2a2a5a; }
.ws-log-header { display: flex; align-items: center; gap: 10px; cursor: pointer; }
/* Lock toggle width to prevent layout shifts */
.ws-log-toggle { color: #aaa; font-size: 14px; font-family: monospace; display: inline-block; width: 28px; text-align: center; margin-left: auto; flex-shrink: 0; user-select: none; }
/* Interactive JSON Viewer Styles & Overflow Fixes */
.ws-log-details { font-family: monospace; font-size: 12px; margin-top: 2px; word-break: break-all; line-height: 1.3; }
.ws-log-details details { margin: 0 0 0 4px; padding: 0; }
.ws-log-details summary { cursor: pointer; color: #667; user-select: none; transition: color 0.2s; outline: none; margin: 0; padding: 0; }
.ws-log-details summary:hover { color: #a0a8ff; }
.ws-log-details summary::marker { font-size: 10px; }
.json-key { color: var(--ws-accent); }
.json-str { color: var(--ws-recv); }
.json-num { color: var(--ws-send); }
.json-bool { color: #ff79c6; }
.json-null { color: #8be9fd; }
.json-bracket { color: var(--ws-text); }
`);
// --- PERSISTENT SETTINGS ---
const STORAGE_KEY = 'mwi_ws_logger_settings';
const DEFAULT_TYPES = { 'action_completed': true, 'active_player_count_updated': true, 'chat_message_received': true, 'get_market_item_order_books': true, 'init_character_data': true, 'init_client_data': true, 'items_updated': true, 'market_item_order_books_updated': true, 'market_listings_updated': true, 'post_market_order': true };
let settings = { logRecv: false, logSend: false, types: { ...DEFAULT_TYPES }, knownTypes: Object.keys(DEFAULT_TYPES), showLogModal: false, modalPos: { left: null, top: null }, iconPos: { left: null, top: null }, maxLogs: 100 };
const recentLogs = [];
function loadSettings() {
try {
const s = JSON.parse(GM_getValue(STORAGE_KEY, '{}'));
if (Array.isArray(s.types)) {
const typesDict = {};
s.types.forEach(t => typesDict[t] = true);
s.types = typesDict;
}
settings = { ...settings, ...s };
} catch (_) {}
}
function saveSettings() { GM_setValue(STORAGE_KEY, JSON.stringify(settings)); }
// --- DRAG LOGIC ABSTRACTION ---
function makeDraggable(element, handle, posKey, onClick) {
let isDragging = false, moved = false, startX, startY, initialLeft, initialTop;
handle.addEventListener('mousedown', e => {
if (e.button !== 0) return;
isDragging = true; moved = false;
startX = e.clientX; startY = e.clientY;
// Read absolute pixel position before drag starts
const rect = element.getBoundingClientRect();
initialLeft = rect.left;
initialTop = rect.top;
// Temporarily lock to top/left for 1:1 smooth mouse tracking
element.style.left = `${initialLeft}px`;
element.style.top = `${initialTop}px`;
element.style.right = 'auto'; element.style.bottom = 'auto';
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', e => {
if (!isDragging) return;
moved = true;
const maxLeft = window.innerWidth - element.offsetWidth;
const maxTop = window.innerHeight - element.offsetHeight;
// Clamp bounds to prevent dragging off screen
element.style.left = `${Math.max(0, Math.min(initialLeft + e.clientX - startX, maxLeft))}px`;
element.style.top = `${Math.max(0, Math.min(initialTop + e.clientY - startY, maxTop))}px`;
});
document.addEventListener('mouseup', e => {
if (!isDragging) return;
isDragging = false;
document.body.style.userSelect = '';
if (moved && posKey) {
// Find distance to all 4 edges
const rect = element.getBoundingClientRect();
const distRight = window.innerWidth - rect.right;
const distBottom = window.innerHeight - rect.bottom;
// Pick the closest edge to anchor to
const anchorX = rect.left < distRight ? 'left' : 'right';
const anchorY = rect.top < distBottom ? 'top' : 'bottom';
const x = Math.max(0, anchorX === 'left' ? rect.left : distRight);
const y = Math.max(0, anchorY === 'top' ? rect.top : distBottom);
// Apply permanent CSS anchors
element.style[anchorX] = `${x}px`;
element.style[anchorX === 'left' ? 'right' : 'left'] = 'auto';
element.style[anchorY] = `${y}px`;
element.style[anchorY === 'top' ? 'bottom' : 'top'] = 'auto';
// Save anchor setup to persistent storage
settings[posKey] = { anchorX, x, anchorY, y };
saveSettings();
} else if (!moved && onClick) {
onClick();
}
});
}
// --- INTERACTIVE JSON VIEWER ---
function createJSONViewer(data) {
if (typeof data !== 'object' || data === null) {
if (typeof data === 'string') return `<span class="json-str">"${escapeHtml(data)}"</span>`;
if (typeof data === 'number') return `<span class="json-num">${data}</span>`;
if (typeof data === 'boolean') return `<span class="json-bool">${data}</span>`;
return `<span class="json-null">${data}</span>`;
}
const isArr = Array.isArray(data);
const keys = Object.keys(data);
if (keys.length === 0) return `<span class="json-bracket">${isArr ? '[]' : '{}'}</span>`;
let html = `<details open><summary><span class="json-bracket">${isArr ? '[' : '{'}</span><span style="font-size:10px;margin-left:6px;color:#888;">${keys.length} items</span></summary><div style="padding-left:14px;border-left:1px dashed var(--ws-border);margin-left:6px;">`;
keys.forEach((key, i) => {
const isLast = i === keys.length - 1;
const keyHtml = isArr ? '' : `<span class="json-key">"${escapeHtml(key)}"</span>: `;
html += `<div style="margin:2px 0;">${keyHtml}${createJSONViewer(data[key])}${isLast ? '' : '<span class="json-bracket">,</span>'}</div>`;
});
html += `</div><span class="json-bracket">${isArr ? ']' : '}'}</span></details>`;
return html;
}
// --- LOG MODAL & ICON UI ---
function createLogElement(log) {
const wrapper = document.createElement('div');
wrapper.className = 'ws-log-item';
wrapper.innerHTML = `
<div class="ws-log-header">
<span style="color:${log.dir === 'recv' ? 'var(--ws-recv)' : 'var(--ws-send)'};font-weight:bold;flex-shrink:0;">${log.dir === 'recv' ? '◀ RECV' : '▶ SEND'}</span>
<span style="color:var(--ws-accent);font-family:monospace;word-break:break-all;">${log.type}</span>
<span class="ws-log-toggle">[+]</span>
</div>
<div class="ws-log-details" style="display:none;">${createJSONViewer(log.data)}</div>
`;
const header = wrapper.querySelector('.ws-log-header');
const details = wrapper.querySelector('.ws-log-details');
const toggle = wrapper.querySelector('.ws-log-toggle');
let expanded = false;
header.onclick = () => {
expanded = !expanded;
details.style.display = expanded ? 'block' : 'none';
toggle.textContent = expanded ? '[-]' : '[+]';
};
return wrapper;
}
function renderFullLogUI() {
const content = document.getElementById('wslogger-log-content');
if (!content) return;
content.innerHTML = '';
if (recentLogs.length === 0) {
content.innerHTML = '<div class="ws-no-logs" style="color:#888;text-align:center;margin-top:40px;">No log messages yet.</div>';
return;
}
recentLogs.forEach(log => content.appendChild(createLogElement(log)));
content.scrollTop = content.scrollHeight;
}
function appendNewLogUI(log) {
const content = document.getElementById('wslogger-log-content');
if (!content) return;
const noLogsMsg = content.querySelector('.ws-no-logs');
if (noLogsMsg) noLogsMsg.remove();
// Smart auto-scroll logic
const isAtBottom = content.scrollHeight - content.scrollTop - content.clientHeight < 50;
content.appendChild(createLogElement(log));
// Enforce max log limit visibly
while (settings.maxLogs !== -1 && content.children.length > settings.maxLogs) {
content.removeChild(content.firstChild);
}
if (isAtBottom) content.scrollTop = content.scrollHeight;
}
function updateHeaderStatus() {
const statusBar = document.getElementById('wslogger-status-bar');
if (!statusBar) return;
const getDot = (active) => `<span style="color:${active ? '#50fa7b' : '#ff5555'}; margin-right:3px;">●</span>`;
statusBar.innerHTML = `
<div title="Log Received Messages">${getDot(settings.logRecv)}RECV</div>
<div title="Log Sent Messages">${getDot(settings.logSend)}SEND</div>
`;
}
function showLogModal() {
closeLogUI();
const modal = document.createElement('div');
modal.id = 'wslogger-log-modal';
modal.innerHTML = `
<div class="wslogger-header">
<div style="display:flex; align-items:center; gap:10px;">
<span style="font-size:15px; color:var(--ws-accent); font-weight:bold;">📝 WebSocket Logs</span>
<div id="wslogger-status-bar" style="display:flex; gap:8px; font-size:10px; color:#888; border-left:1px solid #333; padding-left:10px; margin-left:5px;">
</div>
</div>
<div style="display:flex; gap:8px;">
<button id="wslogger-log-settings" style="background:none; border:none; color:var(--ws-accent); font-size:18px; cursor:pointer;">⚙</button>
<button id="wslogger-log-minimize" style="background:none; border:none; color:var(--ws-accent); font-size:18px; cursor:pointer;">✕</button>
</div>
</div>
<div id="wslogger-log-content" style="flex:1; overflow-y:scroll; overflow-x:hidden; padding:10px 0;"></div>
`;
if (settings.modalPos.left) {
modal.style.left = `${settings.modalPos.left}px`;
modal.style.top = `${settings.modalPos.top}px`;
} else {
modal.style.right = '40px'; modal.style.top = '60px';
}
document.body.appendChild(modal);
if (settings.modalPos.left !== null) {
const maxLeft = window.innerWidth - modal.offsetWidth;
const maxTop = window.innerHeight - modal.offsetHeight;
modal.style.left = `${Math.max(0, Math.min(parseInt(modal.style.left), maxLeft))}px`;
modal.style.top = `${Math.max(0, Math.min(parseInt(modal.style.top), maxTop))}px`;
}
renderFullLogUI();
updateHeaderStatus();
modal.querySelector('#wslogger-log-minimize').onclick = hideLogModalToIcon;
modal.querySelector('#wslogger-log-settings').onclick = toggleSettingsModal;
makeDraggable(modal, modal.querySelector('.wslogger-header'), 'modalPos');
settings.showLogModal = true; saveSettings();
}
function hideLogModalToIcon() {
closeLogUI();
const icon = document.createElement('div');
icon.id = 'wslogger-log-icon';
icon.title = 'Show WebSocket Logs';
icon.innerHTML = '<span style="font-size:22px;color:var(--ws-send);">⚠</span>';
if (settings.iconPos.left) {
icon.style.left = `${settings.iconPos.left}px`;
icon.style.top = `${settings.iconPos.top}px`;
} else {
icon.style.right = '32px'; icon.style.bottom = '32px';
}
document.body.appendChild(icon);
if (settings.iconPos.left !== null) {
const maxLeft = window.innerWidth - icon.offsetWidth;
const maxTop = window.innerHeight - icon.offsetHeight;
icon.style.left = `${Math.max(0, Math.min(parseInt(icon.style.left), maxLeft))}px`;
icon.style.top = `${Math.max(0, Math.min(parseInt(icon.style.top), maxTop))}px`;
}
makeDraggable(icon, icon, 'iconPos', showLogModal);
settings.showLogModal = false; saveSettings();
}
function closeLogUI() {
document.getElementById('wslogger-log-modal')?.remove();
document.getElementById('wslogger-log-icon')?.remove();
}
// --- SETTINGS MODAL ---
function toggleSettingsModal() {
if (document.getElementById('wslogger-settings-overlay')) {
document.getElementById('wslogger-settings-overlay').remove();
return;
}
const overlay = document.createElement('div');
overlay.id = 'wslogger-settings-overlay';
overlay.innerHTML = `
<div id="wslogger-settings-modal">
<h3 style="margin:0 0 16px;border-bottom:1px solid #2a2a5a;padding-bottom:10px;">⚙ WS Logger Settings</h3>
<div style="display:flex; flex-direction:column; gap:10px; margin-bottom:20px;">
<label style="display:flex; align-items:center; gap:8px; cursor:pointer;">
<input type="checkbox" id="wslog-recv" ${settings.logRecv ? 'checked' : ''}> Log received messages
</label>
<label style="display:flex; align-items:center; gap:8px; cursor:pointer;">
<input type="checkbox" id="wslog-send" ${settings.logSend ? 'checked' : ''}> Log sent messages
</label>
<label style="display:flex; align-items:center; gap:8px;">
Max logs (-1 = unlimited):
<input type="number" id="wslog-max" value="${settings.maxLogs}" min="-1" style="width:60px; padding:2px 6px; border-radius:4px; border:1px solid var(--ws-border); background:var(--ws-bg); color:#fff; font-family:monospace;">
</label>
</div>
<div style="font-size:12px;color:var(--ws-accent);margin-bottom:4px;">Discovered Types:</div>
<ul id="wslogger-types-list"></ul>
<div style="display:flex;gap:6px;margin-bottom:16px;">
<input id="wslog-type-input" type="text" placeholder="Add type..." style="flex:1;padding:5px;border-radius:4px;border:1px solid var(--ws-border);background:var(--ws-bg);color:#fff;">
<button class="ws-btn" id="wslog-type-add">Add</button>
</div>
<div style="display:flex;justify-content:flex-end;"><button class="ws-btn" id="wslog-close">Close</button></div>
</div>
`;
document.body.appendChild(overlay);
function renderTypes() {
const ul = overlay.querySelector('#wslogger-types-list');
ul.innerHTML = '';
[...settings.knownTypes].sort().forEach(type => {
const li = document.createElement('li');
li.style = 'display:flex;gap:6px;margin-bottom:4px;align-items:center;';
li.innerHTML = `
<label class="ws-type-label">
<input type="checkbox" ${settings.types[type] ? 'checked' : ''}>
<span>${type}</span>
</label>
<button style="background:none;border:none;color:#d55;cursor:pointer;" title="Remove">✕</button>
`;
li.querySelector('input').onchange = e => { settings.types[type] = e.target.checked; saveSettings(); };
li.querySelector('button').onclick = () => {
settings.knownTypes = settings.knownTypes.filter(t => t !== type);
delete settings.types[type]; saveSettings(); renderTypes();
};
ul.appendChild(li);
});
}
renderTypes();
document.addEventListener('mwi_ws_types_updated', renderTypes);
overlay.querySelector('#wslog-type-add').onclick = () => {
const val = overlay.querySelector('#wslog-type-input').value.trim();
if (val && !settings.knownTypes.includes(val)) {
settings.knownTypes.push(val); settings.types[val] = true;
saveSettings(); renderTypes(); overlay.querySelector('#wslog-type-input').value = '';
}
};
overlay.querySelector('#wslog-recv').onchange = e => {
settings.logRecv = e.target.checked;
saveSettings();
updateHeaderStatus();
};
overlay.querySelector('#wslog-send').onchange = e => {
settings.logSend = e.target.checked;
saveSettings();
updateHeaderStatus();
};
overlay.querySelector('#wslog-max').onchange = e => { settings.maxLogs = Math.max(-1, parseInt(e.target.value) || -1); saveSettings(); };
const closeMenu = () => { document.removeEventListener('mwi_ws_types_updated', renderTypes); overlay.remove(); };
overlay.querySelector('#wslog-close').onclick = closeMenu;
overlay.addEventListener('mousedown', e => { if (e.target === overlay) closeMenu(); });
}
// --- WEBSOCKET WRAPPER ---
function installWebSocketWrapper() {
const targetWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
if (!targetWindow.WebSocket || targetWindow.WebSocket.__wsLoggerWrapped) return;
class WSLogger extends targetWindow.WebSocket {
constructor(...args) {
super(...args);
this.addEventListener('message', e => this.handleMessage(e.data, 'recv', settings.logRecv));
}
send(data) {
this.handleMessage(data, 'send', settings.logSend);
return super.send(data);
}
handleMessage(data, dir, shouldLogGlobal) {
try {
const msg = typeof data === 'string' ? JSON.parse(data) : data;
if (msg?.type) {
if (!settings.knownTypes.includes(msg.type)) {
settings.knownTypes.push(msg.type);
settings.types[msg.type] = DEFAULT_TYPES[msg.type] ?? false;
saveSettings();
document.dispatchEvent(new CustomEvent('mwi_ws_types_updated'));
}
if (shouldLogGlobal && settings.types[msg.type]) {
const logEntry = { dir, type: msg.type, data: msg, time: new Date().toLocaleTimeString() };
recentLogs.push(logEntry);
if (settings.maxLogs !== -1 && recentLogs.length > settings.maxLogs) recentLogs.shift();
appendNewLogUI(logEntry);
console.log(`%c[WS] ${dir === 'recv' ? '◀' : '▶'} ${msg.type}`, `color: ${dir === 'recv' ? '#a0c8ff' : '#ffb86c'}; font-weight: bold;`, msg);
}
}
} catch (_) {}
}
}
WSLogger.__wsLoggerWrapped = true;
try { targetWindow.WebSocket = WSLogger; } catch (_) {}
}
// --- UTILS & INIT ---
function escapeHtml(str) {
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
return str.replace(/[&<>"']/g, m => map[m]);
}
// --- PREVENT STRANDING ON WINDOW RESIZE ---
window.addEventListener('resize', () => {
const enforceBounds = (element, posKey) => {
if (!element) return;
const maxLeft = window.innerWidth - element.offsetWidth;
const maxTop = window.innerHeight - element.offsetHeight;
const currentLeft = parseInt(element.style.left) || 0;
const currentTop = parseInt(element.style.top) || 0;
const newLeft = Math.max(0, Math.min(currentLeft, maxLeft));
const newTop = Math.max(0, Math.min(currentTop, maxTop));
if (currentLeft !== newLeft || currentTop !== newTop) {
element.style.left = `${newLeft}px`;
element.style.top = `${newTop}px`;
settings[posKey] = { left: newLeft, top: newTop };
saveSettings();
}
};
enforceBounds(document.getElementById('wslogger-log-modal'), 'modalPos');
enforceBounds(document.getElementById('wslogger-log-icon'), 'iconPos');
});
function init() {
loadSettings();
installWebSocketWrapper();
if (typeof GM_registerMenuCommand !== 'undefined') GM_registerMenuCommand('⚙ WS Logger Settings', toggleSettingsModal);
window.addEventListener('keydown', e => {
if (e.key === 'F7' && !['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) {
e.preventDefault(); toggleSettingsModal();
}
});
settings.showLogModal ? showLogModal() : hideLogModalToIcon();
}
document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();
})();