Files
ttrpg-character-builder/app/main/index.ts
T
Ivan Fontosh d1f180ee0a Initial TTRPG character meta-builder with visual sheet editor.
Offline Electron app seeds 10 system presets, supports create-from-scratch designer with palette/drag/resize/bricks, and exports .ttrpg-system.json for the player.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 14:49:31 +08:00

157 lines
4.9 KiB
TypeScript

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<string, string>();
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,
},
});
const devUrl = process.env.VITE_DEV_SERVER_URL;
if (devUrl) {
void mainWindow.loadURL(devUrl);
} 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();
});