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

+9 -11
View File
@@ -17,11 +17,10 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node.js - name: Verify Node.js toolchain
uses: actions/setup-node@v4 run: |
with: node --version
node-version: 20 npm --version
cache: npm
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -56,11 +55,10 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node.js - name: Verify Node.js toolchain
uses: actions/setup-node@v4 run: |
with: node --version
node-version: 20 npm --version
cache: npm
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -71,7 +69,7 @@ jobs:
- name: Build installers - name: Build installers
env: env:
CSC_IDENTITY_AUTO_DISCOVERY: "false" CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: npx electron-builder --${{ matrix.platform }} --${{ matrix.arch }} --publish never run: npx electron-builder@26.8.1 --${{ matrix.platform }} --${{ matrix.arch }} --publish never
- name: Upload installer artifact - name: Upload installer artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
+9 -2
View File
@@ -8,8 +8,15 @@ Current stage:
## Prerequisites ## Prerequisites
- Node.js 20+ - Node.js 20+
- `5etools-src` directory in this repository root: - Git (only required if setup mode uses source/image repository URLs)
- `./5etools-src`
On first launch, if no local source is found, the app opens a setup window.
Setup supports either:
- Source repository URL clone
- Local source ZIP import
Images can be cloned from an image repository URL, and/or loaded from an external image base URL fallback.
Optional override: Optional override:
+2 -1
View File
@@ -8,7 +8,6 @@
"name": "5et-d-desktop", "name": "5et-d-desktop",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"electron": "40.6.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4"
}, },
@@ -18,6 +17,7 @@
"@types/react-dom": "latest", "@types/react-dom": "latest",
"@vitejs/plugin-react": "5.1.4", "@vitejs/plugin-react": "5.1.4",
"concurrently": "9.2.1", "concurrently": "9.2.1",
"electron": "40.6.0",
"rimraf": "6.1.3", "rimraf": "6.1.3",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "7.3.1", "vite": "7.3.1",
@@ -1857,6 +1857,7 @@
"version": "40.6.0", "version": "40.6.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-40.6.0.tgz", "resolved": "https://registry.npmjs.org/electron/-/electron-40.6.0.tgz",
"integrity": "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA==", "integrity": "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+5 -13
View File
@@ -3,6 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"description": "5ET-D desktop shell for local 5etools wiki", "description": "5ET-D desktop shell for local 5etools wiki",
"author": "notBoris",
"main": "dist-electron/main/main.js", "main": "dist-electron/main/main.js",
"scripts": { "scripts": {
"clean": "rimraf dist dist-electron release", "clean": "rimraf dist dist-electron release",
@@ -17,12 +18,11 @@
"dev": "concurrently -k \"npm:dev:main\" \"npm:dev:renderer\" \"npm:dev:electron\"", "dev": "concurrently -k \"npm:dev:main\" \"npm:dev:renderer\" \"npm:dev:electron\"",
"dev:web": "concurrently -k \"npm:dev:main\" \"npm:dev:renderer:web\" \"npm:dev:electron:web\"", "dev:web": "concurrently -k \"npm:dev:main\" \"npm:dev:renderer:web\" \"npm:dev:electron:web\"",
"start": "npm run build && node scripts/run-electron.cjs .", "start": "npm run build && node scripts/run-electron.cjs .",
"pack": "npm run build && npx electron-builder --dir", "pack": "npm run build && npx electron-builder@26.8.1 --dir",
"dist": "npm run build && npx electron-builder", "dist": "npm run build && npx electron-builder@26.8.1",
"dist:mac:arm64": "npm run build && CSC_IDENTITY_AUTO_DISCOVERY=false npx electron-builder --mac --arm64 --publish never" "dist:mac:arm64": "npm run build && CSC_IDENTITY_AUTO_DISCOVERY=false npx electron-builder@26.8.1 --mac --arm64 --publish never"
}, },
"dependencies": { "dependencies": {
"electron": "40.6.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4"
}, },
@@ -32,6 +32,7 @@
"@types/react-dom": "latest", "@types/react-dom": "latest",
"@vitejs/plugin-react": "5.1.4", "@vitejs/plugin-react": "5.1.4",
"concurrently": "9.2.1", "concurrently": "9.2.1",
"electron": "40.6.0",
"rimraf": "6.1.3", "rimraf": "6.1.3",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "7.3.1", "vite": "7.3.1",
@@ -48,15 +49,6 @@
"dist-electron/**/*", "dist-electron/**/*",
"package.json" "package.json"
], ],
"extraResources": [
{
"from": "5etools-src",
"to": "5etools-src",
"filter": [
"**/*"
]
}
],
"mac": { "mac": {
"target": "dmg" "target": "dmg"
}, },
+50
View File
@@ -0,0 +1,50 @@
import fs from "node:fs";
import path from "node:path";
import { app } from "electron";
export interface DesktopAppConfig {
sourceDir?: string;
sourceRepoUrl?: string;
imageRepoUrl?: string;
imageBaseUrl?: string;
downloadImages?: boolean;
}
const CONFIG_FILE_NAME = "desktop-config.json";
function getConfigPath(): string {
return path.join(app.getPath("userData"), CONFIG_FILE_NAME);
}
export function getDesktopConfigPath(): string {
return getConfigPath();
}
export function loadDesktopAppConfig(): DesktopAppConfig {
const configPath = getConfigPath();
if (!fs.existsSync(configPath)) {
return {};
}
try {
const raw = fs.readFileSync(configPath, "utf8");
const parsed = JSON.parse(raw) as DesktopAppConfig | null;
return parsed ?? {};
} catch {
return {};
}
}
export function saveDesktopAppConfig(config: DesktopAppConfig): void {
const configPath = getConfigPath();
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
}
export function clearDesktopAppConfig(): void {
const configPath = getConfigPath();
if (fs.existsSync(configPath)) {
fs.rmSync(configPath, { force: true });
}
}
+528 -34
View File
@@ -1,49 +1,482 @@
import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { app, BrowserWindow, ipcMain } from "electron"; import { spawn } from "node:child_process";
import { startWikiServer, type WikiServer } from "./wikiServer"; import { app, BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron";
import {
clearDesktopAppConfig,
loadDesktopAppConfig,
saveDesktopAppConfig,
type DesktopAppConfig
} from "./appConfig";
import { DEFAULT_EXTERNAL_IMAGE_ROOT, findWikiRootDir, startWikiServer, type WikiServer } from "./wikiServer";
const DEV_RENDERER_URL = process.env.VITE_DEV_SERVER_URL;
const DEFAULT_SOURCE_REPO_URL = "https://github.com/5etools-mirror-3/5etools-src.git";
const DEFAULT_IMAGE_REPO_URL = "https://github.com/5etools-mirror-3/5etools-img.git";
const USERDATA_SOURCE_DIRNAME = "5etools-src";
type SetupSourceMode = "url" | "zip";
interface SetupState {
needsSetup: boolean;
sourceDir: string | null;
sourceRepoUrl: string;
imageRepoUrl: string;
imageBaseUrl: string;
}
interface SetupRunInput {
mode?: SetupSourceMode;
sourceRepoUrl?: string;
sourceZipPath?: string;
imageRepoUrl?: string;
imageBaseUrl?: string;
downloadImages?: boolean;
}
interface SetupRunResult {
ok: boolean;
error?: string;
}
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let wikiServer: WikiServer | null = null; let wikiServer: WikiServer | null = null;
let wikiServerRootDir: string | null = null;
const DEV_RENDERER_URL = process.env.VITE_DEV_SERVER_URL; let wikiServerExternalImageRoot: string | null = null;
let isSetupInProgress = false;
function isDevMode(): boolean { function isDevMode(): boolean {
return !app.isPackaged; return !app.isPackaged;
} }
async function createMainWindow(): Promise<void> { function getDefaultSourceDir(): string {
if (!wikiServer) { return path.join(app.getPath("userData"), USERDATA_SOURCE_DIRNAME);
throw new Error("Wiki server was not initialized before creating the window"); }
function isWithinDirectory(baseDir: string, candidatePath: string): boolean {
const resolvedBase = path.resolve(baseDir);
const resolvedCandidate = path.resolve(candidatePath);
const relative = path.relative(resolvedBase, resolvedCandidate);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function getConfigValues(config: DesktopAppConfig): {
sourceRepoUrl: string;
imageRepoUrl: string;
imageBaseUrl: string;
sourceDir: string | null;
} {
const sourceRepoUrl = (config.sourceRepoUrl || DEFAULT_SOURCE_REPO_URL).trim();
const imageRepoUrl = (config.imageRepoUrl || DEFAULT_IMAGE_REPO_URL).trim();
const imageBaseUrl = (config.imageBaseUrl || DEFAULT_EXTERNAL_IMAGE_ROOT).trim();
const sourceDir = config.sourceDir?.trim() || null;
return {
sourceRepoUrl,
imageRepoUrl,
imageBaseUrl,
sourceDir
};
}
function getSetupState(): SetupState {
const config = loadDesktopAppConfig();
const { sourceRepoUrl, imageRepoUrl, imageBaseUrl, sourceDir } = getConfigValues(config);
const resolvedSourceDir = sourceDir ? findWikiRootDir(sourceDir) : findWikiRootDir();
return {
needsSetup: !Boolean(resolvedSourceDir),
sourceDir: resolvedSourceDir,
sourceRepoUrl,
imageRepoUrl,
imageBaseUrl
};
}
function normalizeRequiredHttpUrl(raw: string, label: string): string {
const value = raw.trim();
if (!value) {
throw new Error(`${label} is required.`);
} }
mainWindow = new BrowserWindow({ let parsed: URL;
width: 1440, try {
height: 900, parsed = new URL(value);
minWidth: 1024, } catch {
minHeight: 700, throw new Error(`${label} must be a valid URL.`);
title: "5etools Desktop", }
webPreferences: {
contextIsolation: true, if (!["http:", "https:"].includes(parsed.protocol)) {
nodeIntegration: false, throw new Error(`${label} must use HTTP or HTTPS.`);
sandbox: false, }
preload: path.join(__dirname, "../preload/preload.js")
return parsed.toString();
}
function normalizeRequiredZipPath(raw: string): string {
const value = raw.trim();
if (!value) {
throw new Error("Source ZIP path is required.");
}
const absolutePath = path.resolve(value);
if (!fs.existsSync(absolutePath)) {
throw new Error(`Source ZIP path does not exist: ${absolutePath}`);
}
const stat = fs.statSync(absolutePath);
if (!stat.isFile()) {
throw new Error(`Source ZIP path is not a file: ${absolutePath}`);
}
if (path.extname(absolutePath).toLowerCase() !== ".zip") {
throw new Error("Source file must be a .zip archive.");
}
return absolutePath;
}
function isWikiRootDirectory(dir: string): boolean {
return fs.existsSync(path.join(dir, "5etools.html")) && fs.existsSync(path.join(dir, "data"));
}
function findWikiRootInDirectoryTree(rootDir: string, maxDepth = 4): string | null {
if (!fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) {
return null;
}
const queue: Array<{ dir: string; depth: number }> = [{ dir: rootDir, depth: 0 }];
const seen = new Set<string>();
while (queue.length) {
const current = queue.shift();
if (!current) {
break;
} }
});
if (isDevMode() && DEV_RENDERER_URL) { const resolved = path.resolve(current.dir);
await mainWindow.loadURL(DEV_RENDERER_URL); if (seen.has(resolved)) {
} else { continue;
await mainWindow.loadFile(path.join(__dirname, "../../dist/index.html")); }
seen.add(resolved);
if (isWikiRootDirectory(resolved)) {
return resolved;
}
if (current.depth >= maxDepth) {
continue;
}
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(resolved, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
queue.push({ dir: path.join(resolved, entry.name), depth: current.depth + 1 });
}
} }
mainWindow.on("closed", () => { return null;
mainWindow = null; }
function ensureSourceTargetReady(targetDir: string): void {
if (!fs.existsSync(targetDir)) {
return;
}
if (isWikiRootDirectory(targetDir)) {
return;
}
const entries = fs.readdirSync(targetDir);
if (entries.length === 0) {
fs.rmdirSync(targetDir);
return;
}
throw new Error(
`Target directory already exists and is not a valid 5etools source directory: ${targetDir}. ` +
"Please remove or rename it and run setup again."
);
}
async function runProcess(command: string, args: string[], cwd?: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
if (stderr.length > 12000) {
stderr = stderr.slice(-12000);
}
});
child.once("error", (err) => {
reject(new Error(`Failed to start "${command}". Ensure it is installed and available in PATH. ${err.message}`));
});
child.once("close", (code) => {
if (code === 0) {
resolve();
return;
}
const trimmed = stderr.trim();
reject(
new Error(
trimmed
? `${command} ${args.join(" ")} failed: ${trimmed}`
: `${command} ${args.join(" ")} exited with code ${code}.`
)
);
});
}); });
} }
async function bootstrap(): Promise<void> { async function runGit(args: string[], cwd?: string): Promise<void> {
wikiServer = await startWikiServer(); await runProcess("git", args, cwd);
}
function getPowerShellLiteralPath(filePath: string): string {
return filePath.replace(/'/g, "''");
}
async function extractSourceZip(zipPath: string, targetDir: string): Promise<string> {
const tempExtractDir = fs.mkdtempSync(path.join(app.getPath("temp"), "5etd-src-zip-"));
try {
if (process.platform === "win32") {
const zipLiteral = getPowerShellLiteralPath(zipPath);
const destLiteral = getPowerShellLiteralPath(tempExtractDir);
await runProcess("powershell", [
"-NoProfile",
"-NonInteractive",
"-Command",
`Expand-Archive -LiteralPath '${zipLiteral}' -DestinationPath '${destLiteral}' -Force`
]);
} else {
await runProcess("unzip", ["-q", "-o", zipPath, "-d", tempExtractDir]);
}
const extractedRoot = findWikiRootInDirectoryTree(tempExtractDir);
if (!extractedRoot) {
throw new Error("Source ZIP archive does not contain a valid 5etools source directory.");
}
fs.mkdirSync(targetDir, { recursive: true });
fs.cpSync(extractedRoot, targetDir, { recursive: true, force: true });
} finally {
fs.rmSync(tempExtractDir, { recursive: true, force: true });
}
const resolved = findWikiRootInDirectoryTree(targetDir);
if (!resolved) {
throw new Error("Failed to validate extracted source directory.");
}
return resolved;
}
async function prepareSourceFromSetup(input: SetupRunInput): Promise<{ rootDir: string; externalImageRoot: string }> {
const mode: SetupSourceMode = input.mode === "zip" ? "zip" : "url";
const sourceRepoUrlForConfig = String(input.sourceRepoUrl || DEFAULT_SOURCE_REPO_URL).trim() || DEFAULT_SOURCE_REPO_URL;
const imageRepoUrl = normalizeRequiredHttpUrl(String(input.imageRepoUrl ?? ""), "Image repository URL");
const imageBaseUrl = normalizeRequiredHttpUrl(String(input.imageBaseUrl ?? ""), "Image base URL");
const downloadImages = Boolean(input.downloadImages);
const sourceDir = getDefaultSourceDir();
ensureSourceTargetReady(sourceDir);
let rootDir = findWikiRootInDirectoryTree(sourceDir);
if (!rootDir) {
if (mode === "zip") {
const sourceZipPath = normalizeRequiredZipPath(String(input.sourceZipPath ?? ""));
rootDir = await extractSourceZip(sourceZipPath, sourceDir);
} else {
const sourceRepoUrl = normalizeRequiredHttpUrl(String(input.sourceRepoUrl ?? ""), "Source repository URL");
await runGit(["clone", "--depth=1", sourceRepoUrl, sourceDir]);
rootDir = findWikiRootInDirectoryTree(sourceDir);
if (!rootDir) {
throw new Error("Downloaded source does not look like a valid 5etools-src directory.");
}
}
}
if (downloadImages) {
const imageDir = path.join(rootDir, "img");
if (!fs.existsSync(imageDir) || fs.readdirSync(imageDir).length === 0) {
if (fs.existsSync(imageDir) && fs.readdirSync(imageDir).length === 0) {
fs.rmdirSync(imageDir);
}
await runGit(["clone", "--depth=1", imageRepoUrl, imageDir]);
}
}
saveDesktopAppConfig({
sourceDir: rootDir,
sourceRepoUrl: sourceRepoUrlForConfig,
imageRepoUrl,
imageBaseUrl,
downloadImages
});
return { rootDir, externalImageRoot: imageBaseUrl };
}
function getLoadUrl(setupMode: boolean): string {
if (!DEV_RENDERER_URL) {
throw new Error("Missing VITE_DEV_SERVER_URL for dev renderer");
}
const url = new URL(DEV_RENDERER_URL);
if (setupMode) {
url.searchParams.set("setup", "1");
} else {
url.searchParams.delete("setup");
}
return url.toString();
}
function configureWindowForMode(window: BrowserWindow, setupMode: boolean): void {
if (setupMode) {
window.setTitle("5ET-D Setup");
window.setMinimumSize(760, 520);
const current = window.getBounds();
if (current.width < 900 || current.height < 620) {
window.setSize(900, 620);
}
return;
}
window.setTitle("5etools Desktop");
window.setMinimumSize(1024, 700);
const current = window.getBounds();
if (current.width < 1024 || current.height < 700) {
window.setSize(1440, 900);
}
}
async function loadWindowForMode(window: BrowserWindow, setupMode: boolean): Promise<void> {
configureWindowForMode(window, setupMode);
if (isDevMode() && DEV_RENDERER_URL) {
await window.loadURL(getLoadUrl(setupMode));
return;
}
await window.loadFile(path.join(__dirname, "../../dist/index.html"), setupMode ? { query: { setup: "1" } } : undefined);
}
async function openAppWindow(setupMode: boolean): Promise<void> {
if (!mainWindow) {
mainWindow = new BrowserWindow({
width: setupMode ? 900 : 1440,
height: setupMode ? 620 : 900,
minWidth: setupMode ? 760 : 1024,
minHeight: setupMode ? 520 : 700,
title: setupMode ? "5ET-D Setup" : "5etools Desktop",
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
preload: path.join(__dirname, "../preload/preload.js")
}
});
mainWindow.on("closed", () => {
mainWindow = null;
});
}
await loadWindowForMode(mainWindow, setupMode);
}
async function ensureWikiServer(rootDir: string, externalImageRoot?: string | null): Promise<void> {
const effectiveImageRoot = externalImageRoot ?? null;
const isAlreadyRunning =
wikiServer && wikiServerRootDir === rootDir && wikiServerExternalImageRoot === effectiveImageRoot;
if (isAlreadyRunning) {
return;
}
if (wikiServer) {
await wikiServer.close();
wikiServer = null;
}
wikiServer = await startWikiServer({ rootDir, externalImageRoot: effectiveImageRoot });
wikiServerRootDir = rootDir;
wikiServerExternalImageRoot = effectiveImageRoot;
}
async function stopWikiServer(): Promise<void> {
if (!wikiServer) {
return;
}
await wikiServer.close();
wikiServer = null;
wikiServerRootDir = null;
wikiServerExternalImageRoot = null;
}
function clearOnboardingData(): void {
const config = loadDesktopAppConfig();
const userDataDir = app.getPath("userData");
const targets = new Set<string>();
targets.add(getDefaultSourceDir());
const configuredSourceDir = config.sourceDir?.trim();
if (configuredSourceDir && isWithinDirectory(userDataDir, configuredSourceDir)) {
targets.add(path.resolve(configuredSourceDir));
}
for (const target of targets) {
if (fs.existsSync(target)) {
fs.rmSync(target, { recursive: true, force: true });
}
}
clearDesktopAppConfig();
}
function resolveConfiguredSource(config: DesktopAppConfig): string | null {
const configuredSource = config.sourceDir?.trim();
if (configuredSource) {
const fromConfig = findWikiRootDir(configuredSource);
if (fromConfig) {
return fromConfig;
}
}
return findWikiRootDir();
}
async function pTryStartWikiFromLocalConfig(): Promise<boolean> {
const config = loadDesktopAppConfig();
const rootDir = resolveConfiguredSource(config);
if (!rootDir) {
return false;
}
await ensureWikiServer(rootDir, config.imageBaseUrl);
return true;
}
function registerIpcHandlers(): void {
ipcMain.handle("wiki:get-url", async () => { ipcMain.handle("wiki:get-url", async () => {
if (!wikiServer) { if (!wikiServer) {
throw new Error("Wiki server is not available"); throw new Error("Wiki server is not available");
@@ -52,12 +485,76 @@ async function bootstrap(): Promise<void> {
return wikiServer.url; return wikiServer.url;
}); });
await createMainWindow(); ipcMain.handle("setup:get-state", async (): Promise<SetupState> => getSetupState());
ipcMain.handle("setup:pick-source-zip", async (event): Promise<string | null> => {
const owner = BrowserWindow.fromWebContents(event.sender) || mainWindow;
const dialogOptions: OpenDialogOptions = {
title: "Select 5etools Source ZIP",
properties: ["openFile"],
filters: [{ name: "ZIP archives", extensions: ["zip"] }]
};
const result = owner ? await dialog.showOpenDialog(owner, dialogOptions) : await dialog.showOpenDialog(dialogOptions);
if (result.canceled || result.filePaths.length === 0) {
return null;
}
return result.filePaths[0] || null;
});
ipcMain.handle("setup:run", async (_event, input: SetupRunInput): Promise<SetupRunResult> => {
if (isSetupInProgress) {
return { ok: false, error: "Setup is already running. Please wait for it to finish." };
}
isSetupInProgress = true;
try {
const { rootDir, externalImageRoot } = await prepareSourceFromSetup(input);
await ensureWikiServer(rootDir, externalImageRoot);
await openAppWindow(false);
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown setup failure";
return { ok: false, error: message };
} finally {
isSetupInProgress = false;
}
});
ipcMain.handle("setup:reset-onboarding", async (): Promise<SetupRunResult> => {
if (isSetupInProgress) {
return { ok: false, error: "Another setup operation is already running." };
}
isSetupInProgress = true;
try {
await stopWikiServer();
clearOnboardingData();
await openAppWindow(true);
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown reset failure";
return { ok: false, error: message };
} finally {
isSetupInProgress = false;
}
});
}
async function bootstrap(): Promise<void> {
registerIpcHandlers();
const hasLocalSource = await pTryStartWikiFromLocalConfig();
await openAppWindow(!hasLocalSource);
app.on("activate", async () => { app.on("activate", async () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length > 0) {
await createMainWindow(); return;
} }
const hasSource = await pTryStartWikiFromLocalConfig();
await openAppWindow(!hasSource);
}); });
} }
@@ -75,8 +572,5 @@ app.on("window-all-closed", () => {
}); });
app.on("before-quit", async () => { app.on("before-quit", async () => {
if (wikiServer) { await stopWikiServer();
await wikiServer.close();
wikiServer = null;
}
}); });
+389 -35
View File
@@ -7,10 +7,7 @@ import { app } from "electron";
const SHOULD_INJECT_RESKIN = process.env.FIVEETOOLS_DESKTOP_RESKIN !== "0"; const SHOULD_INJECT_RESKIN = process.env.FIVEETOOLS_DESKTOP_RESKIN !== "0";
const SHOULD_FORCE_NIGHT_THEME = process.env.FIVEETOOLS_DESKTOP_FORCE_NIGHT !== "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 SHOULD_USE_EXTERNAL_IMAGE_FALLBACK = process.env.FIVEETOOLS_DESKTOP_EXTERNAL_IMAGES !== "0";
const DEFAULT_EXTERNAL_IMAGE_ROOT = "https://5e.tools/img/"; export 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;
const MIME_TYPES: Record<string, string> = { const MIME_TYPES: Record<string, string> = {
".avif": "image/avif", ".avif": "image/avif",
@@ -42,9 +39,10 @@ const MIME_TYPES: Record<string, string> = {
".xml": "application/xml; charset=utf-8" ".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; const root = document.documentElement;
root.classList.add("desktop-reskin"); root.classList.add("desktop-reskin");
@@ -71,6 +69,40 @@ const DESKTOP_RESKIN_BOOTSTRAP = `
console.info("[5ET-D]", content); 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) => { const closeDropdown = (li, toggle) => {
if (typeof NavBar !== "undefined" && typeof NavBar._closeDropdownElement === "function") { if (typeof NavBar !== "undefined" && typeof NavBar._closeDropdownElement === "function") {
NavBar._closeDropdownElement(li); NavBar._closeDropdownElement(li);
@@ -172,17 +204,150 @@ const DESKTOP_RESKIN_BOOTSTRAP = `
navbar.append(li); navbar.append(li);
}; };
window.addEventListener("DOMContentLoaded", addCharactersMenu); const addResetOnboardingMenu = () => {
window.addEventListener("load", addCharactersMenu); 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 = ` const DESKTOP_RESKIN_STYLE = `
html.desktop-reskin { html.desktop-reskin {
--desktop-shell-gutter: 14px; --desktop-shell-gutter: 14px;
--desktop-topbar-pad-x: 18px; --desktop-topbar-pad-x: 18px;
--desktop-nav-top-offset: 10px;
--desktop-nav-reserved-height: 92px;
} }
html.desktop-reskin body { html.desktop-reskin body {
padding-top: calc(var(--safe-area-inset-top) + var(--desktop-nav-reserved-height)) !important;
background: background:
radial-gradient(circle at 13% -2%, rgba(77, 140, 222, 0.3), transparent 40%), 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%), radial-gradient(circle at 86% -4%, rgba(241, 176, 92, 0.24), transparent 38%),
@@ -190,12 +355,17 @@ html.desktop-reskin body {
color: #dce7f6; 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 { html.desktop-reskin .page__header {
margin: 10px var(--desktop-shell-gutter) 0 !important; margin: 8px var(--desktop-shell-gutter) 0 !important;
padding: 0 !important; padding: 0 !important;
border: 1px solid rgba(109, 164, 223, 0.32) !important; border: 1px solid rgba(109, 164, 223, 0.32) !important;
border-bottom: 0 !important; border-radius: 18px;
border-radius: 18px 18px 0 0;
background: linear-gradient(115deg, rgba(17, 29, 47, 0.98), rgba(24, 38, 58, 0.96) 64%, rgba(87, 58, 32, 0.88)); 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); backdrop-filter: blur(14px);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24); 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 { html.desktop-reskin #navigation.page__nav {
margin: 0 var(--desktop-shell-gutter) 12px !important; margin: 0 !important;
padding: 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; width: auto !important;
max-width: none !important;
border: 1px solid rgba(109, 164, 223, 0.32) !important; border: 1px solid rgba(109, 164, 223, 0.32) !important;
border-top: 0 !important; border-radius: 18px;
border-radius: 0 0 18px 18px;
background: linear-gradient(180deg, rgba(12, 21, 35, 0.96), rgba(8, 15, 27, 0.98)); 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); box-shadow: 0 12px 26px rgba(0, 0, 0, 0.28);
position: relative; z-index: 3600 !important;
z-index: 3000 !important;
} }
html.desktop-reskin #navigation.page__nav .page__nav-inner { 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; 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) { html.desktop-reskin #navbar > .omni__wrp-input:not(.omni__wrp-input--scrolled) {
margin-left: auto !important; margin-left: auto !important;
flex: 1 1 320px; flex: 1 1 320px;
@@ -372,6 +606,7 @@ html.desktop-reskin #navbar > .omni__wrp-input:not(.omni__wrp-input--scrolled) .
@media (max-width: 1180px) { @media (max-width: 1180px) {
html.desktop-reskin { html.desktop-reskin {
--desktop-topbar-pad-x: 12px; --desktop-topbar-pad-x: 12px;
--desktop-nav-reserved-height: 108px;
} }
html.desktop-reskin #navbar { 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 .form-control,
html.desktop-reskin #navbar input[type="search"] { html.desktop-reskin #navbar input[type="search"] {
background: rgba(8, 14, 24, 0.96) !important; background: rgba(8, 14, 24, 0.96) !important;
border: 1px solid rgba(102, 155, 210, 0.42) !important; border: 1px solid rgba(102, 155, 210, 0.42) !important;
color: #e4effb !important; color: #e4effb !important;
height: 44px !important;
border-radius: 12px !important; border-radius: 12px !important;
} }
@@ -692,10 +1009,16 @@ export interface WikiServer {
close: () => Promise<void>; 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; const envDir = process.env.FIVEETOOLS_SRC_DIR;
return [ return [
explicitRootDir,
envDir, envDir,
path.resolve(app.getAppPath(), "..", "5etools-src"), path.resolve(app.getAppPath(), "..", "5etools-src"),
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)); ].filter((it): it is string => Boolean(it));
} }
function resolveWikiRootDir(): string { function isWikiRootDir(candidate: string): boolean {
const candidates = getWikiRootCandidates(); 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) { for (const candidate of candidates) {
const indexPath = path.join(candidate, "5etools.html"); if (isWikiRootDir(candidate)) {
const dataPath = path.join(candidate, "data");
if (fs.existsSync(indexPath) && fs.existsSync(dataPath)) {
return candidate; return candidate;
} }
} }
@@ -721,6 +1047,14 @@ function resolveWikiRootDir(): string {
throw new Error(`Unable to locate 5etools-src directory. Checked:\n${formatted}`); 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 { function getMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase(); const ext = path.extname(filePath).toLowerCase();
return MIME_TYPES[ext] ?? "application/octet-stream"; return MIME_TYPES[ext] ?? "application/octet-stream";
@@ -731,13 +1065,27 @@ function normalizeExternalBaseUrl(url: string): string {
return `${trimmed.replace(/\/+$/, "")}/`; 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 { function getRequestPath(requestUrl: string): string {
const parsed = new URL(requestUrl, "http://127.0.0.1"); const parsed = new URL(requestUrl, "http://127.0.0.1");
return decodeURIComponent(parsed.pathname); return decodeURIComponent(parsed.pathname);
} }
function getExternalImageUrl(requestUrl: string): URL | null { function getExternalImageUrl(requestUrl: string, externalImageRoot: string | null): URL | null {
if (!EXTERNAL_IMAGE_ROOT) { if (!externalImageRoot) {
return null; return null;
} }
@@ -757,11 +1105,15 @@ function getExternalImageUrl(requestUrl: string): URL | null {
return 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> { async function pTryServeExternalImage(
const externalImageUrl = getExternalImageUrl(requestUrl); requestUrl: string,
res: http.ServerResponse,
externalImageRoot: string | null
): Promise<boolean> {
const externalImageUrl = getExternalImageUrl(requestUrl, externalImageRoot);
if (!externalImageUrl) { if (!externalImageUrl) {
return false; 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")) { if (html.includes("desktop-reskin-style")) {
return html; return html;
} }
const injection = [ 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>` `<style id="desktop-reskin-style">${DESKTOP_RESKIN_STYLE}</style>`
].join(""); ].join("");
@@ -825,8 +1177,10 @@ function resolveRequestPath(rootDir: string, requestUrl: string): string {
return candidate; return candidate;
} }
export async function startWikiServer(): Promise<WikiServer> { export async function startWikiServer(options: StartWikiServerOptions = {}): Promise<WikiServer> {
const rootDir = resolveWikiRootDir(); const rootDir = resolveWikiRootDir(options.rootDir);
const externalImageRoot = resolveExternalImageRoot(options.externalImageRoot);
const reskinBootstrap = getDesktopReskinBootstrap(externalImageRoot);
return await new Promise<WikiServer>((resolve, reject) => { return await new Promise<WikiServer>((resolve, reject) => {
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
@@ -844,7 +1198,7 @@ export async function startWikiServer(): Promise<WikiServer> {
fs.readFile(filePath, (err, data) => { fs.readFile(filePath, (err, data) => {
if (err) { if (err) {
if (err.code === "ENOENT") { if (err.code === "ENOENT") {
void pTryServeExternalImage(url, res).then((isServed) => { void pTryServeExternalImage(url, res, externalImageRoot).then((isServed) => {
if (isServed) { if (isServed) {
return; return;
} }
@@ -862,7 +1216,7 @@ export async function startWikiServer(): Promise<WikiServer> {
const extension = path.extname(filePath).toLowerCase(); const extension = path.extname(filePath).toLowerCase();
const transformedBody = 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, { res.writeHead(200, {
"Content-Type": getMimeType(filePath), "Content-Type": getMimeType(filePath),
+27 -1
View File
@@ -1,5 +1,31 @@
import { contextBridge, ipcRenderer } from "electron"; import { contextBridge, ipcRenderer } from "electron";
interface SetupState {
needsSetup: boolean;
sourceDir: string | null;
sourceRepoUrl: string;
imageRepoUrl: string;
imageBaseUrl: string;
}
interface SetupRunInput {
mode: "url" | "zip";
sourceRepoUrl?: string;
sourceZipPath?: string;
imageRepoUrl?: string;
imageBaseUrl?: string;
downloadImages?: boolean;
}
interface SetupRunResult {
ok: boolean;
error?: string;
}
contextBridge.exposeInMainWorld("desktopApi", { contextBridge.exposeInMainWorld("desktopApi", {
getWikiUrl: () => ipcRenderer.invoke("wiki:get-url") as Promise<string> getWikiUrl: () => ipcRenderer.invoke("wiki:get-url") as Promise<string>,
getSetupState: () => ipcRenderer.invoke("setup:get-state") as Promise<SetupState>,
pickSourceZip: () => ipcRenderer.invoke("setup:pick-source-zip") as Promise<string | null>,
runSetup: (input: SetupRunInput) => ipcRenderer.invoke("setup:run", input) as Promise<SetupRunResult>,
resetOnboarding: () => ipcRenderer.invoke("setup:reset-onboarding") as Promise<SetupRunResult>
}); });
+235 -5
View File
@@ -1,12 +1,187 @@
import { useEffect, useMemo, useState } from "react"; import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { WIKI_NAV_SECTIONS } from "./wikiNav"; import { WIKI_NAV_SECTIONS } from "./wikiNav";
const DEFAULT_PAGE = "/5etools.html"; const DEFAULT_PAGE = "/5etools.html";
export default function App() { function SetupView() {
const [setupState, setSetupState] = useState<SetupState | null>(null);
const [sourceMode, setSourceMode] = useState<"url" | "zip">("url");
const [sourceRepoUrl, setSourceRepoUrl] = useState("");
const [sourceZipPath, setSourceZipPath] = useState("");
const [imageRepoUrl, setImageRepoUrl] = useState("");
const [imageBaseUrl, setImageBaseUrl] = useState("");
const [downloadImages, setDownloadImages] = useState(true);
const [error, setError] = useState("");
const [status, setStatus] = useState("Checking local 5etools source...");
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
window.desktopApi
.getSetupState()
.then((state) => {
setSetupState(state);
setSourceRepoUrl(state.sourceRepoUrl);
setImageRepoUrl(state.imageRepoUrl);
setImageBaseUrl(state.imageBaseUrl);
setStatus(state.needsSetup ? "Local 5etools source not found. Configure first-run setup below." : "Setup already complete.");
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : "Unknown setup-state error";
setError(message);
setStatus("Failed to load setup state.");
});
}, []);
const onSubmit = async (event: FormEvent) => {
event.preventDefault();
setError("");
setIsSubmitting(true);
setStatus(
sourceMode === "zip"
? "Extracting local source ZIP and preparing files. This can take a few minutes..."
: "Downloading and preparing local 5etools source. This can take a few minutes..."
);
try {
const result = await window.desktopApi.runSetup({
mode: sourceMode,
sourceRepoUrl,
sourceZipPath,
imageRepoUrl,
imageBaseUrl,
downloadImages
});
if (!result.ok) {
setError(result.error || "Setup failed.");
setStatus("Setup failed.");
setIsSubmitting(false);
return;
}
setStatus("Setup complete. Launching wiki...");
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown setup error";
setError(message);
setStatus("Setup failed.");
setIsSubmitting(false);
}
};
const onClickBrowseZip = async () => {
const selected = await window.desktopApi.pickSourceZip();
if (selected) {
setSourceZipPath(selected);
}
};
return (
<main className="status-wrap setup-wrap">
<section className="status-panel setup-panel">
<h1>First Run Setup</h1>
<p>{status}</p>
{setupState?.sourceDir ? <p className="setup-meta">Detected local source: {setupState.sourceDir}</p> : null}
{error ? <p className="setup-error">{error}</p> : null}
<form className="setup-form" onSubmit={onSubmit}>
<div className="setup-mode-row" role="radiogroup" aria-label="Source input mode">
<button
type="button"
className={sourceMode === "url" ? "setup-mode-btn is-active" : "setup-mode-btn"}
onClick={() => setSourceMode("url")}
disabled={isSubmitting}
>
Source URL
</button>
<button
type="button"
className={sourceMode === "zip" ? "setup-mode-btn is-active" : "setup-mode-btn"}
onClick={() => setSourceMode("zip")}
disabled={isSubmitting}
>
Source ZIP
</button>
</div>
{sourceMode === "url" ? (
<label className="setup-field">
<span>5etools source repository URL</span>
<input
type="url"
value={sourceRepoUrl}
onChange={(event) => setSourceRepoUrl(event.target.value)}
required={sourceMode === "url"}
disabled={isSubmitting}
placeholder="https://github.com/5etools-mirror-2/5etools-mirror-2.github.io.git"
/>
</label>
) : (
<label className="setup-field">
<span>5etools source ZIP file</span>
<div className="setup-zip-row">
<input
type="text"
value={sourceZipPath}
onChange={(event) => setSourceZipPath(event.target.value)}
required={sourceMode === "zip"}
disabled={isSubmitting}
placeholder="/path/to/5etools-source.zip"
/>
<button type="button" className="setup-zip-pick" onClick={onClickBrowseZip} disabled={isSubmitting}>
Browse
</button>
</div>
</label>
)}
<label className="setup-field">
<span>5etools image repository URL</span>
<input
type="url"
value={imageRepoUrl}
onChange={(event) => setImageRepoUrl(event.target.value)}
required
disabled={isSubmitting}
placeholder="https://github.com/5etools-mirror-2/5etools-img"
/>
</label>
<label className="setup-field">
<span>External image base URL fallback</span>
<input
type="url"
value={imageBaseUrl}
onChange={(event) => setImageBaseUrl(event.target.value)}
required
disabled={isSubmitting}
placeholder="https://5e.tools/img/"
/>
</label>
<label className="setup-checkbox">
<input
type="checkbox"
checked={downloadImages}
onChange={(event) => setDownloadImages(event.target.checked)}
disabled={isSubmitting}
/>
<span>Download local image repository (recommended)</span>
</label>
<button className="setup-submit" type="submit" disabled={isSubmitting}>
{isSubmitting ? "Setting up..." : "Run Setup"}
</button>
</form>
</section>
</main>
);
}
function WikiView() {
const [wikiUrl, setWikiUrl] = useState<string>(""); const [wikiUrl, setWikiUrl] = useState<string>("");
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
const [currentPath, setCurrentPath] = useState<string>(DEFAULT_PAGE); const [currentPath, setCurrentPath] = useState<string>(DEFAULT_PAGE);
const frameRef = useRef<HTMLIFrameElement | null>(null);
useEffect(() => { useEffect(() => {
window.desktopApi window.desktopApi
@@ -18,6 +193,43 @@ export default function App() {
}); });
}, []); }, []);
useEffect(() => {
const onMessage = (event: MessageEvent<unknown>) => {
const frameWindow = frameRef.current?.contentWindow;
if (!frameWindow || event.source !== frameWindow) {
return;
}
if (!event.data || typeof event.data !== "object") {
return;
}
const data = event.data as { type?: string };
if (data.type !== "5etd:reset-onboarding") {
return;
}
void (async () => {
const result = await window.desktopApi.resetOnboarding();
if (result.ok) {
return;
}
frameWindow.postMessage(
{
type: "5etd:reset-onboarding-result",
ok: false,
error: result.error || "Failed to reset onboarding."
},
"*"
);
})();
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, []);
const frameSrc = useMemo(() => { const frameSrc = useMemo(() => {
if (!wikiUrl) { if (!wikiUrl) {
return ""; return "";
@@ -54,8 +266,13 @@ export default function App() {
<header className="sidebar-brand"> <header className="sidebar-brand">
<p className="sidebar-kicker">5etools Desktop</p> <p className="sidebar-kicker">5etools Desktop</p>
<h1 className="sidebar-title">Compendium</h1> <h1 className="sidebar-title">Compendium</h1>
<p className="sidebar-copy">5etools Desktop by <b>notBoris</b>.</p> <p className="sidebar-copy">
<p className="sidebar-copy-subtitle">This application is not affiliated with the 5etools Project. 5etools® is a Registered Trademark of The Norr Group Ltd. All rights reserved.</p> 5etools Desktop by <b>notBoris</b>.
</p>
<p className="sidebar-copy-subtitle">
This application is not affiliated with the 5etools Project. 5etools® is a Registered Trademark of The Norr Group Ltd.
All rights reserved.
</p>
</header> </header>
<nav className="sidebar-nav" aria-label="Wiki Sections"> <nav className="sidebar-nav" aria-label="Wiki Sections">
@@ -83,9 +300,22 @@ export default function App() {
<section className="workspace"> <section className="workspace">
<section className="frame-shell frame-shell--full"> <section className="frame-shell frame-shell--full">
<iframe className="wiki-frame" src={frameSrc} title="5etools Wiki" /> <iframe ref={frameRef} className="wiki-frame" src={frameSrc} title="5etools Wiki" />
</section> </section>
</section> </section>
</main> </main>
); );
} }
export default function App() {
const isSetupMode = useMemo(() => {
const params = new URLSearchParams(window.location.search);
return params.get("setup") === "1";
}, []);
if (isSetupMode) {
return <SetupView />;
}
return <WikiView />;
}
+102
View File
@@ -213,6 +213,108 @@ body {
color: var(--text-1); color: var(--text-1);
} }
.setup-wrap {
padding: 20px;
}
.setup-panel {
width: min(860px, 100%);
display: grid;
gap: 12px;
}
.setup-meta {
color: var(--text-2) !important;
font-size: 0.85rem;
}
.setup-error {
color: #ff9b9b !important;
}
.setup-form {
margin-top: 4px;
display: grid;
gap: 12px;
}
.setup-mode-row {
display: flex;
gap: 8px;
}
.setup-mode-btn {
border: 1px solid var(--line-strong);
border-radius: 9px;
background: rgba(10, 16, 28, 0.8);
color: var(--text-1);
padding: 7px 10px;
cursor: pointer;
}
.setup-mode-btn.is-active {
border-color: rgba(120, 205, 255, 0.75);
background: linear-gradient(140deg, rgba(63, 132, 207, 0.38), rgba(36, 75, 116, 0.82));
color: var(--text-0);
}
.setup-field {
display: grid;
gap: 5px;
}
.setup-field span {
color: var(--text-1);
font-size: 0.85rem;
}
.setup-field input {
width: 100%;
border: 1px solid var(--line-strong);
border-radius: 10px;
background: rgba(8, 14, 24, 0.88);
color: var(--text-0);
padding: 10px 12px;
}
.setup-zip-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
.setup-zip-pick {
border: 1px solid var(--line-strong);
border-radius: 10px;
background: rgba(18, 32, 50, 0.94);
color: var(--text-1);
padding: 10px 12px;
cursor: pointer;
}
.setup-checkbox {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-1);
}
.setup-submit {
justify-self: start;
border: 1px solid rgba(120, 205, 255, 0.65);
border-radius: 10px;
background: linear-gradient(140deg, rgba(56, 126, 198, 0.9), rgba(29, 87, 146, 0.95));
color: var(--text-0);
font-weight: 600;
padding: 10px 14px;
cursor: pointer;
}
.setup-submit:disabled {
opacity: 0.7;
cursor: default;
}
@keyframes frame-enter { @keyframes frame-enter {
from { from {
opacity: 0.5; opacity: 0.5;
+26
View File
@@ -2,6 +2,32 @@
interface DesktopApi { interface DesktopApi {
getWikiUrl: () => Promise<string>; getWikiUrl: () => Promise<string>;
getSetupState: () => Promise<SetupState>;
pickSourceZip: () => Promise<string | null>;
runSetup: (input: SetupRunInput) => Promise<SetupRunResult>;
resetOnboarding: () => Promise<SetupRunResult>;
}
interface SetupState {
needsSetup: boolean;
sourceDir: string | null;
sourceRepoUrl: string;
imageRepoUrl: string;
imageBaseUrl: string;
}
interface SetupRunInput {
mode: "url" | "zip";
sourceRepoUrl?: string;
sourceZipPath?: string;
imageRepoUrl?: string;
imageBaseUrl?: string;
downloadImages?: boolean;
}
interface SetupRunResult {
ok: boolean;
error?: string;
} }
interface Window { interface Window {
+1 -2
View File
@@ -15,7 +15,7 @@ export const WIKI_NAV_SECTIONS: WikiNavSection[] = [
items: [ items: [
{ label: "Home", path: "/5etools.html", description: "Main dashboard and tools" }, { label: "Home", path: "/5etools.html", description: "Main dashboard and tools" },
{ label: "Search", path: "/search.html", description: "Global content search" }, { label: "Search", path: "/search.html", description: "Global content search" },
{ label: "Quick Reference", path: "/quickreference.html", description: "Fast rules lookup" } { label: "Rules", path: "/variantrules.html", description: "System and optional rules" },
] ]
}, },
{ {
@@ -34,7 +34,6 @@ export const WIKI_NAV_SECTIONS: WikiNavSection[] = [
{ label: "Spells", path: "/spells.html", description: "Spell catalog and filters" }, { label: "Spells", path: "/spells.html", description: "Spell catalog and filters" },
{ label: "Items", path: "/items.html", description: "Equipment and magic items" }, { label: "Items", path: "/items.html", description: "Equipment and magic items" },
{ label: "Bestiary", path: "/bestiary.html", description: "Creatures and stat blocks" }, { label: "Bestiary", path: "/bestiary.html", description: "Creatures and stat blocks" },
{ label: "Rules", path: "/variantrules.html", description: "System and optional rules" },
{ label: "Tables", path: "/tables.html", description: "Reference tables and charts" } { label: "Tables", path: "/tables.html", description: "Reference tables and charts" }
] ]
} }