import { app, BrowserWindow, dialog, ipcMain } from 'electron'; import fs from 'node:fs'; import path from 'node:path'; import type { CharacterSheet, SystemDefinition } from '../shared/types.ts'; import { AppStore } from './store.ts'; let mainWindow: BrowserWindow | null = null; let store: AppStore; function preloadPath(): string { return path.join(__dirname, '../preload/index.cjs'); } function presetsDirs(): string[] { const candidates = [ path.join(app.getAppPath(), 'presets'), path.join(process.cwd(), 'presets'), path.join(__dirname, '../../presets'), ]; if (app.isPackaged) { candidates.unshift(path.join(process.resourcesPath, 'presets')); } return candidates.filter((d) => fs.existsSync(d)); } function listPresetFiles(): string[] { const files: string[] = []; for (const dir of presetsDirs()) { for (const f of fs.readdirSync(dir)) { if (f.endsWith('.json') && !f.includes('sample')) { files.push(path.join(dir, f)); } } } // unique by basename const byName = new Map(); for (const f of files) byName.set(path.basename(f), f); return [...byName.values()].sort(); } function seedPresetsIfEmpty(): number { if (store.listSystems().length > 0) return 0; let n = 0; for (const file of listPresetFiles()) { try { const system = JSON.parse(fs.readFileSync(file, 'utf8')) as SystemDefinition; store.upsertSystem(system); n += 1; } catch (e) { console.error('Failed to seed preset', file, e); } } return n; } function createWindow(): void { mainWindow = new BrowserWindow({ width: 1480, height: 920, minWidth: 1100, minHeight: 700, backgroundColor: '#09090b', title: 'TTRPG Character Builder', webPreferences: { preload: preloadPath(), contextIsolation: true, nodeIntegration: false, }, }); mainWindow.once('ready-to-show', () => { mainWindow?.show(); mainWindow?.focus(); }); mainWindow.show(); const devUrl = process.env.VITE_DEV_SERVER_URL; if (devUrl) { mainWindow.webContents.openDevTools({ mode: 'detach' }); mainWindow.webContents.on('did-fail-load', (_e, code, desc, url) => { console.error('did-fail-load', code, desc, url); }); mainWindow.webContents.on('console-message', (_e, level, message) => { console.log(`[renderer:${level}]`, message); }); const load = async (attempt = 0) => { try { await mainWindow!.loadURL(devUrl); console.log('Loaded', devUrl); } catch (err) { console.error('loadURL attempt', attempt, err); if (attempt < 20) { setTimeout(() => void load(attempt + 1), 300); return; } await mainWindow!.loadURL( `data:text/html,

Dev server not reachable: ${devUrl}

`, ); } }; void load(); } else { void mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')); } } function registerIpc(): void { ipcMain.handle('systems:list', () => store.listSystems()); ipcMain.handle('systems:get', (_e, id: string) => store.getSystem(id)); ipcMain.handle('systems:upsert', (_e, system: SystemDefinition) => store.upsertSystem(system)); ipcMain.handle('systems:delete', (_e, id: string) => { store.deleteSystem(id); return true; }); ipcMain.handle('characters:list', (_e, systemId?: string) => store.listCharacters(systemId)); ipcMain.handle('characters:get', (_e, id: string) => store.getCharacter(id)); ipcMain.handle('characters:upsert', (_e, character: CharacterSheet) => store.upsertCharacter(character)); ipcMain.handle('characters:delete', (_e, id: string) => { store.deleteCharacter(id); return true; }); ipcMain.handle('systems:export', async (_e, id: string) => { const system = store.getSystem(id); if (!system || !mainWindow) return null; const result = await dialog.showSaveDialog(mainWindow, { title: 'Export system', defaultPath: `${system.id}.ttrpg-system.json`, filters: [{ name: 'TTRPG System', extensions: ['json'] }], }); if (result.canceled || !result.filePath) return null; fs.writeFileSync(result.filePath, JSON.stringify(system, null, 2), 'utf8'); return result.filePath; }); ipcMain.handle('systems:import', async () => { if (!mainWindow) return null; const result = await dialog.showOpenDialog(mainWindow, { title: 'Import system', filters: [{ name: 'TTRPG System', extensions: ['json'] }], properties: ['openFile'], }); if (result.canceled || !result.filePaths[0]) return null; const raw = fs.readFileSync(result.filePaths[0], 'utf8'); const system = JSON.parse(raw) as SystemDefinition; return store.upsertSystem(system); }); ipcMain.handle('presets:list', () => listPresetFiles()); ipcMain.handle('presets:load', (_e, filePath: string) => { const raw = fs.readFileSync(filePath, 'utf8'); const system = JSON.parse(raw) as SystemDefinition; return store.upsertSystem(system); }); ipcMain.handle('presets:seed', () => { // Force re-seed missing presets by id let n = 0; for (const file of listPresetFiles()) { const system = JSON.parse(fs.readFileSync(file, 'utf8')) as SystemDefinition; if (!store.getSystem(system.id)) { store.upsertSystem(system); n += 1; } } if (store.listSystems().length === 0) n = seedPresetsIfEmpty(); return n; }); } app.whenReady().then(() => { const dbPath = path.join(app.getPath('userData'), 'builder.sqlite'); store = new AppStore(dbPath); const seeded = seedPresetsIfEmpty(); if (seeded) console.log(`Seeded ${seeded} presets`); registerIpc(); createWindow(); }); app.on('window-all-closed', () => { store?.close(); if (process.platform !== 'darwin') app.quit(); });