diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e6fff71..72b211a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -17,11 +17,10 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm + - name: Verify Node.js toolchain + run: | + node --version + npm --version - name: Install dependencies run: npm ci @@ -56,11 +55,10 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm + - name: Verify Node.js toolchain + run: | + node --version + npm --version - name: Install dependencies run: npm ci @@ -71,7 +69,7 @@ jobs: - name: Build installers env: 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 uses: actions/upload-artifact@v4 diff --git a/README.md b/README.md index 41a8e32..ea7a0d0 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,15 @@ Current stage: ## Prerequisites - Node.js 20+ -- `5etools-src` directory in this repository root: - - `./5etools-src` +- Git (only required if setup mode uses source/image repository URLs) + +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: diff --git a/package-lock.json b/package-lock.json index a6b5e02..190ddcb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "5et-d-desktop", "version": "0.1.0", "dependencies": { - "electron": "40.6.0", "react": "19.2.4", "react-dom": "19.2.4" }, @@ -18,6 +17,7 @@ "@types/react-dom": "latest", "@vitejs/plugin-react": "5.1.4", "concurrently": "9.2.1", + "electron": "40.6.0", "rimraf": "6.1.3", "typescript": "5.9.3", "vite": "7.3.1", @@ -1857,6 +1857,7 @@ "version": "40.6.0", "resolved": "https://registry.npmjs.org/electron/-/electron-40.6.0.tgz", "integrity": "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 2f44e27..90df6a0 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "description": "5ET-D desktop shell for local 5etools wiki", + "author": "notBoris", "main": "dist-electron/main/main.js", "scripts": { "clean": "rimraf dist dist-electron release", @@ -17,12 +18,11 @@ "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\"", "start": "npm run build && node scripts/run-electron.cjs .", - "pack": "npm run build && npx electron-builder --dir", - "dist": "npm run build && npx electron-builder", - "dist:mac:arm64": "npm run build && CSC_IDENTITY_AUTO_DISCOVERY=false npx electron-builder --mac --arm64 --publish never" + "pack": "npm run build && npx electron-builder@26.8.1 --dir", + "dist": "npm run build && npx electron-builder@26.8.1", + "dist:mac:arm64": "npm run build && CSC_IDENTITY_AUTO_DISCOVERY=false npx electron-builder@26.8.1 --mac --arm64 --publish never" }, "dependencies": { - "electron": "40.6.0", "react": "19.2.4", "react-dom": "19.2.4" }, @@ -32,6 +32,7 @@ "@types/react-dom": "latest", "@vitejs/plugin-react": "5.1.4", "concurrently": "9.2.1", + "electron": "40.6.0", "rimraf": "6.1.3", "typescript": "5.9.3", "vite": "7.3.1", @@ -48,15 +49,6 @@ "dist-electron/**/*", "package.json" ], - "extraResources": [ - { - "from": "5etools-src", - "to": "5etools-src", - "filter": [ - "**/*" - ] - } - ], "mac": { "target": "dmg" }, diff --git a/src/main/appConfig.ts b/src/main/appConfig.ts new file mode 100644 index 0000000..0b896ff --- /dev/null +++ b/src/main/appConfig.ts @@ -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 }); + } +} diff --git a/src/main/main.ts b/src/main/main.ts index 29d9870..de3aa27 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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 { - 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(); + + 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 { + await new Promise((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 { - wikiServer = await startWikiServer(); +async function runGit(args: string[], cwd?: string): Promise { + await runProcess("git", args, cwd); +} +function getPowerShellLiteralPath(filePath: string): string { + return filePath.replace(/'/g, "''"); +} + +async function extractSourceZip(zipPath: string, targetDir: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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(); + + 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 { + 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 { return wikiServer.url; }); - await createMainWindow(); + ipcMain.handle("setup:get-state", async (): Promise => getSetupState()); + + ipcMain.handle("setup:pick-source-zip", async (event): Promise => { + 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 => { + 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 => { + 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 { + 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(); }); diff --git a/src/main/wikiServer.ts b/src/main/wikiServer.ts index 088dbb8..2367ed7 100644 --- a/src/main/wikiServer.ts +++ b/src/main/wikiServer.ts @@ -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 = { ".avif": "image/avif", @@ -42,9 +39,10 @@ const MIME_TYPES: Record = { ".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; } -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 { - const externalImageUrl = getExternalImageUrl(requestUrl); +async function pTryServeExternalImage( + requestUrl: string, + res: http.ServerResponse, + externalImageRoot: string | null +): Promise { + 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 = [ - ``, + ``, `` ].join(""); @@ -825,8 +1177,10 @@ function resolveRequestPath(rootDir: string, requestUrl: string): string { return candidate; } -export async function startWikiServer(): Promise { - const rootDir = resolveWikiRootDir(); +export async function startWikiServer(options: StartWikiServerOptions = {}): Promise { + const rootDir = resolveWikiRootDir(options.rootDir); + const externalImageRoot = resolveExternalImageRoot(options.externalImageRoot); + const reskinBootstrap = getDesktopReskinBootstrap(externalImageRoot); return await new Promise((resolve, reject) => { const server = http.createServer((req, res) => { @@ -844,7 +1198,7 @@ export async function startWikiServer(): Promise { 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 { 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), diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 087b48f..8691f79 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -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 + getWikiUrl: () => ipcRenderer.invoke("wiki:get-url") as Promise, + getSetupState: () => ipcRenderer.invoke("setup:get-state") as Promise, + pickSourceZip: () => ipcRenderer.invoke("setup:pick-source-zip") as Promise, + runSetup: (input: SetupRunInput) => ipcRenderer.invoke("setup:run", input) as Promise, + resetOnboarding: () => ipcRenderer.invoke("setup:reset-onboarding") as Promise }); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 85a85fd..3c671ba 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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(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 ( +
+
+

First Run Setup

+

{status}

+ {setupState?.sourceDir ?

Detected local source: {setupState.sourceDir}

: null} + {error ?

{error}

: null} + +
+
+ + +
+ + {sourceMode === "url" ? ( + + ) : ( + + )} + + + + + + + + +
+
+
+ ); +} + +function WikiView() { const [wikiUrl, setWikiUrl] = useState(""); const [error, setError] = useState(""); const [currentPath, setCurrentPath] = useState(DEFAULT_PAGE); + const frameRef = useRef(null); useEffect(() => { window.desktopApi @@ -18,6 +193,43 @@ export default function App() { }); }, []); + useEffect(() => { + const onMessage = (event: MessageEvent) => { + 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() {

5etools Desktop

Compendium

-

5etools Desktop by notBoris.

-

This application is not affiliated with the 5etools Project. 5etools® is a Registered Trademark of The Norr Group Ltd. All rights reserved.

+

+ 5etools Desktop by notBoris. +

+

+ This application is not affiliated with the 5etools Project. 5etools® is a Registered Trademark of The Norr Group Ltd. + All rights reserved. +