Added loading 5et from zip release, or cloning git repo. Improvements to CSS and some missing functionality
This commit is contained in:
1 parent
e241442302
commit
8be4c8d571
12 files changed
+1383
-104
No files matched your search
@@ -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
@@ -1,49 +1,482 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { app, BrowserWindow, ipcMain } from "electron";
|
||||
import { startWikiServer, type WikiServer } from "./wikiServer";
|
||||
import { spawn } from "node:child_process";
|
||||
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 wikiServer: WikiServer | null = null;
|
||||
|
||||
const DEV_RENDERER_URL = process.env.VITE_DEV_SERVER_URL;
|
||||
let wikiServerRootDir: string | null = null;
|
||||
let wikiServerExternalImageRoot: string | null = null;
|
||||
let isSetupInProgress = false;
|
||||
|
||||
function isDevMode(): boolean {
|
||||
return !app.isPackaged;
|
||||
}
|
||||
|
||||
async function createMainWindow(): Promise<void> {
|
||||
if (!wikiServer) {
|
||||
throw new Error("Wiki server was not initialized before creating the window");
|
||||
function getDefaultSourceDir(): string {
|
||||
return path.join(app.getPath("userData"), USERDATA_SOURCE_DIRNAME);
|
||||
}
|
||||
|
||||
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({
|
||||
width: 1440,
|
||||
height: 900,
|
||||
minWidth: 1024,
|
||||
minHeight: 700,
|
||||
title: "5etools Desktop",
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
preload: path.join(__dirname, "../preload/preload.js")
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`${label} must be a valid URL.`);
|
||||
}
|
||||
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
throw new Error(`${label} must use HTTP or HTTPS.`);
|
||||
}
|
||||
|
||||
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) {
|
||||
await mainWindow.loadURL(DEV_RENDERER_URL);
|
||||
} else {
|
||||
await mainWindow.loadFile(path.join(__dirname, "../../dist/index.html"));
|
||||
const resolved = path.resolve(current.dir);
|
||||
if (seen.has(resolved)) {
|
||||
continue;
|
||||
}
|
||||
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", () => {
|
||||
mainWindow = null;
|
||||
return 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> {
|
||||
wikiServer = await startWikiServer();
|
||||
async function runGit(args: string[], cwd?: string): Promise<void> {
|
||||
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 () => {
|
||||
if (!wikiServer) {
|
||||
throw new Error("Wiki server is not available");
|
||||
@@ -52,12 +485,76 @@ async function bootstrap(): Promise<void> {
|
||||
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 () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
await createMainWindow();
|
||||
if (BrowserWindow.getAllWindows().length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasSource = await pTryStartWikiFromLocalConfig();
|
||||
await openAppWindow(!hasSource);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,8 +572,5 @@ app.on("window-all-closed", () => {
|
||||
});
|
||||
|
||||
app.on("before-quit", async () => {
|
||||
if (wikiServer) {
|
||||
await wikiServer.close();
|
||||
wikiServer = null;
|
||||
}
|
||||
await stopWikiServer();
|
||||
});
|
||||
+389
-35
@@ -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),
|
||||
|
||||
+27
-1
@@ -1,5 +1,31 @@
|
||||
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", {
|
||||
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
@@ -1,12 +1,187 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { WIKI_NAV_SECTIONS } from "./wikiNav";
|
||||
|
||||
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 [error, setError] = useState<string>("");
|
||||
const [currentPath, setCurrentPath] = useState<string>(DEFAULT_PAGE);
|
||||
const frameRef = useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
if (!wikiUrl) {
|
||||
return "";
|
||||
@@ -54,8 +266,13 @@ export default function App() {
|
||||
<header className="sidebar-brand">
|
||||
<p className="sidebar-kicker">5etools Desktop</p>
|
||||
<h1 className="sidebar-title">Compendium</h1>
|
||||
<p className="sidebar-copy">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>
|
||||
<p className="sidebar-copy">
|
||||
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>
|
||||
|
||||
<nav className="sidebar-nav" aria-label="Wiki Sections">
|
||||
@@ -83,9 +300,22 @@ export default function App() {
|
||||
|
||||
<section className="workspace">
|
||||
<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>
|
||||
</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 />;
|
||||
}
|
||||
@@ -213,6 +213,108 @@ body {
|
||||
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 {
|
||||
from {
|
||||
opacity: 0.5;
|
||||
|
||||
Vendored
+26
@@ -2,6 +2,32 @@
|
||||
|
||||
interface DesktopApi {
|
||||
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 {
|
||||
|
||||
@@ -15,7 +15,7 @@ export const WIKI_NAV_SECTIONS: WikiNavSection[] = [
|
||||
items: [
|
||||
{ label: "Home", path: "/5etools.html", description: "Main dashboard and tools" },
|
||||
{ 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: "Items", path: "/items.html", description: "Equipment and magic items" },
|
||||
{ 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" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user