Enables you to open developer tools on mobile
// ==UserScript==
// @name Devtool Mobile Pro
// @namespace https://greasyfork.org/users/1426529
// @version 2.1.0
// @description Enables you to open developer tools on mobile
// @match *://*/*
// @grant none
// @license MIT
// @run-at document-start
// ==/UserScript==
(function () {
"use strict";
/* =========================================================
* 0. Trusted Types support
* ========================================================= */
let ttPolicy = null;
try {
if (window.trustedTypes && trustedTypes.createPolicy) {
ttPolicy = trustedTypes.createPolicy("devtool-mobile-" + Date.now() + "-" + Math.random().toString(36).slice(2), {
createHTML: (s) => s,
createScript: (s) => s,
createScriptURL: (s) => s,
});
}
} catch (_) {}
function tt(html) {
return ttPolicy ? ttPolicy.createHTML(html) : html;
}
function ttScript(s) {
return ttPolicy ? ttPolicy.createScript(s) : s;
}
function ttScriptURL(s) {
return ttPolicy ? ttPolicy.createScriptURL(s) : s;
}
// Best-effort only helps if the page hasn't already claimed a policy named "default".
try {
if (window.trustedTypes && trustedTypes.createPolicy) {
trustedTypes.createPolicy("default", { createHTML: (s) => s, createScript: (s) => s, createScriptURL: (s) => s });
}
} catch (_) {}
/* =========================================================
* 1. Gesture settings
* ========================================================= */
const SETTINGS_KEY = "devtoolMobileGestureCfg_v1";
const DEFAULT_GESTURES = {
eruda: { fingers: 2, taps: 3 },
inspector: { fingers: 2, taps: 4 },
};
function loadGestures() {
try {
const raw = localStorage.getItem(SETTINGS_KEY);
if (!raw) return { ...DEFAULT_GESTURES };
const parsed = JSON.parse(raw);
return {
eruda: { fingers: parsed?.eruda?.fingers || 2, taps: parsed?.eruda?.taps || 3 },
inspector: { fingers: parsed?.inspector?.fingers || 2, taps: parsed?.inspector?.taps || 4 },
};
} catch (_) {
return { ...DEFAULT_GESTURES };
}
}
function saveGestures(cfg) {
try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(cfg)); } catch (_) {}
}
let gestureCfg = loadGestures();
function gesturesCollide(cfg) {
return cfg.eruda.fingers === cfg.inspector.fingers && cfg.eruda.taps === cfg.inspector.taps;
}
/* =========================================================
* 2. Gesture dispatcher
* ========================================================= */
const TAP_WINDOW_MS = 550;
const tapGroups = {};
function actionsForFingerCount(n) {
const list = [];
if (gestureCfg.eruda.fingers === n) list.push({ type: "eruda", taps: gestureCfg.eruda.taps });
if (gestureCfg.inspector.fingers === n) list.push({ type: "inspector", taps: gestureCfg.inspector.taps });
return list;
}
function fireGestureAction(type) {
if (type === "eruda") toggleEruda();
else if (type === "inspector") toggleInspector();
}
document.addEventListener(
"touchstart",
(e) => {
const n = e.touches.length;
if (n < 2) return;
const actions = actionsForFingerCount(n);
if (!actions.length) return;
if (!tapGroups[n]) tapGroups[n] = { count: 0, timer: null };
const g = tapGroups[n];
g.count++;
if (g.timer) clearTimeout(g.timer);
g.timer = setTimeout(() => {
const taps = g.count;
g.count = 0;
const match = actions.find((a) => a.taps === taps);
if (match) fireGestureAction(match.type);
}, TAP_WINDOW_MS);
},
{ capture: true, passive: true }
);
const KEYBOARD_SHORTCUT_KEY = "devtoolMobileKeyboardEnabled_v1";
function loadKeyboardEnabled() {
try {
const v = localStorage.getItem(KEYBOARD_SHORTCUT_KEY);
return v === null ? true : v === "1";
} catch (_) {
return true;
}
}
function saveKeyboardEnabled(v) {
try { localStorage.setItem(KEYBOARD_SHORTCUT_KEY, v ? "1" : "0"); } catch (_) {}
}
let keyboardShortcutsEnabled = loadKeyboardEnabled();
// Ctrl+Shift+I (devtools) or Ctrl+Shift+R (hard reload) — browsers already reserve those
// Ctrl+Alt+E / Ctrl+Alt+I are not commonly bound by browsers/OS.
document.addEventListener("keydown", (e) => {
if (!keyboardShortcutsEnabled) return;
if (e.ctrlKey && e.altKey && !e.shiftKey && e.key.toLowerCase() === "e") { e.preventDefault(); toggleEruda(); }
if (e.ctrlKey && e.altKey && !e.shiftKey && e.key.toLowerCase() === "i") { e.preventDefault(); toggleInspector(); }
});
/* =========================================================
* 3. Eruda — reopens automatically after a reload if it was open before.
* ========================================================= */
const ERUDA_STATE_KEY = "devtoolMobileErudaOpen_v1";
let erudaLoaded = false;
let erudaScriptEl = null;
function toggleEruda() {
erudaLoaded ? unloadEruda() : loadEruda();
}
function loadEruda() {
if (erudaLoaded) return;
erudaScriptEl = document.createElement("script");
erudaScriptEl.src = ttScriptURL("//cdn.jsdelivr.net/npm/eruda");
erudaScriptEl.onload = () => {
try {
window.eruda.init();
window.eruda.show();
erudaLoaded = true;
try { localStorage.setItem(ERUDA_STATE_KEY, "1"); } catch (_) {}
} catch (_) {}
};
erudaScriptEl.onerror = () => {
console.warn("[devtool-mobile] eruda failed to load (blocked CDN or CSP). Falling back to built-in inspector.");
erudaScriptEl = null;
showInspector();
};
(document.body || document.documentElement).appendChild(erudaScriptEl);
}
function unloadEruda() {
if (!erudaLoaded) return;
try { window.eruda && window.eruda.destroy(); } catch (_) {}
if (erudaScriptEl?.parentNode) erudaScriptEl.parentNode.removeChild(erudaScriptEl);
erudaScriptEl = null;
erudaLoaded = false;
try { localStorage.setItem(ERUDA_STATE_KEY, "0"); } catch (_) {}
try {
document.getElementById("eruda")?.remove();
delete window.eruda;
} catch (_) {}
}
/* =========================================================
* 4. Console capture + REPL
* ========================================================= */
const MAX_LOG_ENTRIES = 500;
const logBuffer = [];
const groupState = { indent: 0 };
const timeLabels = new Map();
const countLabels = new Map();
function argsToText(args) {
return args
.map((a) => {
if (typeof a === "string") return a;
try { return JSON.stringify(a); } catch (_) { return String(a); }
})
.join(" ");
}
function withIndent(text) {
return groupState.indent > 0 ? " ".repeat(groupState.indent) + text : text;
}
function pushLog(tag, args) {
const text = withIndent(argsToText(args));
logBuffer.push({ time: new Date().toLocaleTimeString(), tag, text });
if (logBuffer.length > MAX_LOG_ENTRIES) logBuffer.shift();
if (panelVisible && activeTab === "console") renderConsole();
}
function pushTableLog(data) {
const tableData = consoleTableData(data);
logBuffer.push({ time: new Date().toLocaleTimeString(), tag: "table", text: "[table]", tableData });
if (logBuffer.length > MAX_LOG_ENTRIES) logBuffer.shift();
if (panelVisible && activeTab === "console") renderConsole();
}
function consoleTableData(data) {
if (data == null || typeof data !== "object") return { headers: ["Value"], rows: [[String(data)]] };
const rows = Array.isArray(data) ? data.map((v, i) => [String(i), v]) : Object.entries(data);
const colSet = new Set();
let hasPrimitiveValues = false;
rows.forEach(([, v]) => {
if (v && typeof v === "object") Object.keys(v).forEach((k) => colSet.add(k));
else hasPrimitiveValues = true;
});
const cols = Array.from(colSet);
const headers = ["(index)", ...cols, ...(hasPrimitiveValues ? ["Values"] : [])];
const dataRows = rows.map(([idx, v]) => {
const line = [idx];
cols.forEach((c) => {
const cell = v && typeof v === "object" && c in v ? v[c] : "";
line.push(cell === undefined ? "" : typeof cell === "object" ? JSON.stringify(cell) : String(cell));
});
if (hasPrimitiveValues) line.push(v && typeof v === "object" ? "" : String(v));
return line;
});
return { headers, rows: dataRows };
}
function logEntryToText(l) {
if (l.tag === "table" && l.tableData) {
return [l.tableData.headers.join("\t"), ...l.tableData.rows.map((r) => r.join("\t"))].join("\n");
}
return l.text;
}
["log", "warn", "error", "info", "debug"].forEach((method) => {
const orig = console[method];
console[method] = function (...args) {
pushLog(method, args);
return orig ? orig.apply(console, args) : undefined;
};
});
const origClear = console.clear;
console.clear = function (...args) {
logBuffer.length = 0;
if (panelVisible && activeTab === "console") renderConsole();
return origClear ? origClear.apply(console, args) : undefined;
};
const origDir = console.dir;
console.dir = function (...args) {
pushLog("log", args);
return origDir ? origDir.apply(console, args) : undefined;
};
const origTable = console.table;
console.table = function (data, ...rest) {
pushTableLog(data);
return origTable ? origTable.apply(console, [data, ...rest]) : undefined;
};
console.time = function (label = "default") {
timeLabels.set(label, performance.now());
};
console.timeEnd = function (label = "default") {
if (!timeLabels.has(label)) { pushLog("warn", [`Timer '${label}' does not exist`]); return; }
const elapsed = performance.now() - timeLabels.get(label);
timeLabels.delete(label);
pushLog("log", [`${label}: ${elapsed.toFixed(2)}ms`]);
};
console.timeLog = function (label = "default", ...args) {
if (!timeLabels.has(label)) { pushLog("warn", [`Timer '${label}' does not exist`]); return; }
const elapsed = performance.now() - timeLabels.get(label);
pushLog("log", [`${label}: ${elapsed.toFixed(2)}ms`, ...args]);
};
console.group = console.groupCollapsed = function (...args) {
pushLog("log", args.length ? args : ["console.group"]);
groupState.indent++;
};
console.groupEnd = function () {
groupState.indent = Math.max(0, groupState.indent - 1);
};
console.count = function (label = "default") {
const n = (countLabels.get(label) || 0) + 1;
countLabels.set(label, n);
pushLog("log", [`${label}: ${n}`]);
};
console.countReset = function (label = "default") {
countLabels.set(label, 0);
};
console.assert = function (condition, ...args) {
if (condition) return;
pushLog("error", ["Assertion failed:", ...args]);
};
console.trace = function (...args) {
const stack = new Error().stack || "";
pushLog("log", [[["Trace:", ...args].join(" "), stack].join("\n")]);
};
window.addEventListener("error", (e) => {
pushLog("error", [`${e.message} @ ${e.filename}:${e.lineno}:${e.colno}`]);
});
window.addEventListener("unhandledrejection", (e) => {
pushLog("error", [`Unhandled promise rejection: ${e.reason}`]);
});
function formatEvalResult(v) {
if (v === undefined) return "undefined";
if (v === null) return "null";
if (typeof v === "function") return v.toString();
if (typeof v === "string") return v;
if (v instanceof Node) return `<${v.nodeName?.toLowerCase() || "node"}> ${v.outerHTML ? v.outerHTML.slice(0, 300) : ""}`;
try { return JSON.stringify(v, null, 2); } catch (_) { try { return String(v); } catch (__) { return "(unserializable value)"; } }
}
function attemptNonceEval(code) {
return new Promise((resolve, reject) => {
try {
const existing = document.querySelector("script[nonce]");
const nonce = existing ? existing.nonce || existing.getAttribute("nonce") : null;
if (!nonce) { reject(new Error("No reusable nonce found on this page.")); return; }
const resultKey = "__di_eval_" + Math.random().toString(36).slice(2);
const s = document.createElement("script");
s.setAttribute("nonce", nonce);
s.textContent = ttScript(
`try { window['${resultKey}'] = { ok: true, value: (function(){ return (${code}); })() }; } catch (e) { window['${resultKey}'] = { ok: false, error: String(e) }; }`
);
document.documentElement.appendChild(s);
s.remove();
const res = window[resultKey];
delete window[resultKey];
if (!res) { reject(new Error("The nonce script did not run — CSP blocked it too.")); return; }
if (res.ok) resolve(res.value);
else reject(new Error(res.error));
} catch (err) {
reject(err);
}
});
}
async function runCommand(code) {
logBuffer.push({ time: new Date().toLocaleTimeString(), tag: "log", text: "> " + code });
try {
const result = (0, eval)(code);
logBuffer.push({ time: new Date().toLocaleTimeString(), tag: "result", text: formatEvalResult(result) });
} catch (err) {
const msg = String((err && err.message) || err);
const isCspEval = err instanceof EvalError || /unsafe-eval|content security policy|trusted-types-eval/i.test(msg);
if (isCspEval) {
logBuffer.push({ time: new Date().toLocaleTimeString(), tag: "warn", text: "eval() is blocked by this site's CSP (no 'unsafe-eval'). Trying a nonce-reuse fallback…" });
try {
const result = await attemptNonceEval(code);
logBuffer.push({ time: new Date().toLocaleTimeString(), tag: "result", text: formatEvalResult(result) + "\n(via nonce-reuse fallback — this trick only works on some sites)" });
} catch (err2) {
logBuffer.push({
time: new Date().toLocaleTimeString(),
tag: "error",
text:
"Fallback also failed: " + ((err2 && err2.message) || err2) +
"\nThis site's CSP fully blocks dynamic code execution — no way around that from a userscript.",
});
}
} else {
logBuffer.push({ time: new Date().toLocaleTimeString(), tag: "error", text: (err && err.stack) || String(err) });
}
}
if (logBuffer.length > MAX_LOG_ENTRIES) logBuffer.splice(0, logBuffer.length - MAX_LOG_ENTRIES);
renderConsole();
const input = body.querySelector(".di-cmd-input");
if (input) input.focus();
}
/* =========================================================
* 5. Network capture
* ========================================================= */
const NETWORK_LOG_KEY = "devtoolMobileNetworkLog_v1";
const MAX_NETWORK_ENTRIES = 200;
let networkIdCounter = 0;
function isTextLikeContentType(ct) {
const c = (ct || "").toLowerCase();
if (!c) return true; // unknown — assume text, safer to show than to hide
return /^text\/|application\/(json|javascript|xml|x-www-form-urlencoded)|\+json|\+xml/.test(c);
}
// Live per-entry state, kept separate from `networkLog` so the log remains JSON-safe for sessionStorage.
// Stores Response clones and image object URLs.
const networkLiveState = new Map(); // id -> { liveResponse?, blobUrl? }
function loadNetworkLog() {
try {
const raw = sessionStorage.getItem(NETWORK_LOG_KEY);
const arr = raw ? JSON.parse(raw) : [];
return Array.isArray(arr) ? arr : [];
} catch (_) { return []; }
}
const networkLog = loadNetworkLog();
networkIdCounter = networkLog.reduce((max, n) => Math.max(max, n.id || 0), 0);
// Restored entries cannot retain a live Response clone, so they are marked for on-demand fetching.
networkLog.forEach((n) => { if (!n.bodyLoaded) n.bodyLoaded = false; });
let networkSaveTimer = null;
function saveNetworkLog() {
if (networkSaveTimer) return;
networkSaveTimer = setTimeout(() => {
networkSaveTimer = null;
try { sessionStorage.setItem(NETWORK_LOG_KEY, JSON.stringify(networkLog.slice(-MAX_NETWORK_ENTRIES))); } catch (_) {}
}, 250);
}
const NETWORK_RECORDING_KEY = "devtoolMobileNetworkRecording_v1";
function loadNetworkRecording() {
try {
const v = sessionStorage.getItem(NETWORK_RECORDING_KEY);
return v === null ? true : v === "1";
} catch (_) { return true; }
}
function saveNetworkRecording(v) {
try { sessionStorage.setItem(NETWORK_RECORDING_KEY, v ? "1" : "0"); } catch (_) {}
}
let networkRecording = loadNetworkRecording();
// Called when the inspector is explicitly closed (not just left open across a reload) — frees everything accumulated this session.
function clearNetworkSession() {
networkLiveState.forEach((state) => {
if (state.blobUrl) { try { URL.revokeObjectURL(state.blobUrl); } catch (_) {} }
});
networkLiveState.clear();
networkLog.length = 0;
try { sessionStorage.removeItem(NETWORK_LOG_KEY); } catch (_) {}
}
function pushNetwork(entry) {
if (!networkRecording) return;
entry.id = ++networkIdCounter;
networkLog.push(entry);
if (networkLog.length > MAX_NETWORK_ENTRIES) {
const dropped = networkLog.shift();
const state = networkLiveState.get(dropped.id);
if (state?.blobUrl) { try { URL.revokeObjectURL(state.blobUrl); } catch (_) {} }
networkLiveState.delete(dropped.id);
}
saveNetworkLog();
if (panelVisible && activeTab === "network") renderNetwork();
}
(function patchFetch() {
if (!window.fetch) return;
const origFetch = window.fetch;
window.fetch = function (...args) {
const start = performance.now();
const req = args[0];
const init = args[1] || {};
const url = typeof req === "string" ? req : req?.url || String(req);
const method = init.method || (req && req.method) || "GET";
let reqHeaders = {};
try {
const h = init.headers || (req && req.headers);
if (h instanceof Headers) h.forEach((v, k) => (reqHeaders[k] = v));
else if (h && typeof h === "object") reqHeaders = { ...h };
} catch (_) {}
const reqBody = typeof init.body === "string" ? init.body : init.body ? "(non-text body)" : "";
return origFetch.apply(this, args).then(
(res) => {
if (!networkRecording) return res;
let resHeaders = {};
try { res.headers.forEach((v, k) => (resHeaders[k] = v)); } catch (_) {}
const textLike = isTextLikeContentType(resHeaders["content-type"]);
const entry = {
time: new Date().toLocaleTimeString(), method, url, status: res.status,
duration: Math.round(performance.now() - start), type: "fetch",
body: "", reqHeaders, reqBody, resHeaders, bodyLoaded: textLike,
};
if (textLike) {
try {
res.clone().text().then((t) => { entry.body = t; saveNetworkLog(); }).catch(() => {});
} catch (_) {}
pushNetwork(entry);
} else {
pushNetwork(entry);
// Keep a live Response clone so the body can be read without another request.
// The clone is released after reading, when the entry is removed, or on close.
try { networkLiveState.set(entry.id, { liveResponse: res.clone() }); } catch (_) {}
}
return res;
},
(err) => {
pushNetwork({ time: new Date().toLocaleTimeString(), method, url, status: "ERR", duration: Math.round(performance.now() - start), type: "fetch", body: String(err), reqHeaders, reqBody, resHeaders: {}, bodyLoaded: true });
throw err;
}
);
};
})();
(function patchXHR() {
const origOpen = XMLHttpRequest.prototype.open;
const origSend = XMLHttpRequest.prototype.send;
const origSetHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this.__di = { method, url, reqHeaders: {} };
return origOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
if (this.__di) this.__di.reqHeaders[name] = value;
return origSetHeader.call(this, name, value);
};
XMLHttpRequest.prototype.send = function (bodyArg, ...rest) {
if (this.__di) {
this.__di.start = performance.now();
this.__di.reqBody = typeof bodyArg === "string" ? bodyArg : bodyArg ? "(non-text body)" : "";
this.addEventListener("loadend", () => {
if (!networkRecording) return;
let resHeaders = {};
try {
this.getAllResponseHeaders()
.split("\r\n")
.filter(Boolean)
.forEach((line) => {
const idx = line.indexOf(":");
if (idx > 0) resHeaders[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
});
} catch (_) {}
// XHR buffers the full response when using a text response type, so the complete response can be read directly without additional buffering.
let bodyText = "";
let bodyLoaded = true;
try {
const textLike = this.responseType === "" || this.responseType === "text";
bodyText = textLike ? String(this.responseText) : "(binary response — XHR with responseType other than \"text\" can't be read here; try the Resources tab, or switch this request to responseType \"text\"/\"json\")";
} catch (_) {
bodyText = "(couldn't read body)";
}
pushNetwork({
time: new Date().toLocaleTimeString(),
method: this.__di.method,
url: this.__di.url,
status: this.status,
duration: Math.round(performance.now() - this.__di.start),
type: "xhr",
body: bodyText,
bodyLoaded,
reqHeaders: this.__di.reqHeaders,
reqBody: this.__di.reqBody,
resHeaders,
});
});
}
return origSend.apply(this, [bodyArg, ...rest]);
};
})();
/* =========================================================
* 6. Selector generation heuristics (ID/attribute/class priority, uniqueness checks, dynamic-token filtering)
* ========================================================= */
const DYNAMIC_TOKEN = /^[a-f0-9]{6,}$/i;
const NOISY_CLASS = /active|selected|focus|hover|disabled|checked|open|loading|enter|leave|--is-|js-|css-/i;
const RELIABLE_ATTRS = ["data-testid", "data-cy", "data-test", "name", "aria-label", "alt", "title", "placeholder", "role", "type", "for"];
function cssEscape(v) {
return typeof CSS !== "undefined" && CSS.escape ? CSS.escape(v) : v.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
}
function isStableToken(v) {
if (!v) return false;
if (v.length > 60) return false;
if (DYNAMIC_TOKEN.test(v)) return false;
if (/\d{4,}/.test(v)) return false;
return true;
}
function uniqueCount(sel, root2 = document) {
try { return root2.querySelectorAll(sel).length; } catch (_) { return -1; }
}
function stableClasses(el) {
return Array.from(el.classList || []).filter((c) => isStableToken(c) && !NOISY_CLASS.test(c)).slice(0, 2);
}
function buildSingleLevelSelector(el) {
const tag = el.tagName.toLowerCase();
if (el.id && isStableToken(el.id)) {
const sel = `#${cssEscape(el.id)}`;
if (uniqueCount(sel) === 1) return sel;
}
for (const attr of RELIABLE_ATTRS) {
const val = el.getAttribute(attr);
if (val && isStableToken(val)) {
const sel = `${tag}[${attr}="${val.replace(/"/g, '\\"')}"]`;
if (uniqueCount(sel) === 1) return sel;
}
}
const classes = stableClasses(el);
if (classes.length) {
const sel = `${tag}.${classes.map(cssEscape).join(".")}`;
if (uniqueCount(sel) <= 3) return sel;
}
const parent = el.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter((c) => c.tagName === el.tagName);
if (siblings.length > 1) return `${tag}:nth-of-type(${siblings.indexOf(el) + 1})`;
}
return tag;
}
function buildSelector(el, maxDepth = 8) {
if (!el || el.nodeType !== 1) return "";
const parts = [];
let node = el;
for (let i = 0; i < maxDepth && node && node.tagName; i++) {
const tag = node.tagName.toLowerCase();
if (tag === "body" || tag === "html") break;
parts.unshift(buildSingleLevelSelector(node));
const path = parts.join(" > ");
if (uniqueCount(path) === 1) return path;
node = node.parentElement;
}
return parts.join(" > ");
}
function summarizeElement(el, depth = 0, maxDepth = 3) {
const indent = " ".repeat(depth);
const tag = el.tagName.toLowerCase();
const id = el.id ? `#${el.id}` : "";
const cls = el.className && typeof el.className === "string" ? `.${el.className.trim().replace(/\s+/g, ".")}` : "";
const text = el.childElementCount === 0 ? (el.textContent || "").trim().slice(0, 60) : "";
let line = `${indent}<${tag}${id}${cls}>${text ? ' "' + text + '"' : ""}\n`;
if (depth < maxDepth) {
for (const child of el.children) line += summarizeElement(child, depth + 1, maxDepth);
}
return line;
}
/* =========================================================
* 7. Element hide/show (session-only)
* ========================================================= */
const hiddenStore = new WeakMap();
function isHidden(el) { return hiddenStore.has(el); }
function toggleHide(el) {
if (isHidden(el)) {
el.style.display = hiddenStore.get(el);
hiddenStore.delete(el);
} else {
hiddenStore.set(el, el.style.display || "");
el.style.setProperty("display", "none", "important");
}
}
/* =========================================================
* 8. Cookies / localStorage / sessionStorage
* ========================================================= */
function getCookies() {
return document.cookie
.split(";")
.map((s) => s.trim())
.filter(Boolean)
.map((s) => {
const idx = s.indexOf("=");
const name = idx >= 0 ? s.slice(0, idx) : s;
let value = "";
try { value = idx >= 0 ? decodeURIComponent(s.slice(idx + 1)) : ""; } catch (_) { value = idx >= 0 ? s.slice(idx + 1) : ""; }
return { key: name, value };
});
}
function setCookie(name, value) {
// document.cookie does not expose a cookie's original path or domain.
// Write the replacement at the root path as a best-effort fallback.
// A cookie with different path/domain attributes may remain.
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=31536000; SameSite=Lax`;
}
function candidateCookiePaths() {
const parts = location.pathname.split("/").filter(Boolean);
const paths = ["/"];
let acc = "";
parts.slice(0, -1).forEach((p) => { acc += "/" + p; paths.push(acc); });
paths.push(location.pathname);
return Array.from(new Set(paths));
}
function candidateCookieDomains() {
const host = location.hostname;
const domains = [null, host];
const parts = host.split(".");
for (let i = 1; i < parts.length - 1; i++) domains.push(parts.slice(i).join("."));
return Array.from(new Set(domains));
}
function deleteCookie(name) {
// Tries every common path/domain combination, since the exact originals aren't knowable from JS
// this maximizes the chance of actually removing it but still isn't 100% guaranteed on every site.
const expire = "expires=Thu, 01 Jan 1970 00:00:00 GMT; max-age=0";
candidateCookiePaths().forEach((path) => {
candidateCookieDomains().forEach((domain) => {
const domainPart = domain ? `; domain=${domain}` : "";
document.cookie = `${name}=; path=${path}${domainPart}; ${expire}`;
});
});
}
function getWebStorageEntries(storage) {
try { return Object.keys(storage).map((k) => ({ key: k, value: storage.getItem(k) })); } catch (_) { return []; }
}
/* =========================================================
* 9. UI
* ========================================================= */
let panelVisible = false;
let activeTab = "console";
let pickerActive = false;
let currentTarget = null;
let highlightBox = null;
let panelMinimized = false;
let lastPanelHeight = "62vh";
let fullSourceCache = null;
const expandedSource = new Set(); // subset of "html","css","js"
const expandedResources = new Set();
const expandedNetwork = new Set();
let root, style, panel, dragHandle, tabsBar, body, statusBar, pickerIndicator;
const TAB_NAMES = ["console", "elements", "network", "resources", "storage", "source", "settings"];
function buildUI() {
root = document.createElement("div");
root.style.cssText = "all:initial;position:fixed;inset:0;z-index:2147483647;pointer-events:none;";
document.documentElement.appendChild(root);
style = document.createElement("style");
style.textContent = `
.di-panel { position:fixed; left:0; right:0; bottom:0; height:62vh; background:#1e1e1e; color:#d4d4d4;
font:12px/1.4 -apple-system,Menlo,monospace; display:none; flex-direction:column;
border-top:1px solid #444; pointer-events:auto; }
.di-panel.visible { display:flex; }
.di-drag-handle { height:16px; flex:0 0 auto; display:flex; align-items:center; justify-content:center;
background:#2a2a2a; cursor:ns-resize; touch-action:none; }
.di-drag-grip { width:36px; height:4px; border-radius:2px; background:#5a5a5a; }
.di-tabs { display:flex; background:#252526; border-bottom:1px solid #333; flex:0 0 auto; }
.di-tabs-scroll { display:flex; overflow-x:auto; -webkit-overflow-scrolling:touch; flex:1; min-width:0; }
.di-tabs-fixed { display:flex; flex:0 0 auto; border-left:1px solid #333; }
.di-tab { flex:0 0 auto; padding:9px 13px; text-align:center; cursor:pointer; color:#9d9d9d;
font-size:12px; font-weight:600; white-space:nowrap; }
.di-tab.active { color:#4fc3f7; background:#1e1e1e; border-bottom:2px solid #4fc3f7; }
.di-body { flex:1; overflow-y:auto; padding:8px; min-height:0; }
.di-row { padding:4px 6px; margin-bottom:3px; border-radius:4px; word-break:break-word; white-space:pre-wrap; }
.di-row.error { background:rgba(255,80,80,0.12); color:#ff8080; }
.di-row.warn { background:rgba(255,200,80,0.10); color:#ffcf70; }
.di-row.log, .di-row.info, .di-row.debug { background:rgba(255,255,255,0.03); }
.di-row.result { background:rgba(79,195,247,0.08); color:#4fc3f7; }
.di-row.table { background:rgba(255,255,255,0.03); }
.di-time { color:#777; margin-right:6px; }
.di-toolbar { display:flex; gap:6px; padding:6px 8px; background:#252526; flex-wrap:wrap; align-items:center; border-bottom:1px solid #333; }
.di-btn { background:#333; color:#ddd; border:1px solid #444; border-radius:5px; padding:5px 9px; font-size:11px; cursor:pointer; }
.di-btn.primary { background:#0e639c; border-color:#0e639c; color:#fff; }
.di-btn.danger { background:#5a1d1d; border-color:#7a2a2a; color:#ffb3b3; }
.di-btn:active { transform:scale(0.96); }
.di-selector-box { background:#111; border:1px solid #333; border-radius:6px; padding:8px; margin-bottom:8px; word-break:break-all; }
.di-label { color:#888; font-size:10px; text-transform:uppercase; margin-bottom:3px; }
.di-match { color:#4fc3f7; font-weight:600; }
.di-rr-row { display:flex; align-items:center; gap:6px; margin-bottom:8px; }
.di-section-title { color:#4fc3f7; font-weight:700; margin:10px 0 4px; font-size:11px; text-transform:uppercase;
display:flex; align-items:center; justify-content:space-between; }
.di-attr { color:#9cdcfe; }
.di-attrval { color:#ce9178; }
.di-status { padding:4px 8px; font-size:11px; color:#4fc3f7; min-height:16px; flex:0 0 auto; }
.di-highlight { position:fixed; z-index:2147483646; background:rgba(79,195,247,0.22); border:1px solid #4fc3f7;
pointer-events:none; box-sizing:border-box; transition:opacity 0.3s; }
.di-picker-indicator { position:fixed; left:50%; bottom:18px; transform:translateX(-50%);
background:#0e639c; color:#fff; font:12px/1.4 -apple-system,Menlo,monospace; font-weight:600;
padding:9px 16px; border-radius:999px; box-shadow:0 4px 14px rgba(0,0,0,0.35);
pointer-events:auto; z-index:2147483647; display:none; white-space:nowrap; }
.di-settings-row { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:9px 4px; border-bottom:1px solid #2c2c2c; }
.di-settings-group { display:flex; gap:6px; align-items:center; }
.di-settings-group select { background:#2a2a2a; color:#ddd; border:1px solid #444; border-radius:5px; padding:4px 6px; font-size:12px; }
.di-settings-title { color:#4fc3f7; font-weight:700; font-size:11px; text-transform:uppercase; margin:12px 4px 2px; }
.di-kv-row { padding:6px 0; border-bottom:1px solid #2c2c2c; }
.di-kv-text { font-size:11px; word-break:break-all; margin-bottom:4px; }
.di-kv-actions { display:flex; gap:6px; }
.di-preview-box { margin:4px 0 10px; padding:8px; background:#111; border:1px solid #333; border-radius:6px; font-size:11px; }
.di-preview-box img { max-width:100%; max-height:200px; display:block; }
.di-detail-title { color:#4fc3f7; font-weight:700; margin-bottom:2px; }
.di-detail-pre { white-space:pre-wrap; word-break:break-word; font-family:Menlo,Consolas,monospace; max-height:150px; overflow-y:auto; }
`;
root.appendChild(style);
panel = document.createElement("div");
panel.className = "di-panel";
panel.style.height = lastPanelHeight;
root.appendChild(panel);
dragHandle = document.createElement("div");
dragHandle.className = "di-drag-handle";
const grip = document.createElement("div");
grip.className = "di-drag-grip";
dragHandle.appendChild(grip);
panel.appendChild(dragHandle);
makeResizable(dragHandle);
tabsBar = document.createElement("div");
tabsBar.className = "di-tabs";
const tabsScroll = document.createElement("div");
tabsScroll.className = "di-tabs-scroll";
TAB_NAMES.forEach((tabName) => {
const el = document.createElement("div");
el.className = "di-tab" + (tabName === activeTab ? " active" : "");
el.textContent = tabName[0].toUpperCase() + tabName.slice(1);
el.dataset.tab = tabName;
el.addEventListener("click", () => switchTab(tabName));
tabsScroll.appendChild(el);
});
tabsBar.appendChild(tabsScroll);
const tabsFixed = document.createElement("div");
tabsFixed.className = "di-tabs-fixed";
const minTab = document.createElement("div");
minTab.className = "di-tab";
minTab.textContent = "▁";
minTab.addEventListener("click", toggleMinimize);
tabsFixed.appendChild(minTab);
const closeTab = document.createElement("div");
closeTab.className = "di-tab";
closeTab.textContent = "Close";
closeTab.addEventListener("click", hideInspector);
tabsFixed.appendChild(closeTab);
tabsBar.appendChild(tabsFixed);
panel.appendChild(tabsBar);
statusBar = document.createElement("div");
statusBar.className = "di-status";
panel.appendChild(statusBar);
body = document.createElement("div");
body.className = "di-body";
panel.appendChild(body);
highlightBox = document.createElement("div");
highlightBox.className = "di-highlight";
highlightBox.style.display = "none";
root.appendChild(highlightBox);
pickerIndicator = document.createElement("div");
pickerIndicator.className = "di-picker-indicator";
pickerIndicator.textContent = "Selecting… tap, or drag across elements and release (tap here to cancel)";
pickerIndicator.addEventListener("click", () => stopPicker(true));
root.appendChild(pickerIndicator);
}
function makeResizable(handle) {
let dragging = false, startY = 0, startHeight = 0;
function pos(e) { return e.touches ? e.touches[0] : e; }
function start(e) {
if (panelMinimized) return;
dragging = true;
startY = pos(e).clientY;
startHeight = panel.getBoundingClientRect().height;
}
function move(e) {
if (!dragging) return;
const delta = startY - pos(e).clientY;
const vh = window.innerHeight;
let newHeight = Math.max(vh * 0.2, Math.min(vh * 0.92, startHeight + delta));
panel.style.height = newHeight + "px";
lastPanelHeight = panel.style.height;
e.preventDefault();
}
function end() { dragging = false; }
handle.addEventListener("touchstart", start, { passive: true });
handle.addEventListener("touchmove", move, { passive: false });
handle.addEventListener("touchend", end, { passive: true });
handle.addEventListener("mousedown", start);
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", end);
}
function toggleMinimize() {
panelMinimized = !panelMinimized;
if (panelMinimized) {
lastPanelHeight = panel.style.height || lastPanelHeight;
panel.style.height = "48px";
body.style.display = "none";
statusBar.style.display = "none";
} else {
panel.style.height = lastPanelHeight;
body.style.display = "";
statusBar.style.display = "";
}
}
function setStatus(msg, ms = 1800) {
statusBar.textContent = msg;
if (ms) setTimeout(() => { if (statusBar.textContent === msg) statusBar.textContent = ""; }, ms);
}
function switchTab(tabName) {
activeTab = tabName;
tabsBar.querySelectorAll(".di-tab[data-tab]").forEach((el) => {
el.classList.toggle("active", el.dataset.tab === tabName);
});
if (tabName === "console") renderConsole();
else if (tabName === "elements") renderElements();
else if (tabName === "network") renderNetwork();
else if (tabName === "resources") renderResources();
else if (tabName === "storage") renderStorage();
else if (tabName === "source") renderSource();
else if (tabName === "settings") renderSettings();
}
const INSPECTOR_STATE_KEY = "devtoolMobileInspectorOpen_v1";
function toggleInspector() {
panelVisible ? hideInspector() : showInspector();
}
function showInspector() {
if (!root) buildUI();
panelVisible = true;
panel.classList.add("visible");
try { localStorage.setItem(INSPECTOR_STATE_KEY, "1"); } catch (_) {}
switchTab(activeTab);
}
// Release accumulated session data when the inspector is explicitly closed.
function hideInspector() {
panelVisible = false;
panel.classList.remove("visible");
stopPicker();
try { localStorage.setItem(INSPECTOR_STATE_KEY, "0"); } catch (_) {}
clearNetworkSession();
}
/* ---------- helpers ---------- */
function copyText(text) {
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
setStatus("Copied");
} catch (_) {
if (navigator.clipboard) { navigator.clipboard.writeText(text); setStatus("Copied (clipboard API)"); }
else setStatus("Copy failed");
}
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
}
function mkSmallBtn(label, onClick) {
const b = document.createElement("button");
b.className = "di-btn";
b.textContent = label;
b.addEventListener("click", onClick);
return b;
}
function appendSection(title, contentText, container = body) {
const t = document.createElement("div");
t.className = "di-section-title";
t.textContent = title;
container.appendChild(t);
const pre = document.createElement("div");
pre.style.cssText = "background:#111;border:1px solid #333;border-radius:6px;padding:8px;white-space:pre-wrap;word-break:break-word;max-height:220px;overflow-y:auto;font-family:Menlo,Consolas,monospace;font-size:11px;";
pre.textContent = contentText;
container.appendChild(pre);
return pre;
}
function buildDetailBlock(title, text) {
const wrap = document.createElement("div");
wrap.style.marginBottom = "8px";
const t = document.createElement("div");
t.className = "di-detail-title";
t.textContent = title;
wrap.appendChild(t);
const pre = document.createElement("div");
pre.className = "di-detail-pre";
pre.textContent = text;
wrap.appendChild(pre);
return wrap;
}
function buildTableElement({ headers, rows }) {
const wrap = document.createElement("div");
wrap.style.cssText = "overflow-x:auto; margin-top:4px;";
const table = document.createElement("table");
table.style.cssText = "border-collapse:collapse; font-size:11px; font-family:Menlo,Consolas,monospace; min-width:100%;";
const thead = document.createElement("thead");
const hr = document.createElement("tr");
headers.forEach((h) => {
const th = document.createElement("th");
th.textContent = h;
th.style.cssText = "border:1px solid #444; padding:4px 8px; background:#252526; color:#4fc3f7; text-align:left; white-space:nowrap;";
hr.appendChild(th);
});
thead.appendChild(hr);
table.appendChild(thead);
const tbody = document.createElement("tbody");
rows.forEach((r) => {
const tr = document.createElement("tr");
r.forEach((cell) => {
const td = document.createElement("td");
td.textContent = cell;
td.style.cssText = "border:1px solid #333; padding:4px 8px; white-space:nowrap;";
tr.appendChild(td);
});
tbody.appendChild(tr);
});
table.appendChild(tbody);
wrap.appendChild(table);
return wrap;
}
/* ---------- Console tab ---------- */
function renderConsole() {
body.replaceChildren();
const toolbar = document.createElement("div");
toolbar.className = "di-toolbar";
toolbar.appendChild(mkSmallBtn("Clear", () => { logBuffer.length = 0; renderConsole(); }));
const copyBtn = mkSmallBtn("Copy all", () => copyText(logBuffer.map((l) => `[${l.time}] [${l.tag}] ${logEntryToText(l)}`).join("\n")));
copyBtn.classList.add("primary");
toolbar.appendChild(copyBtn);
body.appendChild(toolbar);
const note = document.createElement("div");
note.style.cssText = "color:#777; font-size:10px; padding:4px 8px;";
note.textContent = "Supported: log/warn/error/info/debug, table, time/timeEnd/timeLog, group/groupEnd, count, assert, trace, dir, clear.";
body.appendChild(note);
const cmdRow = document.createElement("div");
cmdRow.style.cssText = "display:flex; gap:6px; padding:6px 8px; background:#1a1a1a; border-bottom:1px solid #333; align-items:flex-end;";
const cmdInput = document.createElement("textarea");
cmdInput.rows = 3;
cmdInput.placeholder = "Tap Run (or Ctrl/Cmd+Enter) to execute.";
cmdInput.className = "di-cmd-input";
cmdInput.autocomplete = "off";
cmdInput.autocapitalize = "off";
cmdInput.spellcheck = false;
cmdInput.style.cssText = "flex:1; background:#111; color:#d4d4d4; border:1px solid #444; border-radius:5px; padding:7px 9px; font:12px/1.3 Menlo,monospace; resize:vertical; min-height:48px;";
const runBtn = document.createElement("button");
runBtn.className = "di-btn primary";
runBtn.textContent = "Run";
const run = () => {
const value = cmdInput.value.trim();
if (!value) return;
runCommand(value);
};
runBtn.addEventListener("click", run);
cmdInput.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { e.preventDefault(); run(); }
});
cmdRow.append(cmdInput, runBtn);
body.appendChild(cmdRow);
const list = document.createElement("div");
list.style.padding = "6px";
if (!logBuffer.length) {
const hint = document.createElement("div");
hint.style.cssText = "color:#777;padding:10px;";
hint.textContent = "No console output yet. Try typing an expression above, e.g. document.title";
list.appendChild(hint);
} else {
logBuffer.slice().reverse().forEach((l) => {
const row = document.createElement("div");
row.className = `di-row ${l.tag}`;
if (l.tag === "table" && l.tableData) {
const timeSpan = document.createElement("span");
timeSpan.className = "di-time";
timeSpan.textContent = l.time;
row.appendChild(timeSpan);
row.appendChild(buildTableElement(l.tableData));
} else {
row.innerHTML = tt(`<span class="di-time">${l.time}</span>${escapeHtml(l.text)}`);
}
list.appendChild(row);
});
}
body.appendChild(list);
}
/* ---------- Elements tab ---------- */
function outerHtmlOf(el, maxLen = 4000) { return el.cloneNode(true).outerHTML.slice(0, maxLen); }
function computedStyleSummary(el) {
const props = ["display", "position", "width", "height", "margin", "padding", "color", "background-color", "font-size", "z-index", "opacity", "overflow", "visibility"];
const cs = window.getComputedStyle(el);
return props.map((p) => `${p}: ${cs.getPropertyValue(p)};`).join("\n");
}
function elementHierarchy(el) {
const chain = [];
let node = el;
while (node && node.tagName && node.tagName.toLowerCase() !== "body") { chain.unshift(node); node = node.parentElement; }
return chain;
}
function elLabel(el) {
const id = el.id ? `#${el.id}` : "";
const cls = el.className && typeof el.className === "string" ? "." + el.className.trim().split(/\s+/).join(".") : "";
return `<${el.tagName.toLowerCase()}${id}${cls}>`;
}
function renderElements() {
body.replaceChildren();
const toolbar = document.createElement("div");
toolbar.className = "di-toolbar";
const pickBtn = document.createElement("button");
pickBtn.className = "di-btn primary";
pickBtn.textContent = pickerActive ? "✕ Cancel selection" : "⌖ Select element";
pickBtn.addEventListener("click", () => (pickerActive ? stopPicker(true) : startPicker()));
toolbar.appendChild(pickBtn);
body.appendChild(toolbar);
body.appendChild(renderSelectorScanBox());
if (currentTarget) {
renderElementDetail(currentTarget);
} else {
const hint = document.createElement("div");
hint.style.cssText = "color:#777;padding:10px 4px;";
hint.textContent = 'Tap "⌖ Select element", then tap (or drag then release over) anything on the page. Or use the selector scan above.';
body.appendChild(hint);
}
}
function renderSelectorScanBox() {
const box = document.createElement("div");
box.style.marginBottom = "10px";
const title = document.createElement("div");
title.className = "di-section-title";
title.textContent = "Selector scan (manual)";
box.appendChild(title);
const row = document.createElement("div");
row.style.cssText = "display:flex;gap:6px;margin-bottom:6px;";
const input = document.createElement("input");
input.type = "text";
input.placeholder = "e.g. a img button h1 [href]";
input.style.cssText = "flex:1;background:#111;color:#d4d4d4;border:1px solid #444;border-radius:5px;padding:6px 8px;font:12px Menlo,monospace;";
const scanBtn = document.createElement("button");
scanBtn.className = "di-btn primary";
scanBtn.textContent = "Scan";
row.append(input, scanBtn);
box.appendChild(row);
const resultBox = document.createElement("div");
box.appendChild(resultBox);
const PAGE_SIZE = 10;
let scanMatches = [];
let scanPage = 0;
function renderResultsPage() {
resultBox.replaceChildren();
if (!scanMatches.length) return;
const totalPages = Math.max(1, Math.ceil(scanMatches.length / PAGE_SIZE));
if (scanPage >= totalPages) scanPage = totalPages - 1;
if (scanPage < 0) scanPage = 0;
const start = scanPage * PAGE_SIZE;
const pageItems = scanMatches.slice(start, start + PAGE_SIZE);
const summary = document.createElement("div");
summary.style.cssText = "color:#4fc3f7;font-size:11px;margin-bottom:6px;";
summary.textContent = `${scanMatches.length} match(es) — showing ${start + 1}-${start + pageItems.length}`;
resultBox.appendChild(summary);
pageItems.forEach((m, i) => {
const item = document.createElement("div");
item.style.cssText = "display:flex;justify-content:space-between;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid #2c2c2c;";
const label = document.createElement("span");
label.style.cssText = "font-size:11px;color:#ce9178;word-break:break-all;flex:1;";
label.textContent = `[${start + i}] ${elLabel(m)}`;
const showBtn = mkSmallBtn("Highlight", () => flashHighlight(m));
const selectBtn = mkSmallBtn("Inspect", () => selectElement(m));
selectBtn.classList.add("primary");
item.append(label, showBtn, selectBtn);
resultBox.appendChild(item);
});
if (totalPages > 1) {
const navRow = document.createElement("div");
navRow.style.cssText = "display:flex;align-items:center;justify-content:center;gap:10px;margin-top:8px;";
const prevBtn = mkSmallBtn("‹ Prev", () => { scanPage--; renderResultsPage(); });
prevBtn.disabled = scanPage === 0;
const pageLabel = document.createElement("span");
pageLabel.style.cssText = "font-size:11px;color:#888;min-width:70px;text-align:center;";
pageLabel.textContent = `Page ${scanPage + 1}/${totalPages}`;
const nextBtn = mkSmallBtn("Next ›", () => { scanPage++; renderResultsPage(); });
nextBtn.disabled = scanPage >= totalPages - 1;
navRow.append(prevBtn, pageLabel, nextBtn);
resultBox.appendChild(navRow);
}
}
function doScan() {
const sel = input.value.trim();
resultBox.replaceChildren();
scanMatches = [];
scanPage = 0;
if (!sel) return;
try {
scanMatches = Array.from(document.querySelectorAll(sel)).filter((el) => !(root && root.contains(el)));
} catch (err) {
const errDiv = document.createElement("div");
errDiv.style.color = "#ff8080";
errDiv.style.fontSize = "11px";
errDiv.textContent = "Invalid selector: " + err.message;
resultBox.appendChild(errDiv);
return;
}
renderResultsPage();
}
scanBtn.addEventListener("click", doScan);
input.addEventListener("keydown", (e) => { if (e.key === "Enter") doScan(); });
const howto = document.createElement("div");
howto.style.cssText = "color:#777;font-size:10px;margin-top:4px;line-height:1.4;";
howto.textContent = 'Type a CSS selector and tap Scan.';
box.appendChild(howto);
return box;
}
function renderElementDetail(el) {
const selector = buildSelector(el);
const matches = selector ? uniqueCount(selector) : 0;
const quickText = [`Selector: ${selector || "(could not generate)"}`, `Matches: ${matches}`, "", "Structure (3 levels):", summarizeElement(el).trimEnd()].join("\n");
appendSection("Quick summary", quickText);
const box = document.createElement("div");
box.className = "di-selector-box";
box.innerHTML = tt(`
<div class="di-label">CSS selector <span class="di-match">(${matches} match${matches === 1 ? "" : "es"})</span></div>
<div>${escapeHtml(selector || "(could not generate)")}</div>
`);
body.appendChild(box);
const chain = elementHierarchy(el);
const idx = chain.indexOf(el);
const actions = document.createElement("div");
actions.className = "di-toolbar";
actions.style.padding = "0 0 8px 0";
actions.appendChild(mkSmallBtn("Copy selector", () => copyText(selector)));
actions.appendChild(mkSmallBtn("Copy HTML", () => copyText(outerHtmlOf(el))));
actions.appendChild(mkSmallBtn("Highlight on page", () => flashHighlight(el)));
const hideBtn = mkSmallBtn(isHidden(el) ? "Restore element" : "Hide element", () => { toggleHide(el); renderElementDetail(el); });
hideBtn.className = isHidden(el) ? "di-btn primary" : "di-btn danger";
actions.appendChild(hideBtn);
body.appendChild(actions);
const attrPre = appendSection("Attributes", "");
attrPre.innerHTML = tt(Array.from(el.attributes).map((a) => `<span class="di-attr">${escapeHtml(a.name)}</span>=<span class="di-attrval">"${escapeHtml(a.value)}"</span>`).join("\n") || "(none)");
appendSection("Computed style", computedStyleSummary(el));
appendSection("Outer HTML", outerHtmlOf(el));
}
function selectElement(el) {
currentTarget = el;
flashHighlight(el);
if (activeTab === "elements") renderElements();
}
function flashHighlight(el) {
if (!el || !el.getBoundingClientRect || !highlightBox) return;
try { el.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" }); } catch (_) {}
highlightEl(el);
setTimeout(() => highlightEl(el), 380);
clearTimeout(flashHighlight._t);
flashHighlight._t = setTimeout(() => { if (highlightBox) highlightBox.style.display = "none"; }, 2200);
}
/* ---------- Element picking ---------- */
const PICK_TOUCH_OPTS = { capture: true, passive: false };
function startPicker() {
pickerActive = true;
panel.classList.remove("visible");
pickerIndicator.style.display = "block";
document.addEventListener("touchstart", onPickTouchStart, PICK_TOUCH_OPTS);
document.addEventListener("touchmove", onPickTouchMove, PICK_TOUCH_OPTS);
document.addEventListener("touchend", onPick, PICK_TOUCH_OPTS);
document.addEventListener("touchcancel", onPickCancel, PICK_TOUCH_OPTS);
document.addEventListener("mousemove", onHoverMove, true);
document.addEventListener("click", onPickClick, true);
}
function stopPicker(cancelled = false) {
pickerActive = false;
pickerIndicator.style.display = "none";
document.removeEventListener("touchstart", onPickTouchStart, PICK_TOUCH_OPTS);
document.removeEventListener("touchmove", onPickTouchMove, PICK_TOUCH_OPTS);
document.removeEventListener("touchend", onPick, PICK_TOUCH_OPTS);
document.removeEventListener("touchcancel", onPickCancel, PICK_TOUCH_OPTS);
document.removeEventListener("mousemove", onHoverMove, true);
document.removeEventListener("click", onPickClick, true);
if (highlightBox) highlightBox.style.display = "none";
if (panelVisible) panel.classList.add("visible");
if (activeTab === "elements") renderElements();
if (cancelled) setStatus("Selection cancelled");
}
function elementFromEvent(e) {
const p = e.changedTouches?.[0] || e.touches?.[0] || e;
return document.elementFromPoint(p.clientX, p.clientY);
}
function isPickerChrome(el) { return !el || el === pickerIndicator || (panel && panel.contains(el)); }
function highlightEl(el) {
const r = el.getBoundingClientRect();
highlightBox.style.display = "block";
highlightBox.style.left = r.left + "px";
highlightBox.style.top = r.top + "px";
highlightBox.style.width = r.width + "px";
highlightBox.style.height = r.height + "px";
}
function onPickTouchStart(e) { const el = elementFromEvent(e); if (isPickerChrome(el)) return; e.preventDefault(); e.stopPropagation(); highlightEl(el); }
function onPickTouchMove(e) { const el = elementFromEvent(e); if (isPickerChrome(el)) return; e.preventDefault(); e.stopPropagation(); highlightEl(el); }
function onPickCancel() { stopPicker(true); }
function onHoverMove(e) { const el = elementFromEvent(e); if (isPickerChrome(el)) return; highlightEl(el); }
function onPick(e) { const el = elementFromEvent(e); if (isPickerChrome(el)) return; e.preventDefault(); e.stopPropagation(); stopPicker(); selectElement(el); setStatus("Element selected"); }
function onPickClick(e) { const el = e.target; if (isPickerChrome(el)) return; e.preventDefault(); e.stopPropagation(); stopPicker(); selectElement(el); setStatus("Element selected"); }
/* ---------- Network tab ---------- */
function renderNetwork() {
body.replaceChildren();
const toolbar = document.createElement("div");
toolbar.className = "di-toolbar";
const recBtn = mkSmallBtn(networkRecording ? "⏺ Recording" : "⏸ Paused", () => {
networkRecording = !networkRecording;
saveNetworkRecording(networkRecording);
renderNetwork();
});
recBtn.className = networkRecording ? "di-btn danger" : "di-btn";
toolbar.appendChild(recBtn);
toolbar.appendChild(mkSmallBtn("Clear", () => { clearNetworkSession(); expandedNetwork.clear(); renderNetwork(); }));
body.appendChild(toolbar);
const note = document.createElement("div");
note.style.cssText = "color:#777;font-size:11px;padding:6px 4px;";
note.textContent = "Text/JSON responses are captured in full right away. Images/video/binary responses are only fetched in full when you open them below.";
body.appendChild(note);
if (!networkLog.length) {
const empty = document.createElement("div");
empty.style.cssText = "color:#777;padding:10px;";
empty.textContent = "No requests captured yet.";
body.appendChild(empty);
return;
}
networkLog.slice().reverse().forEach((n) => {
const row = document.createElement("div");
row.className = "di-row log";
row.style.cursor = "pointer";
row.innerHTML = tt(`<span class="di-time">${n.time}</span><span class="di-attr">${escapeHtml(String(n.method))}</span> ${escapeHtml(String(n.status))} · ${n.duration}ms<br>${escapeHtml(n.url)}`);
row.addEventListener("click", () => {
if (expandedNetwork.has(n.id)) {
expandedNetwork.delete(n.id);
renderNetwork();
} else {
expandedNetwork.add(n.id);
renderNetwork();
if (!n.bodyLoaded) ensureNetworkBodyLoaded(n).then(renderNetwork);
}
});
body.appendChild(row);
if (expandedNetwork.has(n.id)) {
const detail = document.createElement("div");
detail.className = "di-preview-box";
const reqH = n.reqHeaders && Object.keys(n.reqHeaders).length ? Object.entries(n.reqHeaders).map(([k, v]) => `${k}: ${v}`).join("\n") : "(none captured)";
const resH = n.resHeaders && Object.keys(n.resHeaders).length ? Object.entries(n.resHeaders).map(([k, v]) => `${k}: ${v}`).join("\n") : "(none captured)";
detail.appendChild(buildDetailBlock("Request headers", reqH));
if (n.reqBody) detail.appendChild(buildDetailBlock("Request body", n.reqBody));
detail.appendChild(buildDetailBlock("Response headers", resH));
const liveState = networkLiveState.get(n.id);
if (liveState?.blobUrl) {
const img = document.createElement("img");
img.src = liveState.blobUrl;
img.style.cssText = "max-width:100%; max-height:200px; display:block; margin-bottom:8px;";
detail.appendChild(img);
}
detail.appendChild(buildDetailBlock("Response body", n.bodyLoaded ? n.body || "(empty body)" : "Loading full response…"));
const copyBtn = mkSmallBtn("Copy full entry", () => copyText(JSON.stringify(n, null, 2)));
copyBtn.classList.add("primary");
detail.appendChild(copyBtn);
body.appendChild(detail);
}
});
}
// deferred response body using the retained Response clone when available.
// Otherwise, fetch the resource again.
function ensureNetworkBodyLoaded(n) {
if (n.bodyLoaded) return Promise.resolve();
const ct = (n.resHeaders && n.resHeaders["content-type"]) || "";
const state = networkLiveState.get(n.id) || {};
const resPromise = state.liveResponse ? Promise.resolve(state.liveResponse) : fetch(n.url).then((r) => r);
return resPromise
.then((res) => {
if (/^image\//i.test(ct)) {
return res.blob().then((blob) => {
if (state.blobUrl) { try { URL.revokeObjectURL(state.blobUrl); } catch (_) {} }
const blobUrl = URL.createObjectURL(blob);
networkLiveState.set(n.id, { blobUrl });
n.body = `(image, ${blob.size.toLocaleString()} bytes, ${ct})`;
});
}
return res.text().then((t) => { n.body = t; networkLiveState.delete(n.id); });
})
.catch((err) => {
n.body = "Couldn't load: " + ((err && err.message) || err);
})
.then(() => {
n.bodyLoaded = true;
saveNetworkLog();
});
}
/* ---------- Resources tab (Performance API + content preview) ---------- */
function dedupeResourceEntries(entries) {
const map = new Map();
entries.forEach((r) => {
const existing = map.get(r.name);
if (!existing || r.startTime > existing.startTime) map.set(r.name, r);
});
return Array.from(map.values());
}
function collectPageResources() {
const map = new Map();
const add = (url, type, element = null, timing = null) => {
if (!url) return;
const key = url;
if (!map.has(key)) {
map.set(key, {
name: url,
type,
element,
timing
});
} else {
const existing = map.get(key);
// DOM 요소 정보가 새로 발견되면 보완
if (!existing.element && element) {
existing.element = element;
}
// Performance 정보가 있으면 보완
if (!existing.timing && timing) {
existing.timing = timing;
}
// image 정보가 발견되면 image로 유지
if (type === "image") {
existing.type = "image";
}
}
};
// 1. <img> — 실제 페이지의 모든 img 요소
document.querySelectorAll("img").forEach((el) => {
const url = el.currentSrc || el.src;
add(url, "image", el);
});
// 2. <picture>/<source>의 srcset
document.querySelectorAll("source[src], source[srcset]").forEach((el) => {
const url = el.src || el.srcset;
if (url) add(url, "image", el);
});
// 3. Scripts
document.querySelectorAll("script[src]").forEach((el) => {
add(el.src, "script", el);
});
// 4. Stylesheets
document.querySelectorAll('link[rel~="stylesheet"][href]').forEach((el) => {
add(el.href, "stylesheet", el);
});
// 5. Performance Resource Timing
performance.getEntriesByType("resource")
.sort((a, b) => a.startTime - b.startTime)
.forEach((entry) => {
let type = "other";
if (entry.initiatorType === "img" || looksLikeImageUrl(entry.name)) {
type = "image";
} else if (entry.initiatorType === "script") {
type = "script";
} else if (entry.initiatorType === "link") {
type = "stylesheet";
}
add(entry.name, type, null, entry);
});
return Array.from(map.values()).sort((a, b) => {
const at = a.timing ? a.timing.startTime : Infinity;
const bt = b.timing ? b.timing.startTime : Infinity;
return at - bt;
});
}
function looksLikeImageUrl(url) {
return /\.(jpe?g|png|gif|webp|avif|bmp|ico|svg)(\?|#|$)/i.test(url);
}
let activeGalleryImage = null;
let resourceSnapshot = null;
function renderResources() {
body.replaceChildren();
const toolbar = document.createElement("div");
toolbar.className = "di-toolbar";
toolbar.appendChild(
mkSmallBtn("Refresh", () => {
resourceSnapshot = null;
activeGalleryImage = null;
expandedResources.clear();
renderResources();
})
);
body.appendChild(toolbar);
const note = document.createElement("div");
note.style.cssText = "color:#777;font-size:11px;padding:6px 4px;";
note.textContent =
"Resources currently available on the page. Refresh to capture newly loaded resources.";
body.appendChild(note);
// Resource Snapshot
if (!resourceSnapshot) {
resourceSnapshot = collectPageResources();
}
const allEntries = resourceSnapshot;
if (!allEntries.length) {
const empty = document.createElement("div");
empty.style.cssText = "color:#777;padding:10px;";
empty.textContent = "No resources found.";
body.appendChild(empty);
return;
}
// Images
const imageEntries = allEntries.filter((r) => r.type === "image");
// Other resources
const otherEntries = allEntries.filter((r) => r.type !== "image");
// ---------- Images ----------
if (imageEntries.length) {
const title = document.createElement("div");
title.className = "di-section-title";
title.textContent = `Images (${imageEntries.length})`;
body.appendChild(title);
const grid = document.createElement("div");
grid.style.cssText =
"display:grid;grid-template-columns:repeat(auto-fill,minmax(64px,1fr));gap:6px;margin-bottom:8px;";
imageEntries.forEach((r) => {
const cell = document.createElement("div");
cell.title = r.name;
cell.style.cssText =
"aspect-ratio:1;background:#111;border:1px solid #333;border-radius:6px;display:flex;align-items:center;justify-content:center;overflow:hidden;cursor:pointer;";
const img = document.createElement("img");
img.src = r.element?.currentSrc || r.name;
img.alt = "";
img.loading = "lazy";
img.style.cssText =
"width:100%;height:100%;object-fit:contain;display:block;";
img.onerror = () => {
img.remove();
const fallback = document.createElement("span");
fallback.textContent = "🖼";
fallback.style.cssText =
"font-size:20px;color:#555;";
cell.appendChild(fallback);
};
cell.appendChild(img);
cell.addEventListener("click", () => {
activeGalleryImage = r;
renderResources();
});
grid.appendChild(cell);
});
body.appendChild(grid);
// Selected image details
if (activeGalleryImage) {
const box = document.createElement("div");
box.className = "di-preview-box";
body.appendChild(box);
showImageResourceDetails(activeGalleryImage, box);
}
}
// ---------- Other resources ----------
if (otherEntries.length) {
const title = document.createElement("div");
title.className = "di-section-title";
title.textContent = `Other resources (${otherEntries.length})`;
body.appendChild(title);
otherEntries.forEach((r) => {
const row = document.createElement("div");
row.className = "di-row log";
row.style.cursor = "pointer";
const timing = r.timing;
row.textContent =
`[${timing?.initiatorType || r.type || "?"}] ` +
`${timing ? Math.round(timing.duration) : 0}ms ` +
r.name;
row.addEventListener("click", () => {
expandedResources.has(r.name)
? expandedResources.delete(r.name)
: expandedResources.add(r.name);
renderResources();
});
body.appendChild(row);
if (expandedResources.has(r.name)) {
const box = document.createElement("div");
box.className = "di-preview-box";
box.textContent = "Loading preview…";
body.appendChild(box);
loadResourcePreview(r.name, box);
}
});
}
}
function showImageResourceDetails(resource, container) {
container.replaceChildren();
const url = resource.name;
const timing = resource.timing;
const element = resource.element;
const title = document.createElement("div");
title.className = "di-section-title";
title.textContent = "Image Resource";
container.appendChild(title);
const info = document.createElement("div");
info.className = "di-detail-pre";
const lines = [
`URL: ${url}`,
`Type: image`,
element ? `Element: <${element.tagName.toLowerCase()}>` : null,
timing ? `Initiator: ${timing.initiatorType || "-"}` : null,
timing ? `Start time: ${Math.round(timing.startTime)} ms` : null,
timing ? `Duration: ${Math.round(timing.duration)} ms` : null,
timing ? `Transfer size: ${timing.transferSize.toLocaleString()} bytes` : null,
timing ? `Encoded body size: ${timing.encodedBodySize.toLocaleString()} bytes` : null,
timing ? `Decoded body size: ${timing.decodedBodySize.toLocaleString()} bytes` : null
].filter(Boolean);
info.textContent = lines.join("\n");
container.appendChild(info);
// HTML 형태의 코드
const codeTitle = document.createElement("div");
codeTitle.className = "di-section-title";
codeTitle.textContent = "HTML";
container.appendChild(codeTitle);
const code = document.createElement("div");
code.className = "di-detail-pre";
if (element) {
code.textContent = element.outerHTML;
} else {
code.textContent = `<img src="${url}">`;
}
container.appendChild(code);
}
function loadResourcePreview(url, container) {
fetch(url)
.then((res) => {
const ct = res.headers.get("content-type") || "";
const cl = parseInt(res.headers.get("content-length") || "0", 10);
if (/^image\//.test(ct)) {
return res.blob().then((blob) => {
container.replaceChildren();
const img = document.createElement("img");
img.src = URL.createObjectURL(blob);
container.appendChild(img);
const info = document.createElement("div");
info.style.cssText = "color:#777;font-size:10px;margin-top:4px;";
info.textContent = `${ct} · ${blob.size.toLocaleString()} bytes`;
container.appendChild(info);
});
}
if (cl && cl > 2000000) {
container.replaceChildren();
const tooBig = document.createElement("div");
tooBig.style.cssText = "color:#777;font-size:11px;";
tooBig.textContent = `${Math.round(cl / 1024)} KB — too large to render inline here without risking the page freezing.`;
container.appendChild(tooBig);
container.appendChild(mkSmallBtn("Open full content in new tab", () => window.open(url, "_blank")));
return null;
}
return res.text().then((text) => {
container.replaceChildren();
const pre = document.createElement("div");
pre.className = "di-detail-pre";
pre.style.maxHeight = "220px";
pre.textContent = text;
container.appendChild(pre);
});
})
.catch((err) => {
container.replaceChildren();
const errDiv = document.createElement("div");
errDiv.style.cssText = "color:#ff8080;font-size:11px;margin-bottom:6px;";
errDiv.textContent = "Couldn't load preview: " + ((err && err.message) || err) + ". Often caused by CORS, a CSP restriction, or an expired/signed URL — the browser itself can't tell us more than that.";
container.appendChild(errDiv);
container.appendChild(mkSmallBtn("Open in new tab instead", () => window.open(url, "_blank")));
});
}
async function loadResourceCode(url, container) {
container.replaceChildren();
const info = document.createElement("div");
info.className = "di-detail-pre";
info.textContent = `URL: ${url}\n\nHTML:\n<img src="${url}">`;
container.appendChild(info);
}
/* ---------- Storage tab ---------- */
function renderStorage() {
body.replaceChildren();
const toolbar = document.createElement("div");
toolbar.className = "di-toolbar";
toolbar.appendChild(mkSmallBtn("Refresh", renderStorage));
body.appendChild(toolbar);
const cookieNote = document.createElement("div");
cookieNote.style.cssText = "color:#777;font-size:11px;padding:6px 4px;";
cookieNote.textContent = "Browsers never expose a cookie's original path/domain to JS, so edits/deletes may fail — on some sites, a new cookie may remain instead of replacing the original.";
body.appendChild(cookieNote);
renderStorageSection("Cookies", getCookies(), {
onAdd: () => { const n = prompt("Cookie name?"); if (!n) return; const v = prompt(`Value for "${n}"?`, "") || ""; setCookie(n, v); renderStorage(); },
onEdit: (k, v) => { const nv = prompt(`New value for cookie "${k}"`, v); if (nv === null) return; setCookie(k, nv); renderStorage(); },
onDelete: (k) => { deleteCookie(k); renderStorage(); },
});
renderStorageSection("localStorage", getWebStorageEntries(window.localStorage), {
onAdd: () => { const k = prompt("Key?"); if (!k) return; const v = prompt("Value?", "") || ""; try { localStorage.setItem(k, v); } catch (_) {} renderStorage(); },
onEdit: (k, v) => { const nv = prompt(`New value for localStorage["${k}"]`, v); if (nv === null) return; try { localStorage.setItem(k, nv); } catch (_) {} renderStorage(); },
onDelete: (k) => { try { localStorage.removeItem(k); } catch (_) {} renderStorage(); },
});
renderStorageSection("sessionStorage", getWebStorageEntries(window.sessionStorage), {
onAdd: () => { const k = prompt("Key?"); if (!k) return; const v = prompt("Value?", "") || ""; try { sessionStorage.setItem(k, v); } catch (_) {} renderStorage(); },
onEdit: (k, v) => { const nv = prompt(`New value for sessionStorage["${k}"]`, v); if (nv === null) return; try { sessionStorage.setItem(k, nv); } catch (_) {} renderStorage(); },
onDelete: (k) => { try { sessionStorage.removeItem(k); } catch (_) {} renderStorage(); },
});
}
function renderStorageSection(title, entries, handlers) {
const titleRow = document.createElement("div");
titleRow.className = "di-section-title";
const titleText = document.createElement("span");
titleText.textContent = `${title} (${entries.length})`;
titleRow.appendChild(titleText);
titleRow.appendChild(mkSmallBtn("+ Add", handlers.onAdd));
body.appendChild(titleRow);
if (!entries.length) {
const empty = document.createElement("div");
empty.style.cssText = "color:#777;font-size:11px;padding:4px 4px 10px;";
empty.textContent = "(empty)";
body.appendChild(empty);
return;
}
entries.forEach(({ key, value }) => {
const row = document.createElement("div");
row.className = "di-kv-row";
const kv = document.createElement("div");
kv.className = "di-kv-text";
kv.innerHTML = tt(`<span class="di-attr">${escapeHtml(key)}</span> = <span class="di-attrval">${escapeHtml(String(value))}</span>`);
row.appendChild(kv);
const actions = document.createElement("div");
actions.className = "di-kv-actions";
actions.appendChild(mkSmallBtn("Copy", () => copyText(String(value))));
actions.appendChild(mkSmallBtn("Edit", () => handlers.onEdit(key, value)));
const delBtn = mkSmallBtn("Delete", () => handlers.onDelete(key));
delBtn.className = "di-btn danger";
actions.appendChild(delBtn);
row.appendChild(actions);
body.appendChild(row);
});
}
/* ---------- Source tab ---------- */
function renderSource() {
body.replaceChildren();
const toolbar = document.createElement("div");
toolbar.className = "di-toolbar";
toolbar.appendChild(mkSmallBtn(sourceButtonLabel("html", "HTML"), () => toggleSourceView("html")));
toolbar.appendChild(mkSmallBtn(sourceButtonLabel("css", "CSS"), () => toggleSourceView("css")));
toolbar.appendChild(mkSmallBtn(sourceButtonLabel("js", "JS"), () => toggleSourceView("js")));
toolbar.appendChild(mkSmallBtn(fullSourceCache ? "Hide original source" : "Original source (fetch)", toggleFullSource));
body.appendChild(toolbar);
const note = document.createElement("div");
note.style.cssText = "color:#777;font-size:11px;padding:4px 4px 8px;line-height:1.5;";
note.textContent = 'HTML/CSS/JS show the live, already-rendered page. "Original source" fetches what the server actually sent before any JS ran. Tap a button again to collapse it.';
body.appendChild(note);
if (!expandedSource.size && !fullSourceCache) {
const hint = document.createElement("div");
hint.style.cssText = "color:#777;padding:10px 4px;";
hint.textContent = "Pick HTML / CSS / JS above, or fetch the original source.";
body.appendChild(hint);
}
["html", "css", "js"].forEach((mode) => { if (expandedSource.has(mode)) renderSourceContent(mode); });
if (fullSourceCache) appendSection(`Original source — ${fullSourceCache.label}`, fullSourceCache.html.slice(0, 20000));
appendSection("Environment", diagnosticsText());
}
function sourceButtonLabel(mode, name) {
return (expandedSource.has(mode) ? "Hide " : "") + name;
}
function toggleSourceView(mode) {
expandedSource.has(mode) ? expandedSource.delete(mode) : expandedSource.add(mode);
renderSource();
}
function renderSourceContent(mode) {
if (mode === "html") {
appendSection("Live DOM (document.documentElement.outerHTML)", document.documentElement.outerHTML.slice(0, 20000));
} else if (mode === "css") {
let text = "";
Array.from(document.styleSheets).forEach((sheet) => {
text += `/* ${sheet.href || "inline <style>"} */\n`;
try { Array.from(sheet.cssRules || []).forEach((r) => { text += r.cssText + "\n"; }); }
catch (_) { text += "/* not accessible (cross-origin stylesheet) */\n"; }
text += "\n";
});
appendSection("CSS (accessible stylesheets only)", text.slice(0, 20000) || "(none accessible)");
} else if (mode === "js") {
const text = Array.from(document.scripts)
.map((s, i) => (s.src ? `${i + 1}. <script src="${s.src}">` : `${i + 1}. inline script (${(s.textContent || "").length} chars)\n${(s.textContent || "").slice(0, 300)}${(s.textContent || "").length > 300 ? "…" : ""}`))
.join("\n\n");
appendSection("Scripts on page", text || "(none)");
}
}
function toggleFullSource() {
if (fullSourceCache) { fullSourceCache = null; renderSource(); return; }
setStatus("Fetching page source…", 0);
fetch(location.href)
.then((r) => r.text())
.then((html) => { fullSourceCache = { html, label: "fetched from server" }; setStatus(""); renderSource(); })
.catch(() => { fullSourceCache = { html: document.documentElement.outerHTML, label: "fetch blocked — showing live DOM instead" }; setStatus(""); renderSource(); });
}
function diagnosticsText() {
let webgl = "unavailable";
try {
const c = document.createElement("canvas");
const gl = c.getContext("webgl") || c.getContext("experimental-webgl");
if (gl) {
const dbg = gl.getExtension("WEBGL_debug_renderer_info");
webgl = dbg ? `${gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL)} / ${gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)}` : "available (renderer info hidden)";
}
} catch (_) {}
return [
`User agent: ${navigator.userAgent}`,
`Language: ${navigator.language}`,
`Timezone: ${Intl.DateTimeFormat().resolvedOptions().timeZone}`,
`Screen: ${screen.width}x${screen.height} @ DPR ${window.devicePixelRatio}`,
`Viewport: ${window.innerWidth}x${window.innerHeight}`,
`CPU threads: ${navigator.hardwareConcurrency || "n/a"}`,
`Memory: ${navigator.deviceMemory || "n/a"} GB`,
`WebGL: ${webgl}`,
].join("\n");
}
/* ---------- Settings tab ---------- */
function renderSettings() {
body.replaceChildren();
const title = document.createElement("div");
title.className = "di-settings-title";
title.textContent = "Trigger gestures";
body.appendChild(title);
body.appendChild(buildGestureRow("Open eruda", "eruda"));
body.appendChild(buildGestureRow("Open built-in inspector", "inspector"));
const resetBtn = document.createElement("button");
resetBtn.className = "di-btn";
resetBtn.textContent = "Reset to default";
resetBtn.style.margin = "12px 4px";
resetBtn.addEventListener("click", () => {
gestureCfg = { ...DEFAULT_GESTURES };
saveGestures(gestureCfg);
renderSettings();
setStatus("Reset to default gestures");
});
body.appendChild(resetBtn);
const note = document.createElement("div");
note.style.cssText = "color:#777;font-size:11px;padding:0 4px;line-height:1.6;";
note.textContent = "Hold down one finger and tap with another the configured number of times. Drag the handle bar at the top of the panel to resize it, or tap ▁ to minimize.";
body.appendChild(note);
const kbTitle = document.createElement("div");
kbTitle.className = "di-settings-title";
kbTitle.textContent = "Keyboard shortcuts (desktop)";
body.appendChild(kbTitle);
const kbRow = document.createElement("div");
kbRow.className = "di-settings-row";
const kbLabel = document.createElement("div");
kbLabel.style.cssText = "font-size:12px;color:#ddd;line-height:1.6;";
kbLabel.textContent = "Ctrl+Alt+E → eruda / Ctrl+Alt+I → inspector";
const kbToggle = document.createElement("input");
kbToggle.type = "checkbox";
kbToggle.checked = keyboardShortcutsEnabled;
kbToggle.style.cssText = "width:18px;height:18px;flex:0 0 auto;";
kbToggle.addEventListener("change", () => {
keyboardShortcutsEnabled = kbToggle.checked;
saveKeyboardEnabled(keyboardShortcutsEnabled);
setStatus(keyboardShortcutsEnabled ? "Keyboard shortcuts on" : "Keyboard shortcuts off");
});
kbRow.append(kbLabel, kbToggle);
body.appendChild(kbRow);
}
function buildGestureRow(label, key) {
const row = document.createElement("div");
row.className = "di-settings-row";
const labelEl = document.createElement("div");
labelEl.style.cssText = "font-size:12px;color:#ddd;";
labelEl.textContent = label;
const group = document.createElement("div");
group.className = "di-settings-group";
const fingerSelect = document.createElement("select");
[2, 3].forEach((n) => {
const opt = document.createElement("option");
opt.value = n;
opt.textContent = `${n} fingers`;
if (gestureCfg[key].fingers === n) opt.selected = true;
fingerSelect.appendChild(opt);
});
const tapSelect = document.createElement("select");
[2, 3, 4, 5].forEach((n) => {
const opt = document.createElement("option");
opt.value = n;
opt.textContent = `${n} taps`;
if (gestureCfg[key].taps === n) opt.selected = true;
tapSelect.appendChild(opt);
});
function applyChange() {
const next = { ...gestureCfg, [key]: { fingers: parseInt(fingerSelect.value, 10), taps: parseInt(tapSelect.value, 10) } };
if (gesturesCollide(next)) {
setStatus("Can't use the same gesture for both triggers", 2500);
fingerSelect.value = gestureCfg[key].fingers;
tapSelect.value = gestureCfg[key].taps;
return;
}
gestureCfg = next;
saveGestures(gestureCfg);
setStatus("Saved");
}
fingerSelect.addEventListener("change", applyChange);
tapSelect.addEventListener("change", applyChange);
group.append(fingerSelect, tapSelect);
row.append(labelEl, group);
return row;
}
document.addEventListener("DOMContentLoaded", () => {
try { if (localStorage.getItem(ERUDA_STATE_KEY) === "1") loadEruda(); } catch (_) {}
try { if (localStorage.getItem(INSPECTOR_STATE_KEY) === "1") showInspector(); } catch (_) {}
});
})();