Files
ERitors-Scribe-Desktop/electron/main.ts
natreex 7f34421212 Add error handling, enhance syncing, and refactor deletion logic
- Introduce new error messages for syncing and book deletion in `en.json`.
- Update `DeleteBook` to support local-only deletion and synced book management.
- Refine offline/online behavior with `deleteLocalToo` checkbox and update related state handling.
- Extend repository and IPC methods to handle optional IDs for updates.
- Add `SyncQueueContext` for queueing offline changes and improving synchronization workflows.
- Enhance refined text generation logic in `DraftCompanion` and `GhostWriter` components.
- Replace PUT with PATCH for world updates to align with API expectations.
- Streamline `AlertBox` by integrating dynamic translation keys for deletion prompts.
2026-01-10 15:50:03 -05:00

739 lines
21 KiB
TypeScript

import {app, BrowserWindow, dialog, ipcMain, IpcMainInvokeEvent, Menu, nativeImage, protocol, safeStorage, shell} from 'electron';
import * as path from 'path';
import {fileURLToPath} from 'url';
import * as fs from 'fs';
import {DatabaseService, getDatabaseService} from './database/database.service.js';
import SecureStorage, {getSecureStorage} from './storage/SecureStorage.js';
import {getUserEncryptionKey, hasUserEncryptionKey, setUserEncryptionKey} from './database/keyManager.js';
import {generateUserEncryptionKey} from './database/encryption.js';
// Import IPC handlers
import './ipc/book.ipc.js';
import './ipc/user.ipc.js';
import './ipc/chapter.ipc.js';
import './ipc/character.ipc.js';
import './ipc/location.ipc.js';
import './ipc/offline.ipc.js';
// Fix pour __dirname en ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const isDev = !app.isPackaged;
// Enregistrer le protocole scribedesktop:// comme standard (avant app.whenReady)
if (!isDev) {
protocol.registerSchemesAsPrivileged([
{
scheme: 'scribedesktop',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true
}
}
]);
}
// Définir le nom de l'application
app.setName('ERitors Scribe');
// En dev et prod, __dirname pointe vers dist/electron/
const preloadPath = path.join(__dirname, 'preload.js');
// Icône de l'application
const iconPath = isDev
? path.join(__dirname, '../build/icon.png')
: process.platform === 'darwin'
? path.join(process.resourcesPath, 'icon.icns') // macOS utilise .icns
: path.join(process.resourcesPath, 'app.asar/build/icon.png'); // Windows/Linux utilisent .png
let mainWindow: BrowserWindow | null = null;
let loginWindow: BrowserWindow | null = null;
function createLoginWindow(): void {
loginWindow = new BrowserWindow({
width: 500,
height: 900,
resizable: false,
...(process.platform !== 'darwin' && { icon: iconPath }),
autoHideMenuBar: true,
webPreferences: {
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
allowRunningInsecureContent: false,
experimentalFeatures: false,
enableBlinkFeatures: '',
disableBlinkFeatures: '',
webviewTag: false,
navigateOnDragDrop: false,
},
frame: true,
show: false,
});
if (isDev) {
const devPort = process.env.PORT || '4000';
loginWindow.loadURL(`http://localhost:${devPort}/login/login`);
loginWindow.webContents.openDevTools();
} else {
loginWindow.loadURL('scribedesktop://./login/login/index.html');
}
loginWindow.once('ready-to-show', () => {
loginWindow?.show();
});
loginWindow.on('closed', () => {
loginWindow = null;
});
// Security: Block navigation to external domains
loginWindow.webContents.on('will-navigate', (event, navigationUrl) => {
const parsedUrl = new URL(navigationUrl);
if (isDev) {
if (!parsedUrl.origin.startsWith('http://localhost')) {
event.preventDefault();
}
} else {
if (parsedUrl.protocol !== 'scribedesktop:') {
event.preventDefault();
}
}
});
// Security: Block new window creation
loginWindow.webContents.setWindowOpenHandler(() => {
return { action: 'deny' };
});
}
function createMainWindow(): void {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
...(process.platform !== 'darwin' && { icon: iconPath }),
autoHideMenuBar: true,
webPreferences: {
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
allowRunningInsecureContent: false,
experimentalFeatures: false,
enableBlinkFeatures: '',
disableBlinkFeatures: '',
webviewTag: false,
navigateOnDragDrop: false,
},
show: false,
});
if (isDev) {
const devPort = process.env.PORT || '4000';
mainWindow.loadURL(`http://localhost:${devPort}`);
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadURL('scribedesktop://./index.html');
}
mainWindow.once('ready-to-show', () => {
mainWindow?.show();
});
mainWindow.on('closed', () => {
mainWindow = null;
});
// Security: Block navigation to external domains
mainWindow.webContents.on('will-navigate', (event, navigationUrl) => {
const parsedUrl = new URL(navigationUrl);
if (isDev) {
if (!parsedUrl.origin.startsWith('http://localhost')) {
event.preventDefault();
}
} else {
if (parsedUrl.protocol !== 'scribedesktop:') {
event.preventDefault();
}
}
});
// Security: Block new window creation
mainWindow.webContents.setWindowOpenHandler(() => {
return { action: 'deny' };
});
}
// IPC Handler pour ouvrir des liens externes (navigateur/app native)
ipcMain.handle('open-external', async (_event, url: string) => {
// Security: Validate URL before opening
try {
const parsedUrl = new URL(url);
const allowedProtocols = ['http:', 'https:', 'mailto:'];
if (!allowedProtocols.includes(parsedUrl.protocol)) {
console.error('[Security] Blocked external URL with invalid protocol:', parsedUrl.protocol);
return;
}
await shell.openExternal(url);
} catch (error) {
console.error('[Security] Invalid URL rejected:', url);
}
});
// IPC Handler pour OAuth login via BrowserWindow
let oauthWindow: BrowserWindow | null = null;
interface OAuthResult {
success: boolean;
code?: string;
state?: string;
error?: string;
}
interface OAuthRequest {
provider: 'google' | 'facebook' | 'apple';
baseUrl: string;
}
ipcMain.handle('oauth-login', async (_event, request: OAuthRequest): Promise<OAuthResult> => {
return new Promise((resolve) => {
const { provider, baseUrl } = request;
const redirectUri = `${baseUrl}login?provider=${provider}`;
const encodedRedirectUri = encodeURIComponent(redirectUri);
// Fermer une éventuelle fenêtre OAuth existante
if (oauthWindow) {
oauthWindow.close();
oauthWindow = null;
}
// Configuration OAuth par provider
const oauthConfigs: Record<string, string> = {
google: `https://accounts.google.com/o/oauth2/v2/auth?client_id=911482317931-pvjog1br22r6l8k1afq0ki94em2fsoen.apps.googleusercontent.com&redirect_uri=${encodedRedirectUri}&response_type=code&scope=openid%20email%20profile&access_type=offline`,
facebook: `https://www.facebook.com/v18.0/dialog/oauth?client_id=1015270470233591&redirect_uri=${encodedRedirectUri}&scope=email&response_type=code&state=abc123`,
apple: `https://appleid.apple.com/auth/authorize?client_id=eritors.apple.login&redirect_uri=${encodedRedirectUri}&response_type=code&scope=email%20name&response_mode=query&state=abc123`
};
const authUrl = oauthConfigs[provider];
if (!authUrl) {
resolve({ success: false, error: 'Invalid provider' });
return;
}
oauthWindow = new BrowserWindow({
width: 600,
height: 700,
show: true,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
}
});
// Intercepter les redirections pour capturer le code OAuth
const handleNavigation = (url: string): boolean => {
try {
const parsedUrl = new URL(url);
// Vérifier si c'est notre redirect URI (compare sans le query string)
const baseRedirectUri = `${baseUrl}login`;
if (url.startsWith(baseRedirectUri)) {
const code = parsedUrl.searchParams.get('code');
const state = parsedUrl.searchParams.get('state');
const error = parsedUrl.searchParams.get('error');
if (error) {
resolve({ success: false, error });
} else if (code) {
resolve({ success: true, code, state: state || undefined });
} else {
resolve({ success: false, error: 'No code received' });
}
if (oauthWindow) {
oauthWindow.close();
oauthWindow = null;
}
return true; // Navigation interceptée
}
} catch (e) {
// URL invalide, continuer
}
return false; // Laisser la navigation continuer
};
// Écouter will-redirect (redirections HTTP)
oauthWindow.webContents.on('will-redirect', (event, url) => {
if (handleNavigation(url)) {
event.preventDefault();
}
});
// Écouter will-navigate (navigations normales)
oauthWindow.webContents.on('will-navigate', (event, url) => {
if (handleNavigation(url)) {
event.preventDefault();
}
});
// Gérer la fermeture de la fenêtre par l'utilisateur
oauthWindow.on('closed', () => {
oauthWindow = null;
resolve({ success: false, error: 'Window closed by user' });
});
// Charger l'URL OAuth
oauthWindow.loadURL(authUrl);
});
});
// IPC Handlers pour la gestion du token (OS-encrypted storage)
ipcMain.handle('get-token', () => {
const storage:SecureStorage = getSecureStorage();
return storage.get('authToken', null);
});
ipcMain.handle('set-token', (_event, token: string) => {
const storage = getSecureStorage();
storage.set('authToken', token);
return true;
});
ipcMain.handle('remove-token', () => {
const storage = getSecureStorage();
storage.delete('authToken');
return true;
});
// IPC Handlers pour la gestion de la langue
ipcMain.handle('get-lang', () => {
const storage = getSecureStorage();
return storage.get('userLang', 'fr');
});
ipcMain.handle('set-lang', (_event, lang: 'fr' | 'en') => {
const storage = getSecureStorage();
storage.set('userLang', lang);
return true;
});
// IPC Handler pour initialiser l'utilisateur après récupération depuis le serveur
ipcMain.handle('init-user', async (_event:IpcMainInvokeEvent, userId: string) => {
const storage:SecureStorage = getSecureStorage();
storage.set('userId', userId);
storage.set('lastUserId', userId);
try {
let encryptionKey: string | null;
if (!hasUserEncryptionKey(userId)) {
encryptionKey = generateUserEncryptionKey(userId);
if (!encryptionKey) {
throw new Error('Failed to generate encryption key');
}
setUserEncryptionKey(userId, encryptionKey);
const savedKey:string = getUserEncryptionKey(userId);
if (!savedKey) {
throw new Error('Failed to save encryption key');
}
} else {
encryptionKey = getUserEncryptionKey(userId);
if (!encryptionKey) {
console.error('[InitUser] CRITICAL: Existing key is undefined, regenerating');
const { generateUserEncryptionKey } = await import('./database/encryption.js');
encryptionKey = generateUserEncryptionKey(userId);
setUserEncryptionKey(userId, encryptionKey);
}
}
if (safeStorage.isEncryptionAvailable()) {
storage.save();
} else {
return {
success: false,
error: 'Encryption is not available on this system'
};
}
return { success: true, keyCreated: !hasUserEncryptionKey(userId) };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
});
ipcMain.on('login-success', async (_event, token: string) => {
const storage = getSecureStorage();
storage.set('authToken', token);
if (loginWindow) {
loginWindow.close();
}
createMainWindow();
setTimeout(async ():Promise<void> => {
try {
if (safeStorage.isEncryptionAvailable()) {
storage.save();
} else {
setTimeout(() => {
if (safeStorage.isEncryptionAvailable()) {
storage.save();
} else {
console.error('[Login] CRITICAL: Cannot encrypt credentials');
}
}, 1000);
}
} catch (error) {
console.error('[Login] Error saving auth data:', error);
}
}, 500);
});
ipcMain.on('logout', ():void => {
try {
const storage:SecureStorage = getSecureStorage();
storage.delete('authToken');
storage.delete('userId');
storage.delete('userLang');
storage.save();
} catch (error) {
console.error('[Logout] Error clearing storage:', error);
}
try {
const db:DatabaseService = getDatabaseService();
db.close();
} catch (error) {
console.error('[Logout] Error closing database:', error);
}
if (mainWindow) {
mainWindow.close();
mainWindow = null;
}
createLoginWindow();
});
// ========== USER SYNC (PRE-AUTHENTICATION) ==========
interface SyncUserData {
userId: string;
firstName: string;
lastName: string;
username: string;
email: string;
}
ipcMain.handle('db:user:sync', async (_event, data: SyncUserData): Promise<boolean> => {
try {
const { default: User } = await import('./database/models/User.js');
const { default: UserRepo } = await import('./database/repositories/user.repository.js');
const lang: 'fr' | 'en' = 'fr';
try {
UserRepo.fetchUserInfos(data.userId, lang);
return true;
} catch (error) {
await User.addUser(
data.userId,
data.firstName,
data.lastName,
data.username,
data.email,
'',
lang
);
return true;
}
} catch (error) {
throw error;
}
});
/**
* Generate user encryption key
*/
ipcMain.handle('generate-encryption-key', async (_event, userId: string) => {
try {
const key:string = generateUserEncryptionKey(userId);
return { success: true, key };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
});
/**
* Get or generate user encryption key (OS-encrypted storage)
*/
ipcMain.handle('get-user-encryption-key', (_event, userId: string) => {
const storage:SecureStorage = getSecureStorage();
return storage.get(`encryptionKey-${userId}`, null);
});
/**
* Store user encryption key (OS-encrypted storage)
*/
ipcMain.handle('set-user-encryption-key', (_event, userId: string, encryptionKey: string) => {
const storage:SecureStorage = getSecureStorage();
storage.set(`encryptionKey-${userId}`, encryptionKey);
return true;
});
/**
* Initialize database for user
*/
ipcMain.handle('db-initialize', (_event, userId: string, encryptionKey: string) => {
try {
const db:DatabaseService = getDatabaseService();
db.initialize(userId, encryptionKey);
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
});
/**
* Emergency restore - Clean up ALL local data
*/
function performEmergencyRestore(): void {
try {
// Close database connection
const db: DatabaseService = getDatabaseService();
db.close();
// Get storage and userId before clearing
const storage: SecureStorage = getSecureStorage();
const userId = storage.get<string>('userId');
const lastUserId = storage.get<string>('lastUserId');
// Delete user-specific data
if (userId) {
storage.delete(`pin-${userId}`);
storage.delete(`encryptionKey-${userId}`);
}
if (lastUserId && lastUserId !== userId) {
storage.delete(`pin-${lastUserId}`);
storage.delete(`encryptionKey-${lastUserId}`);
}
// Delete all general data
storage.delete('authToken');
storage.delete('userId');
storage.delete('lastUserId');
storage.delete('userLang');
storage.delete('offlineMode');
storage.delete('syncInterval');
// Save cleared storage
storage.save();
// Delete database file
const userDataPath: string = app.getPath('userData');
const dbPath: string = path.join(userDataPath, 'eritors-local.db');
if (fs.existsSync(dbPath)) {
fs.unlinkSync(dbPath);
}
// Delete secure config file to ensure complete reset
const secureConfigPath: string = path.join(userDataPath, 'secure-config.json');
if (fs.existsSync(secureConfigPath)) {
fs.unlinkSync(secureConfigPath);
}
console.log('[Emergency Restore] All local data cleared successfully');
} catch (error) {
console.error('[Emergency Restore] Error:', error);
}
// Restart app
if (mainWindow) {
mainWindow.close();
mainWindow = null;
}
if (loginWindow) {
loginWindow.close();
loginWindow = null;
}
app.relaunch();
app.exit(0);
}
app.whenReady().then(():void => {
// Security: Disable web cache in production
if (!isDev) {
app.commandLine.appendSwitch('disable-http-cache');
}
// Security: Set permissions request handler
app.on('web-contents-created', (_event, contents) => {
// Allow only clipboard permissions, block others
contents.session.setPermissionRequestHandler((_webContents, permission, callback) => {
const allowedPermissions: string[] = ['clipboard-read', 'clipboard-sanitized-write'];
callback(allowedPermissions.includes(permission));
});
// Block all web requests to file:// protocol
contents.session.protocol.interceptFileProtocol('file', (request, callback) => {
callback({ error: -3 }); // net::ERR_ABORTED
});
});
// Menu minimal pour garder les raccourcis DevTools et clipboard
const template: Electron.MenuItemConstructorOptions[] = [
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' }
]
},
{
label: 'View',
submenu: [
{ role: 'toggleDevTools' }
]
},
{
label: 'Help',
submenu: [
{
label: 'Restore App',
click: () => {
dialog.showMessageBox({
type: 'warning',
buttons: ['Cancel', 'Restore'],
defaultId: 0,
cancelId: 0,
title: 'Restore App',
message: 'Are you sure you want to restore the app?',
detail: 'This will delete all local data including: PIN codes, encryption keys, local database, and authentication tokens. The app will restart after restoration.'
}).then((result) => {
if (result.response === 1) {
performEmergencyRestore();
}
});
}
}
]
}
];
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
if (!isDev) {
const outPath:string = path.join(process.resourcesPath, 'app.asar.unpacked/out');
protocol.handle('scribedesktop', async (request) => {
// Security: Validate and sanitize file path
let filePath:string = request.url.replace('scribedesktop://', '').replace(/^\.\//, '');
// Security: Block path traversal attempts
if (filePath.includes('..') || filePath.includes('~')) {
console.error('[Security] Path traversal attempt blocked:', filePath);
return new Response('Forbidden', { status: 403 });
}
const fullPath:string = path.normalize(path.join(outPath, filePath));
// Security: Ensure path is within allowed directory
if (!fullPath.startsWith(outPath)) {
console.error('[Security] Path escape attempt blocked:', fullPath);
return new Response('Forbidden', { status: 403 });
}
try {
const data = await fs.promises.readFile(fullPath);
const ext:string = path.extname(fullPath).toLowerCase();
const mimeTypes: Record<string, string> = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
};
return new Response(data, {
headers: {
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
'X-Content-Type-Options': 'nosniff'
}
});
} catch (error) {
return new Response('Not found', { status: 404 });
}
});
}
// Définir l'icône du Dock sur macOS
if (process.platform === 'darwin' && app.dock) {
const icon = nativeImage.createFromPath(iconPath);
app.dock.setIcon(icon);
}
// Vérifier si un token existe (OS-encrypted storage)
const storage: SecureStorage = getSecureStorage();
const token: string | null = storage.get('authToken');
if (token) {
createMainWindow();
} else {
createLoginWindow();
}
app.on('activate', ():void => {
if (BrowserWindow.getAllWindows().length === 0) {
const storage: SecureStorage = getSecureStorage();
const token: string | null = storage.get('authToken');
if (token) {
createMainWindow();
} else {
createLoginWindow();
}
}
});
});
app.on('window-all-closed', ():void => {
app.quit();
});