Added loading 5et from zip release, or cloning git repo. Improvements to CSS and some missing functionality
CI / Verify (build + tests) (push) Failing after 12s
CI / Package (Linux) (push) Has been skipped
CI / Package (Windows) (push) Has been skipped
CI / Package (macOS arm64 source) (push) Has been skipped

This commit is contained in:
boris committed 2026-02-24 23:11:31 +00:00
1 parent e241442302
commit 8be4c8d571
12 files changed
+1383 -104

No files matched your search

+389 -35
View File
@@ -7,10 +7,7 @@ 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";
const DEFAULT_EXTERNAL_IMAGE_ROOT = "https://5e.tools/img/";
const EXTERNAL_IMAGE_ROOT = SHOULD_USE_EXTERNAL_IMAGE_FALLBACK
? normalizeExternalBaseUrl(process.env.FIVEETOOLS_DESKTOP_IMG_BASE_URL || DEFAULT_EXTERNAL_IMAGE_ROOT)
: null;
export const DEFAULT_EXTERNAL_IMAGE_ROOT = "https://5e.tools/img/";
const MIME_TYPES: Record<string, string> = {
".avif": "image/avif",
@@ -42,9 +39,10 @@ const MIME_TYPES: Record<string, string> = {
".xml": "application/xml; charset=utf-8"
};
const DESKTOP_RESKIN_BOOTSTRAP = `
function getDesktopReskinBootstrap(externalImageRoot: string | null): string {
return `
(() => {
${EXTERNAL_IMAGE_ROOT ? `globalThis.DEPLOYED_IMG_ROOT = ${JSON.stringify(EXTERNAL_IMAGE_ROOT)};` : ""}
${externalImageRoot ? `globalThis.DEPLOYED_IMG_ROOT = ${JSON.stringify(externalImageRoot)};` : ""}
const root = document.documentElement;
root.classList.add("desktop-reskin");
@@ -71,6 +69,40 @@ const DESKTOP_RESKIN_BOOTSTRAP = `
console.info("[5ET-D]", content);
};
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.");
};
window.addEventListener("message", (event) => {
const data = event?.data;
if (!data || typeof data !== "object") {
return;
}
if (data.type !== "5etd:reset-onboarding-result" || data.ok !== false) {
return;
}
const reason = typeof data.error === "string" && data.error ? data.error : "unknown error";
doToastInfo(\`Reset onboarding failed: \${reason}\`);
});
const closeDropdown = (li, toggle) => {
if (typeof NavBar !== "undefined" && typeof NavBar._closeDropdownElement === "function") {
NavBar._closeDropdownElement(li);
@@ -172,17 +204,150 @@ const DESKTOP_RESKIN_BOOTSTRAP = `
navbar.append(li);
};
window.addEventListener("DOMContentLoaded", addCharactersMenu);
window.addEventListener("load", addCharactersMenu);
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();
scheduleAutoFocusSearch();
};
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%),
@@ -190,12 +355,17 @@ html.desktop-reskin body {
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: 10px var(--desktop-shell-gutter) 0 !important;
margin: 8px var(--desktop-shell-gutter) 0 !important;
padding: 0 !important;
border: 1px solid rgba(109, 164, 223, 0.32) !important;
border-bottom: 0 !important;
border-radius: 18px 18px 0 0;
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);
@@ -226,17 +396,19 @@ html.desktop-reskin .page__subtitle {
}
html.desktop-reskin #navigation.page__nav {
margin: 0 var(--desktop-shell-gutter) 12px !important;
margin: 0 !important;
padding: 0 !important;
max-width: none !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-top: 0 !important;
border-radius: 0 0 18px 18px;
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);
position: relative;
z-index: 3000 !important;
z-index: 3600 !important;
}
html.desktop-reskin #navigation.page__nav .page__nav-inner {
@@ -339,6 +511,68 @@ html.desktop-reskin #navbar > li.dropdown--navbar > a.ve-dropdown-toggle > .care
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;
@@ -372,6 +606,7 @@ html.desktop-reskin #navbar > .omni__wrp-input:not(.omni__wrp-input--scrolled) .
@media (max-width: 1180px) {
html.desktop-reskin {
--desktop-topbar-pad-x: 12px;
--desktop-nav-reserved-height: 108px;
}
html.desktop-reskin #navbar {
@@ -385,11 +620,93 @@ html.desktop-reskin #navbar > .omni__wrp-input:not(.omni__wrp-input--scrolled) .
}
}
@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;
}
@@ -692,10 +1009,16 @@ export interface WikiServer {
close: () => Promise<void>;
}
function getWikiRootCandidates(): string[] {
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"),
@@ -705,14 +1028,17 @@ function getWikiRootCandidates(): string[] {
].filter((it): it is string => Boolean(it));
}
function resolveWikiRootDir(): string {
const candidates = getWikiRootCandidates();
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) {
const indexPath = path.join(candidate, "5etools.html");
const dataPath = path.join(candidate, "data");
if (fs.existsSync(indexPath) && fs.existsSync(dataPath)) {
if (isWikiRootDir(candidate)) {
return candidate;
}
}
@@ -721,6 +1047,14 @@ function resolveWikiRootDir(): string {
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";
@@ -731,13 +1065,27 @@ function normalizeExternalBaseUrl(url: string): string {
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): URL | null {
if (!EXTERNAL_IMAGE_ROOT) {
function getExternalImageUrl(requestUrl: string, externalImageRoot: string | null): URL | null {
if (!externalImageRoot) {
return null;
}
@@ -757,11 +1105,15 @@ function getExternalImageUrl(requestUrl: string): URL | null {
return null;
}
return new URL(relativePath, EXTERNAL_IMAGE_ROOT);
return new URL(relativePath, externalImageRoot);
}
async function pTryServeExternalImage(requestUrl: string, res: http.ServerResponse): Promise<boolean> {
const externalImageUrl = getExternalImageUrl(requestUrl);
async function pTryServeExternalImage(
requestUrl: string,
res: http.ServerResponse,
externalImageRoot: string | null
): Promise<boolean> {
const externalImageUrl = getExternalImageUrl(requestUrl, externalImageRoot);
if (!externalImageUrl) {
return false;
}
@@ -786,13 +1138,13 @@ async function pTryServeExternalImage(requestUrl: string, res: http.ServerRespon
}
}
function injectDesktopReskin(html: string): string {
function injectDesktopReskin(html: string, bootstrapScript: string): string {
if (html.includes("desktop-reskin-style")) {
return html;
}
const injection = [
`<script id="desktop-reskin-boot">${DESKTOP_RESKIN_BOOTSTRAP}</script>`,
`<script id="desktop-reskin-boot">${bootstrapScript}</script>`,
`<style id="desktop-reskin-style">${DESKTOP_RESKIN_STYLE}</style>`
].join("");
@@ -825,8 +1177,10 @@ function resolveRequestPath(rootDir: string, requestUrl: string): string {
return candidate;
}
export async function startWikiServer(): Promise<WikiServer> {
const rootDir = resolveWikiRootDir();
export async function startWikiServer(options: StartWikiServerOptions = {}): Promise<WikiServer> {
const rootDir = resolveWikiRootDir(options.rootDir);
const externalImageRoot = resolveExternalImageRoot(options.externalImageRoot);
const reskinBootstrap = getDesktopReskinBootstrap(externalImageRoot);
return await new Promise<WikiServer>((resolve, reject) => {
const server = http.createServer((req, res) => {
@@ -844,7 +1198,7 @@ export async function startWikiServer(): Promise<WikiServer> {
fs.readFile(filePath, (err, data) => {
if (err) {
if (err.code === "ENOENT") {
void pTryServeExternalImage(url, res).then((isServed) => {
void pTryServeExternalImage(url, res, externalImageRoot).then((isServed) => {
if (isServed) {
return;
}
@@ -862,7 +1216,7 @@ export async function startWikiServer(): Promise<WikiServer> {
const extension = path.extname(filePath).toLowerCase();
const transformedBody =
extension === ".html" && SHOULD_INJECT_RESKIN ? injectDesktopReskin(data.toString("utf8")) : data;
extension === ".html" && SHOULD_INJECT_RESKIN ? injectDesktopReskin(data.toString("utf8"), reskinBootstrap) : data;
res.writeHead(200, {
"Content-Type": getMimeType(filePath),