import fs from "node:fs"; import http from "node:http"; import path from "node:path"; import { URL } from "node:url"; import { app } from "electron"; const SHOULD_INJECT_RESKIN = process.env.FIVEETOOLS_DESKTOP_RESKIN !== "0"; const SHOULD_FORCE_NIGHT_THEME = process.env.FIVEETOOLS_DESKTOP_FORCE_NIGHT !== "0"; const SHOULD_USE_EXTERNAL_IMAGE_FALLBACK = process.env.FIVEETOOLS_DESKTOP_EXTERNAL_IMAGES !== "0"; export const DEFAULT_EXTERNAL_IMAGE_ROOT = "https://5e.tools/img/"; const MIME_TYPES: Record = { ".avif": "image/avif", ".css": "text/css; charset=utf-8", ".eot": "application/vnd.ms-fontobject", ".gif": "image/gif", ".html": "text/html; charset=utf-8", ".ico": "image/x-icon", ".jpeg": "image/jpeg", ".jpg": "image/jpeg", ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".map": "application/json; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", ".mp3": "audio/mpeg", ".mp4": "video/mp4", ".otf": "font/otf", ".pdf": "application/pdf", ".png": "image/png", ".svg": "image/svg+xml", ".ttf": "font/ttf", ".txt": "text/plain; charset=utf-8", ".wasm": "application/wasm", ".webm": "video/webm", ".webp": "image/webp", ".woff": "font/woff", ".woff2": "font/woff2", ".webmanifest": "application/manifest+json; charset=utf-8", ".xml": "application/xml; charset=utf-8" }; const CORS_HEADERS: Record = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Allow-Headers": "Content-Type" }; function withCorsHeaders(headers: Record): Record { return { ...CORS_HEADERS, ...headers }; } function getDesktopReskinBootstrap(externalImageRoot: string | null): string { return ` (() => { ${externalImageRoot ? `globalThis.DEPLOYED_IMG_ROOT = ${JSON.stringify(externalImageRoot)};` : ""} const root = document.documentElement; root.classList.add("desktop-reskin", "desktop-app-navbar-mode"); ${ SHOULD_FORCE_NIGHT_THEME ? ` root.classList.add("ve-night-mode", "ve-night-mode--standard"); try { window.localStorage.setItem("StyleSwitcher_style", "night"); } catch {} ` : "" } const doToastInfo = (content) => { try { if (globalThis.JqueryUtil?.doToast) { globalThis.JqueryUtil.doToast({ type: "info", content }); return; } } catch {} console.info("[5ET-D]", content); }; const postToDesktop = (type, payload) => { if (window.parent && window.parent !== window) { window.parent.postMessage({ type, ...(payload || {}) }, "*"); return true; } return false; }; const requestOnboardingReset = () => { const message = [ "Reset onboarding and remove local 5etools data?", "", "This will clear downloaded source/images and return to first-run setup." ].join("\\n"); if (!window.confirm(message)) { return; } if (window.parent && window.parent !== window) { window.parent.postMessage({ type: "5etd:reset-onboarding" }, "*"); doToastInfo("Resetting onboarding..."); return; } doToastInfo("Reset onboarding is unavailable in this context."); }; const closeDropdown = (li, toggle) => { if (typeof NavBar !== "undefined" && typeof NavBar._closeDropdownElement === "function") { NavBar._closeDropdownElement(li); return; } li.classList.remove("open"); toggle.setAttribute("aria-expanded", "false"); }; const addCharactersMenu = () => { const navbar = document.getElementById("navbar"); if (!navbar || navbar.querySelector(".desktop-nav-characters")) return; const li = document.createElement("li"); li.setAttribute("role", "presentation"); li.className = "dropdown dropdown--navbar page__nav-hidden-mobile page__btn-nav-root desktop-nav-characters"; const toggle = document.createElement("a"); toggle.className = "ve-dropdown-toggle"; toggle.href = "#"; toggle.setAttribute("role", "button"); toggle.setAttribute("aria-haspopup", "true"); toggle.setAttribute("aria-expanded", "false"); toggle.innerHTML = 'Characters '; toggle.onclick = (event) => { if (typeof NavBar !== "undefined" && typeof NavBar._handleDropdownClick === "function") { NavBar._handleDropdownClick(toggle, event, false); return; } event.preventDefault(); event.stopPropagation(); const isOpen = li.classList.contains("open"); [...navbar.querySelectorAll(":scope > li.dropdown--navbar.open")] .forEach((it) => it.classList.remove("open")); li.classList.toggle("open", !isOpen); toggle.setAttribute("aria-expanded", String(!isOpen)); }; li.onmouseenter = function () { if (typeof NavBar !== "undefined" && typeof NavBar._handleItemMouseEnter === "function") { NavBar._handleItemMouseEnter(this); } }; const menu = document.createElement("ul"); menu.setAttribute("role", "menu"); menu.className = "ve-dropdown-menu ve-dropdown-menu--top"; menu.onclick = (event) => event.stopPropagation(); const addMenuItem = (label, onClick) => { const menuItem = document.createElement("li"); menuItem.setAttribute("role", "presentation"); const link = document.createElement("a"); link.href = "#"; link.textContent = label; link.onclick = (event) => { event.preventDefault(); event.stopPropagation(); onClick(); closeDropdown(li, toggle); }; menuItem.append(link); menu.append(menuItem); }; addMenuItem("New", () => { if (!postToDesktop("5etd:characters:new")) { doToastInfo("Characters menu is unavailable in this context."); } }); addMenuItem("Load from JSON", () => { if (!postToDesktop("5etd:characters:load-json")) { doToastInfo("Characters menu is unavailable in this context."); } }); addMenuItem("My Characters", () => { if (!postToDesktop("5etd:characters:list")) { doToastInfo("Characters menu is unavailable in this context."); } }); li.append(toggle, menu); const playerToggle = [...navbar.querySelectorAll(":scope > li.dropdown--navbar > a.ve-dropdown-toggle")] .find((it) => it.textContent?.trim().startsWith("Player")); const playerLi = playerToggle?.parentElement; const helpLi = navbar.querySelector(':scope > li[data-page^="https://wiki.tercept.net/en/5eTools"]'); if (playerLi?.nextSibling) { navbar.insertBefore(li, playerLi.nextSibling); return; } if (playerLi) { navbar.append(li); return; } if (helpLi) { navbar.insertBefore(li, helpLi); return; } navbar.append(li); }; const addResetOnboardingMenu = () => { const navbar = document.getElementById("navbar"); if (!navbar) return; const settingsToggle = [...navbar.querySelectorAll(":scope > li.dropdown--navbar > a.ve-dropdown-toggle")] .find((it) => it.textContent?.trim().startsWith("Settings")); const settingsLi = settingsToggle?.parentElement; if (!settingsLi) return; const menu = settingsLi.querySelector(":scope > ul.ve-dropdown-menu") || settingsLi.querySelector(":scope > ul.dropdown-menu"); if (!menu || menu.querySelector(".desktop-nav-reset-onboarding")) return; const divider = document.createElement("li"); divider.className = "divider desktop-nav-reset-onboarding"; divider.setAttribute("role", "presentation"); const item = document.createElement("li"); item.className = "desktop-nav-reset-onboarding"; item.setAttribute("role", "presentation"); const link = document.createElement("a"); link.href = "#"; link.textContent = "Reset Onboarding"; link.onclick = (event) => { event.preventDefault(); event.stopPropagation(); requestOnboardingReset(); if (typeof NavBar !== "undefined" && typeof NavBar._closeDropdownElement === "function") { NavBar._closeDropdownElement(settingsLi); return; } settingsLi.classList.remove("open"); settingsToggle?.setAttribute("aria-expanded", "false"); }; item.append(link); menu.append(divider, item); }; let isAutoFocusScheduled = false; let hasAutoFocusedSearch = false; const tryAutoFocusSearch = () => { if (hasAutoFocusedSearch) { return true; } const active = document.activeElement; if (active && active !== document.body && active !== document.documentElement) { return true; } const input = document.querySelector("#navbar .omni__input, .omni__input"); if (!(input instanceof HTMLInputElement)) { return false; } input.focus({ preventScroll: true }); input.select(); if (document.activeElement === input) { hasAutoFocusedSearch = true; return true; } return false; }; const scheduleAutoFocusSearch = () => { if (isAutoFocusScheduled || hasAutoFocusedSearch) { return; } isAutoFocusScheduled = true; let attempts = 0; const maxAttempts = 80; const intervalMs = 75; const runAttempt = () => { if (tryAutoFocusSearch()) { isAutoFocusScheduled = false; return; } attempts += 1; if (attempts >= maxAttempts) { isAutoFocusScheduled = false; } }; runAttempt(); const interval = window.setInterval(() => { if (!isAutoFocusScheduled) { window.clearInterval(interval); return; } runAttempt(); }, intervalMs); const observer = new MutationObserver(() => { if (tryAutoFocusSearch()) { isAutoFocusScheduled = false; observer.disconnect(); } }); const obsTarget = document.body || document.documentElement; if (obsTarget) { observer.observe(obsTarget, { childList: true, subtree: true }); } window.setTimeout(() => { observer.disconnect(); isAutoFocusScheduled = false; }, maxAttempts * intervalMs + 250); }; const applyDesktopMenuExtensions = () => { addCharactersMenu(); addResetOnboardingMenu(); bindNativeNavbarSync(); queueNativeNavbarModelPost({ isForce: true }); }; const NAVBAR_ACTION_ATTR = "data-5etd-nav-action-id"; const cleanNavLabel = (value) => String(value || "").replace(/\\s+/g, " ").replace(/[▾▼►]+/g, "").trim(); const getNavPrimaryText = (ele) => { for (const node of Array.from(ele.childNodes || [])) { if (node.nodeType === Node.TEXT_NODE) { const text = cleanNavLabel(node.textContent || ""); if (text) return text; } } return cleanNavLabel(ele.textContent || ""); }; const getNavEntryMeta = (ele) => { if (!ele) return {}; const out = {}; const dateEle = ele.querySelector(":scope > .page__nav-date"); if (dateEle) { const datePrefix = cleanNavLabel(dateEle.textContent || ""); if (datePrefix) out.datePrefix = datePrefix; } const sourceEle = ele.querySelector(":scope > .nav2-list__disp-source"); if (sourceEle instanceof HTMLElement) { const computed = getComputedStyle(sourceEle); const sourceColor = String(computed.backgroundColor || computed.borderLeftColor || "").trim(); if ( sourceColor && sourceColor !== "transparent" && !/^rgba?\\(\\s*0\\s*,\\s*0\\s*,\\s*0(?:\\s*,\\s*0)?\\s*\\)$/i.test(sourceColor) ) { out.sourceColor = sourceColor; } } return out; }; const isActionLikeHref = (hrefRaw) => { const href = String(hrefRaw || "").trim().toLowerCase(); return !href || href === "#" || href.startsWith("javascript:"); }; const parseNavbarMenuList = (list, idPrefix) => { const entries = []; const liElements = Array.from(list?.children || []).filter((it) => it instanceof HTMLLIElement); liElements.forEach((li, index) => { const entryId = \`\${idPrefix}-\${index}\`; const submenuList = li.querySelector(":scope > ul") || (li.classList.contains("nav2-accord__wrp") ? li.querySelector(":scope > .nav2-accord__body") : null); const submenuChildren = submenuList ? parseNavbarMenuList(submenuList, \`\${entryId}-submenu\`) : []; if (li.classList.contains("ve-dropdown-divider") || li.classList.contains("divider")) { entries.push({ id: \`\${entryId}-divider\`, type: "divider" }); return; } if (li.classList.contains("nav2-list__label")) { const label = cleanNavLabel(li.textContent || ""); if (label) entries.push({ id: \`\${entryId}-label\`, type: "label", label }); return; } if (li.classList.contains("dropdown--navbar")) { const toggle = li.querySelector(":scope > a"); const label = toggle ? getNavPrimaryText(toggle) : ""; if (!label) return; entries.push({ id: \`\${entryId}-submenu\`, type: "submenu", label, ...getNavEntryMeta(toggle), children: submenuChildren }); return; } if (li.classList.contains("nav2-accord__wrp")) { const headPrimary = li.querySelector(":scope > .nav2-accord__head > :first-child"); const label = headPrimary ? getNavPrimaryText(headPrimary) : ""; if (!label || !submenuChildren.length) return; entries.push({ id: \`\${entryId}-accordion\`, type: "submenu", label, ...getNavEntryMeta(headPrimary), children: submenuChildren }); return; } const link = li.querySelector(":scope > a"); if (link) { const label = getNavPrimaryText(link); const href = String(link.getAttribute("href") || "").trim(); if (submenuChildren.length) { if (isActionLikeHref(href)) { const actionId = \`\${entryId}-submenu-action\`; link.setAttribute(NAVBAR_ACTION_ATTR, actionId); entries.push({ id: \`\${entryId}-submenu-link\`, type: "submenu", label: label || "Section", ...getNavEntryMeta(link), actionId, children: submenuChildren }); return; } entries.push({ id: \`\${entryId}-submenu-link\`, type: "submenu", label: label || href, ...getNavEntryMeta(link), href, isExternal: link.target === "_blank" || /^https?:\\/\\//i.test(href), children: submenuChildren }); return; } if (isActionLikeHref(href)) { const actionId = \`\${entryId}-action\`; link.setAttribute(NAVBAR_ACTION_ATTR, actionId); entries.push({ id: \`\${entryId}-action\`, type: "action", label: label || "Action", actionId }); return; } entries.push({ id: \`\${entryId}-link\`, type: "link", label: label || href, ...getNavEntryMeta(link), href, isExternal: link.target === "_blank" || /^https?:\\/\\//i.test(href) }); return; } const spans = Array.from(li.querySelectorAll(":scope > span")).filter((it) => it instanceof HTMLSpanElement); if (submenuChildren.length && spans.length) { const first = spans[0]; const label = cleanNavLabel(first.textContent || first.title || ""); const actionId = \`\${entryId}-submenu-span\`; first.setAttribute(NAVBAR_ACTION_ATTR, actionId); entries.push({ id: \`\${entryId}-submenu-span\`, type: "submenu", label: label || "Section", ...getNavEntryMeta(first), actionId, children: submenuChildren }); return; } spans.forEach((span, spanIndex) => { const label = cleanNavLabel(span.textContent || span.title || ""); const actionId = \`\${entryId}-span-\${spanIndex}\`; span.setAttribute(NAVBAR_ACTION_ATTR, actionId); entries.push({ id: \`\${entryId}-span-\${spanIndex}\`, type: "action", label: label || "Action", ...getNavEntryMeta(span), actionId }); }); }); return entries; }; const buildNativeNavbarGroups = () => { const navbar = document.getElementById("navbar"); if (!navbar) return []; const groups = []; const rootItems = Array.from(navbar.children).filter((it) => it instanceof HTMLLIElement); rootItems.forEach((li, index) => { if ( li.classList.contains("omni__wrp-input") || li.classList.contains("desktop-nav-characters") ) { return; } if (li.classList.contains("dropdown--navbar")) { const toggle = li.querySelector(":scope > a"); const menuList = li.querySelector(":scope > ul"); const label = toggle ? getNavPrimaryText(toggle) : ""; if (!label) return; groups.push({ id: \`root-\${index}-dropdown\`, kind: "dropdown", label, items: menuList ? parseNavbarMenuList(menuList, \`root-\${index}-list\`) : [] }); return; } const link = li.querySelector(":scope > a"); if (!link) return; const label = getNavPrimaryText(link); const href = String(link.getAttribute("href") || "").trim(); if (!label) return; if (isActionLikeHref(href)) { const actionId = \`root-\${index}-action\`; link.setAttribute(NAVBAR_ACTION_ATTR, actionId); groups.push({ id: \`root-\${index}-action\`, kind: "action", label, actionId }); return; } groups.push({ id: \`root-\${index}-link\`, kind: "link", label, href, isExternal: link.target === "_blank" || /^https?:\\/\\//i.test(href) }); }); return groups; }; let navbarModelLastSignature = ""; let isNavbarModelPostQueued = false; const postNativeNavbarModel = ({ isForce = false } = {}) => { if (!window.parent || window.parent === window) return; const groups = buildNativeNavbarGroups(); let nextSignature = ""; try { nextSignature = JSON.stringify(groups); } catch { nextSignature = \`groups:\${groups.length}:\${Date.now()}\`; } if (!isForce && nextSignature === navbarModelLastSignature) { return; } navbarModelLastSignature = nextSignature; window.parent.postMessage( { type: "5etd:native-navbar-data", groups }, "*" ); }; const queueNativeNavbarModelPost = ({ isForce = false } = {}) => { if (isForce) { isNavbarModelPostQueued = false; postNativeNavbarModel({ isForce: true }); return; } if (isNavbarModelPostQueued) { return; } isNavbarModelPostQueued = true; window.setTimeout(() => { isNavbarModelPostQueued = false; postNativeNavbarModel(); }, 80); }; let navbarSyncObserver = null; const bindNativeNavbarSync = () => { const navbar = document.getElementById("navbar"); if (!navbar || navbar.dataset.desktopNavSyncBound === "1") { return; } navbar.dataset.desktopNavSyncBound = "1"; navbarSyncObserver?.disconnect?.(); navbarSyncObserver = new MutationObserver(() => { queueNativeNavbarModelPost(); }); navbarSyncObserver.observe(navbar, { childList: true, subtree: true }); }; const runNativeNavbarAction = (actionId) => { const id = String(actionId || "").trim(); if (!id) return; const navbar = document.getElementById("navbar"); if (!navbar) return; const actionEle = navbar.querySelector(\`[\${NAVBAR_ACTION_ATTR}="\${id}"]\`); if (!(actionEle instanceof HTMLElement)) { return; } actionEle.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window })); actionEle.click(); }; window.addEventListener("message", (event) => { const data = event?.data; if (!data || typeof data !== "object") { return; } if (data.type === "5etd:reset-onboarding-result" && data.ok === false) { const reason = typeof data.error === "string" && data.error ? data.error : "unknown error"; doToastInfo(\`Reset onboarding failed: \${reason}\`); return; } if (data.type === "5etd:native-navbar-request") { bindNativeNavbarSync(); queueNativeNavbarModelPost({ isForce: true }); return; } if (data.type === "5etd:native-navbar-run-action") { runNativeNavbarAction(data.actionId); } }); window.addEventListener("DOMContentLoaded", applyDesktopMenuExtensions); window.addEventListener("load", applyDesktopMenuExtensions); })(); `; } const DESKTOP_RESKIN_STYLE = ` html.desktop-reskin { --desktop-shell-gutter: 14px; --desktop-topbar-pad-x: 18px; --desktop-nav-top-offset: 10px; --desktop-nav-reserved-height: 92px; } html.desktop-reskin body { padding-top: calc(var(--safe-area-inset-top) + var(--desktop-nav-reserved-height)) !important; background: radial-gradient(circle at 13% -2%, rgba(77, 140, 222, 0.3), transparent 40%), radial-gradient(circle at 86% -4%, rgba(241, 176, 92, 0.24), transparent 38%), linear-gradient(180deg, #0c1422 0%, #0a1019 100%) !important; color: #dce7f6; } html.desktop-reskin .viewport-wrapper { inset: calc(var(--safe-area-inset-top) + var(--desktop-nav-reserved-height)) 0 0 0 !important; height: auto !important; min-height: calc(100vh - (var(--safe-area-inset-top) + var(--desktop-nav-reserved-height))); } html.desktop-reskin .page__header { margin: 8px var(--desktop-shell-gutter) 0 !important; padding: 0 !important; border: 1px solid rgba(109, 164, 223, 0.32) !important; border-radius: 18px; background: linear-gradient(115deg, rgba(17, 29, 47, 0.98), rgba(24, 38, 58, 0.96) 64%, rgba(87, 58, 32, 0.88)); // backdrop-filter: blur(14px); box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24); } html.desktop-reskin .page__header .container { width: 100% !important; max-width: none !important; padding: 10px var(--desktop-topbar-pad-x) !important; } html.desktop-reskin .page__header .ve-flex-v-baseline { flex-wrap: wrap; row-gap: 4px; } html.desktop-reskin .page__title { margin: 0 !important; color: #ebf4ff !important; font-size: clamp(2.8rem, 4.6vw, 7rem); line-height: 1; } html.desktop-reskin .page__subtitle { margin: 0 0 0 14px !important; color: #bccce0 !important; font-size: clamp(1.2rem, 1.75vw, 3.1rem); } html.desktop-reskin #navigation.page__nav { margin: 0 !important; padding: 0 !important; position: fixed; top: calc(var(--safe-area-inset-top) + var(--desktop-nav-top-offset)); left: var(--desktop-shell-gutter); right: var(--desktop-shell-gutter); width: auto !important; max-width: none !important; border: 1px solid rgba(109, 164, 223, 0.32) !important; border-radius: 18px; background: linear-gradient(180deg, rgba(12, 21, 35, 0.96), rgba(8, 15, 27, 0.98)); box-shadow: 0 12px 26px rgba(0, 0, 0, 0.28); z-index: 3600 !important; } html.desktop-reskin #navigation.page__nav .page__nav-inner { width: 100% !important; margin: 0 !important; padding: 10px var(--desktop-topbar-pad-x) !important; overflow: visible; } html.desktop-reskin #navbar { background: transparent !important; border-bottom: 0 !important; box-shadow: none; display: flex; align-items: stretch; flex-wrap: wrap; gap: 4px; min-width: 0; max-width: 100%; overflow: visible; position: relative; z-index: 3001 !important; } html.desktop-reskin #navbar > li { float: none !important; flex: 0 0 auto; min-width: 0; } html.desktop-reskin #navbar > li > a { border: 1px solid rgba(107, 159, 214, 0.28); background: rgba(19, 31, 49, 0.9); border-radius: 8px; margin: 0 !important; display: flex; align-items: center; height: 100%; transition: background-color 120ms ease, border-color 120ms ease; } html.desktop-reskin #navbar > li.page__btn-nav-root > a { border-color: rgba(116, 174, 233, 0.44) !important; background: linear-gradient(180deg, rgba(22, 37, 58, 0.94), rgba(14, 24, 39, 0.97)) !important; box-shadow: inset 0 1px 0 rgba(162, 214, 255, 0.12); } html.desktop-reskin #navbar > li.dropdown--navbar > a.ve-dropdown-toggle { border-color: rgba(116, 174, 233, 0.44) !important; background: linear-gradient(180deg, rgba(22, 37, 58, 0.94), rgba(14, 24, 39, 0.97)) !important; box-shadow: inset 0 1px 0 rgba(162, 214, 255, 0.12); } html.desktop-reskin #navbar .navbar-brand, html.desktop-reskin #navbar > li > a, html.desktop-reskin #navbar > li > a .caret { color: #e6f1ff !important; } html.desktop-reskin #navbar > li > a:hover { background: rgba(65, 121, 191, 0.32); border-color: rgba(118, 184, 247, 0.56); } html.desktop-reskin #navbar > li.page__btn-nav-root > a:hover, html.desktop-reskin #navbar > li.page__btn-nav-root > a:focus { background: linear-gradient(145deg, rgba(52, 102, 161, 0.78), rgba(31, 86, 142, 0.82)) !important; border-color: rgba(136, 202, 255, 0.84) !important; } html.desktop-reskin #navbar > li.dropdown--navbar > a.ve-dropdown-toggle:hover, html.desktop-reskin #navbar > li.dropdown--navbar > a.ve-dropdown-toggle:focus { background: linear-gradient(145deg, rgba(52, 102, 161, 0.78), rgba(31, 86, 142, 0.82)) !important; border-color: rgba(136, 202, 255, 0.84) !important; } html.desktop-reskin #navbar > li.active > a, html.desktop-reskin #navbar > li.open > a { background: linear-gradient(140deg, rgba(33, 126, 209, 0.9), rgba(25, 95, 167, 0.94)); border-color: rgba(123, 196, 255, 0.86); color: #f4f9ff !important; } html.desktop-reskin #navbar > li.dropdown--navbar.open > a.ve-dropdown-toggle, html.desktop-reskin #navbar > li.dropdown--navbar.active > a.ve-dropdown-toggle { background: linear-gradient(140deg, rgba(34, 127, 210, 0.92), rgba(25, 95, 167, 0.96)) !important; border-color: rgba(139, 207, 255, 0.92) !important; box-shadow: inset 0 1px 0 rgba(229, 245, 255, 0.22); } html.desktop-reskin #navbar > li.page__btn-nav-root.active > a, html.desktop-reskin #navbar > li.page__btn-nav-root.open > a { background: linear-gradient(140deg, rgba(34, 127, 210, 0.92), rgba(25, 95, 167, 0.96)) !important; border-color: rgba(139, 207, 255, 0.92) !important; box-shadow: inset 0 1px 0 rgba(229, 245, 255, 0.22); } html.desktop-reskin #navbar > li.dropdown--navbar > a.ve-dropdown-toggle > .caret { border-top-color: #c9e6ff !important; opacity: 0.95; } html.desktop-reskin #navigation.page__nav .page__btn-toggle-nav { margin: 0 !important; width: 48px !important; min-width: 48px !important; height: 44px !important; padding: 0 !important; border: 1px solid rgba(116, 174, 233, 0.52) !important; border-radius: 12px !important; background: linear-gradient(180deg, rgba(22, 36, 56, 0.95), rgba(13, 23, 38, 0.98)) !important; box-shadow: inset 0 1px 0 rgba(172, 220, 255, 0.16) !important; position: relative; overflow: hidden; text-indent: -9999px; color: transparent !important; } html.desktop-reskin #navigation.page__nav .page__btn-toggle-nav::before, html.desktop-reskin #navigation.page__nav .page__btn-toggle-nav::after { content: ""; position: absolute; left: 13px; right: 13px; height: 2px; border-radius: 2px; background: #ddebff; transition: transform 150ms ease, top 150ms ease, box-shadow 150ms ease; } html.desktop-reskin #navigation.page__nav .page__btn-toggle-nav::before { top: 50%; transform: translateY(-8px); box-shadow: 0 7px 0 #ddebff; } html.desktop-reskin #navigation.page__nav .page__btn-toggle-nav::after { top: 50%; transform: translateY(8px); } html.desktop-reskin #navigation.page__nav .page__btn-toggle-nav:hover, html.desktop-reskin #navigation.page__nav .page__btn-toggle-nav:focus { border-color: rgba(137, 204, 255, 0.88) !important; background: linear-gradient(145deg, rgba(51, 101, 160, 0.82), rgba(31, 84, 139, 0.86)) !important; } html.desktop-reskin #navigation.page__nav .page__btn-toggle-nav.active { border-color: rgba(139, 207, 255, 0.92) !important; background: linear-gradient(140deg, rgba(34, 127, 210, 0.92), rgba(25, 95, 167, 0.96)) !important; box-shadow: inset 0 1px 0 rgba(229, 245, 255, 0.22) !important; } html.desktop-reskin #navigation.page__nav .page__btn-toggle-nav.active::before { top: 50%; transform: translateY(0) rotate(45deg); box-shadow: none; } html.desktop-reskin #navigation.page__nav .page__btn-toggle-nav.active::after { top: 50%; transform: translateY(0) rotate(-45deg); } html.desktop-reskin #navbar > .omni__wrp-input:not(.omni__wrp-input--scrolled) { margin-left: auto !important; flex: 1 1 320px; min-width: 220px; max-width: 460px; } html.desktop-reskin #navbar > .omni__wrp-input:not(.omni__wrp-input--scrolled) .omni__input { min-width: 0 !important; width: 100%; border-right: 1px solid rgba(102, 155, 210, 0.42) !important; border-top-right-radius: 12px !important; border-bottom-right-radius: 12px !important; } html.desktop-reskin #navbar > .omni__wrp-input:not(.omni__wrp-input--scrolled) .omni__submit { display: none !important; } html.desktop-reskin #navbar > .omni__wrp-input:not(.omni__wrp-input--scrolled) .omni__btn-clear { right: 12px !important; } @media (max-width: 1400px) { html.desktop-reskin #navbar > .omni__wrp-input:not(.omni__wrp-input--scrolled) { flex-basis: 260px; min-width: 190px; } } @media (max-width: 1180px) { html.desktop-reskin { --desktop-topbar-pad-x: 12px; --desktop-nav-reserved-height: 108px; } html.desktop-reskin #navbar { gap: 3px; } html.desktop-reskin #navbar > .omni__wrp-input:not(.omni__wrp-input--scrolled) { margin-left: 0 !important; flex-basis: 100%; max-width: 100%; } } @media (max-width: 991px) { html.desktop-reskin { --desktop-nav-reserved-height: 84px; } html.desktop-reskin #navigation.page__nav { display: grid !important; grid-template-columns: auto minmax(0, 1fr); column-gap: 10px; row-gap: 8px; align-items: center; width: auto !important; padding: 10px 12px 0 !important; } html.desktop-reskin #navigation.page__nav .page__btn-toggle-nav { grid-column: 1; grid-row: 1; align-self: stretch; } html.desktop-reskin #navigation.page__nav .page__nav-inner { grid-column: 2; grid-row: 1; margin: 0 !important; padding: 0 !important; width: 100% !important; } html.desktop-reskin #navbar { flex-direction: column; align-items: stretch; flex-wrap: nowrap; gap: 8px; margin: 0 !important; padding: 0 !important; } html.desktop-reskin #navbar > li { width: 100%; } html.desktop-reskin #navbar > li > a { width: 100%; min-height: 46px; padding: 10px 16px !important; justify-content: flex-start; } html.desktop-reskin #navbar > .omni__wrp-input, html.desktop-reskin #navbar > .omni__wrp-input:not(.omni__wrp-input--scrolled) { order: -1; margin: 0 !important; width: 100%; max-width: 100%; flex: 0 0 auto; } html.desktop-reskin #navbar > .omni__wrp-input .omni__input, html.desktop-reskin #navbar > .omni__wrp-input:not(.omni__wrp-input--scrolled) .omni__input { width: 100%; min-width: 0 !important; } html.desktop-reskin #navbar > li.page__nav-hidden-mobile { margin: 0 !important; } html.desktop-reskin #navbar > li.dropdown--navbar.open > .dropdown-menu, html.desktop-reskin #navbar > li.dropdown--navbar.open > .ve-dropdown-menu { position: static !important; float: none !important; width: 100% !important; margin: 6px 0 0 !important; } html.desktop-reskin #navbar .ve-dropdown-menu--side { margin-left: 0 !important; } } html.desktop-reskin #navbar .form-control, html.desktop-reskin #navbar input[type="search"] { background: rgba(8, 14, 24, 0.96) !important; border: 1px solid rgba(102, 155, 210, 0.42) !important; color: #e4effb !important; height: 44px !important; border-radius: 12px !important; } html.desktop-reskin #navbar .form-control::placeholder, html.desktop-reskin #navbar input[type="search"]::placeholder { color: #96adc8 !important; } html.desktop-reskin #navbar .btn { border-radius: 12px !important; border-color: rgba(102, 155, 210, 0.42) !important; background: rgba(14, 24, 40, 0.95) !important; color: #d9e8fb !important; } html.desktop-reskin #navbar .omni__submit, html.desktop-reskin #navbar .omni__submit.ve-btn { display: none !important; } html.desktop-reskin #navbar .dropdown-menu, html.desktop-reskin #navbar .ve-dropdown-menu { border: 1px solid rgba(102, 155, 210, 0.42) !important; border-radius: 12px !important; background: linear-gradient(180deg, rgba(13, 22, 37, 0.98), rgba(9, 17, 30, 0.99)) !important; padding: 4px !important; box-shadow: 0 12px 24px rgba(0, 0, 0, 0.34); z-index: 3200 !important; } html.desktop-reskin #navbar .ve-dropdown-menu--top { margin-top: 4px !important; } html.desktop-reskin #navbar .ve-dropdown-menu--side { margin-left: 4px !important; } html.desktop-reskin #navbar .dropdown-menu > li > a, html.desktop-reskin #navbar .ve-dropdown-menu > li > a, html.desktop-reskin #navbar .ve-dropdown-menu > li > span { color: #dce9fa !important; border-radius: 8px; padding: 6px 12px !important; transition: background-color 100ms ease, color 100ms ease; } html.desktop-reskin #navbar .dropdown-menu > li > a:hover, html.desktop-reskin #navbar .dropdown-menu > li > a:focus, html.desktop-reskin #navbar .ve-dropdown-menu > li > a:hover, html.desktop-reskin #navbar .ve-dropdown-menu > li > a:focus, html.desktop-reskin #navbar .ve-dropdown-menu > li > span:hover, html.desktop-reskin #navbar .ve-dropdown-menu > li > span:focus { background: rgba(58, 108, 170, 0.34) !important; color: #f2f8ff !important; } html.desktop-reskin #navbar .dropdown-menu > li.active > a, html.desktop-reskin #navbar .ve-dropdown-menu > li.active > a, html.desktop-reskin #navbar .ve-dropdown-menu > li.active > span { background: linear-gradient(140deg, rgba(36, 124, 202, 0.92), rgba(28, 95, 161, 0.94)) !important; color: #f5fbff !important; } html.desktop-reskin #navbar .dropdown-menu > li.disabled > a, html.desktop-reskin #navbar .ve-dropdown-menu > li.disabled > a, html.desktop-reskin #navbar .ve-dropdown-menu > li.disabled > span { color: #8aa2bd !important; } html.desktop-reskin #navbar .ve-dropdown-divider, html.desktop-reskin #navbar .dropdown-menu .divider { margin: 6px 4px !important; height: 1px; background: rgba(106, 156, 213, 0.42) !important; } html.desktop-reskin #navbar .ve-dropdown-ext-link > .glyphicon { color: #9ec9f3 !important; } html.desktop-reskin .input-xs, html.desktop-reskin .form-control, html.desktop-reskin select, html.desktop-reskin input, html.desktop-reskin textarea { border-radius: 8px !important; border-color: rgba(115, 170, 226, 0.45) !important; } html.desktop-reskin .btn, html.desktop-reskin .ve-btn { border-radius: 9px !important; } html.desktop-reskin .omni__wrp-output { position: relative; z-index: 3400 !important; } html.desktop-reskin .omni__output { border: 1px solid rgba(114, 171, 232, 0.45) !important; border-radius: 12px !important; background: linear-gradient(180deg, rgba(15, 25, 41, 0.98), rgba(9, 17, 29, 0.99)) !important; box-shadow: 0 16px 30px rgba(0, 0, 0, 0.42); color: #dce9f8 !important; } html.desktop-reskin .omni__output .ve-btn, html.desktop-reskin .omni__output .btn { border: 1px solid rgba(112, 167, 225, 0.42) !important; background: rgba(22, 36, 56, 0.92) !important; color: #dce9f8 !important; } html.desktop-reskin .omni__output .ve-btn.active, html.desktop-reskin .omni__output .btn.active, html.desktop-reskin .omni__output .ve-btn:hover, html.desktop-reskin .omni__output .btn:hover { background: rgba(53, 95, 145, 0.58) !important; border-color: rgba(139, 204, 255, 0.72) !important; } html.desktop-reskin .omni__row-result { padding: 3px 6px; border-radius: 8px; border-bottom: 1px solid rgba(105, 151, 205, 0.3); color: #d7e6f7 !important; } html.desktop-reskin .omni__row-result:nth-child(odd) { background: rgba(43, 71, 105, 0.38) !important; } html.desktop-reskin .omni__row-result:hover { background: rgba(63, 103, 151, 0.52) !important; } html.desktop-reskin .omni__lnk-name { color: #8ec9ff !important; } html.desktop-reskin .omni__disp-srd, html.desktop-reskin .omni__disp-source-marker, html.desktop-reskin .omni__wrp-page { color: #aecaec !important; } html.desktop-reskin .omni__wrp-paginate { margin-top: 4px; padding-top: 6px; border-top: 1px solid rgba(107, 156, 211, 0.35); } html.desktop-reskin .omni__paginate-ctrl { color: #c8def8; } html.desktop-reskin .omni__paginate-ctrl:hover { color: #8dc9ff !important; } html.desktop-reskin .list, html.desktop-reskin .list-display-only, html.desktop-reskin .lst__row, html.desktop-reskin .ui-list__row, html.desktop-reskin .list li { color: #dce8f8; } html.desktop-reskin .lst__row, html.desktop-reskin .ui-list__row, html.desktop-reskin .list li, html.desktop-reskin .lst__row-border, html.desktop-reskin .lst__row-inner, html.desktop-reskin .veapp__list-row { border-radius: 10px; } html.desktop-reskin .lst__row-border, html.desktop-reskin .lst__row-inner, html.desktop-reskin .veapp__list-row, html.desktop-reskin .lst__wrp-cells { border-color: rgba(112, 165, 222, 0.42) !important; color: #dce8f8 !important; } html.desktop-reskin .list .lst__row:nth-child(odd) .lst__row-border, html.desktop-reskin .list .lst__row:nth-child(odd) .lst__row-inner, html.desktop-reskin .list .lst__row:nth-child(odd).lst__row-border, html.desktop-reskin .list .lst__row:nth-child(odd).veapp__list-row { background: rgba(34, 57, 86, 0.36); } html.desktop-reskin .list .lst__row:nth-child(even) .lst__row-border, html.desktop-reskin .list .lst__row:nth-child(even) .lst__row-inner, html.desktop-reskin .list .lst__row:nth-child(even).lst__row-border, html.desktop-reskin .list .lst__row:nth-child(even).veapp__list-row { background: rgba(18, 30, 48, 0.38); } html.desktop-reskin .list .lst__row:hover .lst__row-border, html.desktop-reskin .list .lst__row:hover .lst__row-inner, html.desktop-reskin .list .lst__row:hover.lst__row-border, html.desktop-reskin .list .lst__row:hover.veapp__list-row { background: rgba(64, 105, 151, 0.48) !important; } html.desktop-reskin .lst__row a, html.desktop-reskin .veapp__list-row a, html.desktop-reskin .lst__wrp-cells a { color: inherit; } html.desktop-reskin .lst__form-top > *, html.desktop-reskin .lst__form-top .form-control, html.desktop-reskin .lst__form-top .btn, html.desktop-reskin .lst__form-top .ve-btn { border-color: rgba(112, 167, 225, 0.48) !important; background: rgba(15, 25, 40, 0.94) !important; color: #dce8f8 !important; } html.desktop-reskin .lst__form-top .form-control::placeholder { color: #99b2cf !important; } html.desktop-reskin .fltr__pill, html.desktop-reskin .fltr__mini-pill, html.desktop-reskin .fltr__btn_nest { border-color: rgba(109, 163, 221, 0.45) !important; background: rgba(20, 32, 50, 0.92) !important; color: #dce8f8 !important; } html.desktop-reskin .fltr__pill[data-state="yes"], html.desktop-reskin .fltr__mini-pill[data-state="yes"] { background: linear-gradient(140deg, rgba(38, 120, 196, 0.9), rgba(27, 89, 151, 0.94)) !important; border-color: rgba(131, 196, 255, 0.84) !important; } html.desktop-reskin .fltr__pill[data-state="no"], html.desktop-reskin .fltr__mini-pill[data-state="no"] { background: linear-gradient(140deg, rgba(136, 55, 55, 0.92), rgba(109, 44, 44, 0.94)) !important; border-color: rgba(206, 132, 132, 0.7) !important; } html.desktop-reskin .wrp-stats-table { border-top: 1px solid rgba(114, 171, 232, 0.5) !important; border-bottom: 1px solid rgba(114, 171, 232, 0.5) !important; background: rgba(9, 15, 25, 0.5); } html.desktop-reskin .table, html.desktop-reskin table { color: #dce8f8; } html.desktop-reskin .table > thead > tr > th, html.desktop-reskin .table > tbody > tr > th, html.desktop-reskin .table > tfoot > tr > th, html.desktop-reskin .table > thead > tr > td, html.desktop-reskin .table > tbody > tr > td, html.desktop-reskin .table > tfoot > tr > td, html.desktop-reskin table > thead > tr > th, html.desktop-reskin table > tbody > tr > th, html.desktop-reskin table > tfoot > tr > th, html.desktop-reskin table > thead > tr > td, html.desktop-reskin table > tbody > tr > td, html.desktop-reskin table > tfoot > tr > td { border-color: rgba(104, 154, 209, 0.42) !important; } html.desktop-reskin .table > thead > tr > th, html.desktop-reskin table > thead > tr > th { background: rgba(33, 59, 92, 0.62); color: #e8f2ff; } html.desktop-reskin .table-striped > tbody > tr:nth-of-type(odd), html.desktop-reskin .stripe-odd-table > tbody > tr:nth-child(odd), html.desktop-reskin .stripe-even-table > tbody > tr:nth-child(even), html.desktop-reskin .stripe-odd:nth-child(odd), html.desktop-reskin .stripe-even:nth-child(even) { background: rgba(42, 70, 104, 0.38) !important; } html.desktop-reskin .hwin, html.desktop-reskin .ve-window, html.desktop-reskin .modal__inner { border: 1px solid rgba(115, 170, 226, 0.35) !important; border-radius: 12px !important; box-shadow: 0 16px 28px rgba(0, 0, 0, 0.35); } /* App-managed top bar mode: keep 5etools content, hide native header/nav chrome */ html.desktop-reskin.desktop-app-navbar-mode body { padding-top: 0 !important; } html.desktop-reskin.desktop-app-navbar-mode .viewport-wrapper { inset: 0 !important; height: auto !important; min-height: 100vh !important; } html.desktop-reskin.desktop-app-navbar-mode header.page__header, html.desktop-reskin.desktop-app-navbar-mode .page__header, html.desktop-reskin.desktop-app-navbar-mode #navigation, html.desktop-reskin.desktop-app-navbar-mode nav.page__nav, html.desktop-reskin.desktop-app-navbar-mode #navigation.page__nav, html.desktop-reskin.desktop-app-navbar-mode .page__nav, html.desktop-reskin.desktop-app-navbar-mode .page__disp-download-progress { display: none !important; } `; export interface WikiServer { readonly rootDir: string; readonly url: string; close: () => Promise; } export interface StartWikiServerOptions { rootDir?: string; externalImageRoot?: string | null; } function getWikiRootCandidates(explicitRootDir?: string): string[] { const envDir = process.env.FIVEETOOLS_SRC_DIR; return [ explicitRootDir, envDir, path.resolve(app.getAppPath(), "..", "5etools-src"), path.resolve(app.getAppPath(), "5etools-src"), path.resolve(process.cwd(), "..", "5etools-src"), path.resolve(process.cwd(), "5etools-src"), path.resolve(process.resourcesPath, "5etools-src") ].filter((it): it is string => Boolean(it)); } function isWikiRootDir(candidate: string): boolean { const indexPath = path.join(candidate, "5etools.html"); const dataPath = path.join(candidate, "data"); return fs.existsSync(indexPath) && fs.existsSync(dataPath); } function resolveWikiRootDir(explicitRootDir?: string): string { const candidates = getWikiRootCandidates(explicitRootDir); for (const candidate of candidates) { if (isWikiRootDir(candidate)) { return candidate; } } const formatted = candidates.map((it) => `- ${it}`).join("\n"); throw new Error(`Unable to locate 5etools-src directory. Checked:\n${formatted}`); } export function findWikiRootDir(explicitRootDir?: string): string | null { try { return resolveWikiRootDir(explicitRootDir); } catch { return null; } } function getMimeType(filePath: string): string { const ext = path.extname(filePath).toLowerCase(); return MIME_TYPES[ext] ?? "application/octet-stream"; } function normalizeExternalBaseUrl(url: string): string { const trimmed = url.trim(); return `${trimmed.replace(/\/+$/, "")}/`; } function resolveExternalImageRoot(externalImageRoot?: string | null): string | null { if (externalImageRoot != null) { const trimmed = externalImageRoot.trim(); return trimmed ? normalizeExternalBaseUrl(trimmed) : null; } if (!SHOULD_USE_EXTERNAL_IMAGE_FALLBACK) { return null; } const envValue = process.env.FIVEETOOLS_DESKTOP_IMG_BASE_URL || DEFAULT_EXTERNAL_IMAGE_ROOT; return normalizeExternalBaseUrl(envValue); } function getRequestPath(requestUrl: string): string { const parsed = new URL(requestUrl, "http://127.0.0.1"); return decodeURIComponent(parsed.pathname); } function getExternalImageUrl(requestUrl: string, externalImageRoot: string | null): URL | null { if (!externalImageRoot) { return null; } let requestPath: string; try { requestPath = getRequestPath(requestUrl); } catch { return null; } if (!requestPath.startsWith("/img/")) { return null; } const relativePath = requestPath.slice("/img/".length); if (!relativePath) { return null; } return new URL(relativePath, externalImageRoot); } async function pTryServeExternalImage( requestUrl: string, res: http.ServerResponse, externalImageRoot: string | null ): Promise { const externalImageUrl = getExternalImageUrl(requestUrl, externalImageRoot); if (!externalImageUrl) { return false; } try { const response = await fetch(externalImageUrl.toString()); if (!response.ok) { return false; } const responseBuffer = Buffer.from(await response.arrayBuffer()); const contentType = response.headers.get("content-type") ?? getMimeType(externalImageUrl.pathname); res.writeHead(200, withCorsHeaders({ "Content-Type": contentType, "Cache-Control": "public, max-age=3600" })); res.end(responseBuffer); return true; } catch { return false; } } function injectDesktopReskin(html: string, bootstrapScript: string): string { if (html.includes("desktop-reskin-style")) { return html; } const injection = [ ``, `` ].join(""); if (!/<\/head>/i.test(html)) { return `${injection}${html}`; } return html.replace(/<\/head>/i, `${injection}`); } function resolveRequestPath(rootDir: string, requestUrl: string): string { const reqPath = getRequestPath(requestUrl); const normalizedReqPath = reqPath === "/" ? "/5etools.html" : reqPath; const candidate = path.resolve(rootDir, `.${normalizedReqPath}`); const rootResolved = path.resolve(rootDir); const relative = path.relative(rootResolved, candidate); if (relative.startsWith("..") || path.isAbsolute(relative)) { throw new Error("Blocked path traversal attempt"); } if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) { const indexPath = path.join(candidate, "index.html"); if (fs.existsSync(indexPath)) { return indexPath; } } return candidate; } export async function startWikiServer(options: StartWikiServerOptions = {}): Promise { const rootDir = resolveWikiRootDir(options.rootDir); const externalImageRoot = resolveExternalImageRoot(options.externalImageRoot); const reskinBootstrap = getDesktopReskinBootstrap(externalImageRoot); return await new Promise((resolve, reject) => { const server = http.createServer((req, res) => { if (req.method === "OPTIONS") { res.writeHead(204, withCorsHeaders({ "Content-Type": "text/plain; charset=utf-8" })); res.end(); return; } const url = req.url ?? "/"; let filePath: string; try { filePath = resolveRequestPath(rootDir, url); } catch { res.writeHead(403, withCorsHeaders({ "Content-Type": "text/plain; charset=utf-8" })); res.end("Forbidden"); return; } fs.readFile(filePath, (err, data) => { if (err) { if (err.code === "ENOENT") { void pTryServeExternalImage(url, res, externalImageRoot).then((isServed) => { if (isServed) { return; } res.writeHead(404, withCorsHeaders({ "Content-Type": "text/plain; charset=utf-8" })); res.end("Not Found"); }); return; } res.writeHead(500, withCorsHeaders({ "Content-Type": "text/plain; charset=utf-8" })); res.end("Internal Server Error"); return; } const extension = path.extname(filePath).toLowerCase(); const transformedBody = extension === ".html" && SHOULD_INJECT_RESKIN ? injectDesktopReskin(data.toString("utf8"), reskinBootstrap) : data; res.writeHead(200, withCorsHeaders({ "Content-Type": getMimeType(filePath), "Cache-Control": "no-store" })); if (typeof transformedBody === "string") { res.end(transformedBody, "utf8"); return; } res.end(transformedBody); }); }); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const address = server.address(); if (!address || typeof address === "string") { reject(new Error("Failed to resolve local wiki server address")); return; } const url = `http://127.0.0.1:${address.port}`; resolve({ rootDir, url, close: () => new Promise((closeResolve, closeReject) => { server.close((closeErr) => { if (closeErr) { closeReject(closeErr); return; } closeResolve(); }); }) }); }); }); }