Files
ERitors-Scribe-Desktop/electron/main.ts
natreex 9e51cc93a8 Remove SyncService and introduce context-based offline mode and state management
- Delete `SyncService` and its associated bidirectional synchronization logic.
- Add multiple context providers (`OfflineProvider`, `AlertProvider`, `LangContext`, `UserContext`, `SessionContext`, `WorldContext`, `SettingBookContext`) for contextual state management.
- Implement `SecureStorage` for OS-level secure data encryption and replace dependency on `SyncService` synchronization.
- Update localization files (`en.json`, `fr.json`) with offline mode and error-related strings.
2025-11-19 22:01:24 -05:00

479 lines
14 KiB
TypeScript

import { app, BrowserWindow, ipcMain, nativeImage, protocol, safeStorage } from 'electron';
import * as path from 'path';
import * as url from 'url';
import { fileURLToPath } from 'url';
import * as fs from 'fs';
import { getDatabaseService } from './database/database.service.js';
import { getSecureStorage } from './storage/SecureStorage.js';
import { getUserEncryptionKey, setUserEncryptionKey, hasUserEncryptionKey } 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 app:// comme standard (avant app.whenReady)
if (!isDev) {
protocol.registerSchemesAsPrivileged([
{
scheme: 'app',
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,
// Ne pas définir icon sur macOS - utilise l'icône de l'app bundle
...(process.platform !== 'darwin' && { icon: iconPath }),
webPreferences: {
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
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('app://./login/login/index.html');
}
loginWindow.once('ready-to-show', () => {
loginWindow?.show();
});
loginWindow.on('closed', () => {
loginWindow = null;
});
}
function createMainWindow(): void {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
// Ne pas définir icon sur macOS - utilise l'icône de l'app bundle
...(process.platform !== 'darwin' && { icon: iconPath }),
webPreferences: {
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
show: false,
});
if (isDev) {
const devPort = process.env.PORT || '4000';
mainWindow.loadURL(`http://localhost:${devPort}`);
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadURL('app://./index.html');
}
mainWindow.once('ready-to-show', () => {
mainWindow?.show();
});
mainWindow.on('closed', () => {
mainWindow = null;
});
}
// IPC Handlers pour la gestion du token (OS-encrypted storage)
ipcMain.handle('get-token', () => {
const storage = getSecureStorage();
const token = storage.get('authToken', null);
console.log('[GetToken] Token requested, exists:', !!token);
console.log('[GetToken] Storage has authToken:', storage.has('authToken'));
return token;
});
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, userId: string) => {
console.log('[InitUser] Initializing user:', userId);
const storage = getSecureStorage();
storage.set('userId', userId);
storage.set('lastUserId', userId); // Save for offline mode
try {
let encryptionKey: string | null = null;
if (!hasUserEncryptionKey(userId)) {
encryptionKey = generateUserEncryptionKey(userId);
console.log('[InitUser] Generated new encryption key for user');
console.log('[InitUser] Key generated:', encryptionKey ? `${encryptionKey.substring(0, 10)}...` : 'UNDEFINED');
if (!encryptionKey) {
console.error('[InitUser] CRITICAL: Generated key is undefined, blocking operation');
throw new Error('Failed to generate encryption key');
}
setUserEncryptionKey(userId, encryptionKey);
// Verify the key was saved
const savedKey = getUserEncryptionKey(userId);
console.log('[InitUser] Key verification after save:', savedKey ? `${savedKey.substring(0, 10)}...` : 'UNDEFINED');
if (!savedKey) {
console.error('[InitUser] CRITICAL: Key was not saved correctly, blocking operation');
throw new Error('Failed to save encryption key');
}
} else {
encryptionKey = getUserEncryptionKey(userId);
console.log('[InitUser] Using existing encryption key:', encryptionKey ? `${encryptionKey.substring(0, 10)}...` : 'UNDEFINED');
if (!encryptionKey) {
console.error('[InitUser] CRITICAL: Existing key is undefined, regenerating');
const { generateUserEncryptionKey } = await import('./database/encryption.js');
encryptionKey = generateUserEncryptionKey(userId);
setUserEncryptionKey(userId, encryptionKey);
}
}
// Save userId and lastUserId to disk now that we have everything
// This is the ONLY additional save after login
if (safeStorage.isEncryptionAvailable()) {
storage.save();
console.log('[InitUser] User ID and lastUserId saved to disk (encrypted)');
} else {
console.error('[InitUser] WARNING: Cannot save user ID - encryption not available');
}
return { success: true, keyCreated: !hasUserEncryptionKey(userId) };
} catch (error) {
console.error('[InitUser] Error managing encryption key:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
});
ipcMain.on('login-success', async (_event, token: string) => {
console.log('[Login] Received token, setting in storage');
const storage = getSecureStorage();
storage.set('authToken', token);
console.log('[Login] Token set in cache, has authToken:', storage.has('authToken'));
// Note: userId will be set later when we get user info from server
if (loginWindow) {
loginWindow.close();
}
createMainWindow();
// Save AFTER mainWindow is created (fixes macOS safeStorage issue)
setTimeout(async () => {
try {
if (safeStorage.isEncryptionAvailable()) {
storage.save();
console.log('[Login] Auth token saved to disk (encrypted)');
} else {
console.error('[Login] Encryption still not available after window creation');
// Try one more time after another delay
setTimeout(() => {
if (safeStorage.isEncryptionAvailable()) {
storage.save();
console.log('[Login] Auth token saved to disk (encrypted) - second attempt');
} else {
console.error('[Login] CRITICAL: Cannot encrypt credentials');
}
}, 1000);
}
} catch (error) {
console.error('[Login] Error saving auth data:', error);
}
}, 500);
});
ipcMain.on('logout', () => {
try {
const storage = getSecureStorage();
// Debug: Check what's in storage before deletion
console.log('[Logout] Before deletion - authToken exists:', storage.has('authToken'));
console.log('[Logout] Before deletion - userId exists:', storage.has('userId'));
storage.delete('authToken');
storage.delete('userId');
storage.delete('userLang');
// Debug: Check what's in storage after deletion
console.log('[Logout] After deletion - authToken exists:', storage.has('authToken'));
console.log('[Logout] After deletion - userId exists:', storage.has('userId'));
// IMPORTANT: Save to disk to persist the deletions
storage.save();
console.log('[Logout] Cleared auth data from disk');
} catch (error) {
console.error('[Logout] Error clearing storage:', error);
}
try {
const db = 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';
// Check if user already exists in local DB
try {
UserRepo.fetchUserInfos(data.userId, lang);
// User exists, no need to sync
console.log(`[DB] User ${data.userId} already exists in local DB, skipping sync`);
return true;
} catch (error) {
// User doesn't exist, create it
console.log(`[DB] User ${data.userId} not found, creating in local DB`);
await User.addUser(
data.userId,
data.firstName,
data.lastName,
data.username,
data.email,
'',
lang
);
console.log(`[DB] User ${data.userId} synced successfully`);
return true;
}
} catch (error) {
console.error('[DB] Failed to sync user:', error);
throw error;
}
});
// ========== DATABASE IPC HANDLERS ==========
/**
* Generate user encryption key
*/
ipcMain.handle('generate-encryption-key', async (_event, userId: string) => {
try {
const key = generateUserEncryptionKey(userId);
return { success: true, key };
} catch (error) {
console.error('Failed to generate encryption key:', 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 = getSecureStorage();
const key = storage.get(`encryptionKey-${userId}`, null);
return key;
});
/**
* Store user encryption key (OS-encrypted storage)
*/
ipcMain.handle('set-user-encryption-key', (_event, userId: string, encryptionKey: string) => {
const storage = getSecureStorage();
storage.set(`encryptionKey-${userId}`, encryptionKey);
return true;
});
/**
* Initialize database for user
*/
ipcMain.handle('db-initialize', (_event, userId: string, encryptionKey: string) => {
try {
const db = getDatabaseService();
db.initialize(userId, encryptionKey);
return { success: true };
} catch (error) {
console.error('Failed to initialize database:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
});
// NOTE: All database IPC handlers have been moved to ./ipc/book.ipc.ts
// and use the new createHandler() pattern with auto userId/lang injection
app.whenReady().then(() => {
// Enregistrer le protocole custom app:// pour servir les fichiers depuis out/
if (!isDev) {
const outPath = path.join(process.resourcesPath, 'app.asar.unpacked/out');
protocol.handle('app', async (request) => {
// Enlever app:// et ./
let filePath = request.url.replace('app://', '').replace(/^\.\//, '');
const fullPath = path.normalize(path.join(outPath, filePath));
// Vérifier que le chemin est bien dans out/ (sécurité)
if (!fullPath.startsWith(outPath)) {
console.error('Security: Attempted to access file outside out/:', fullPath);
return new Response('Forbidden', { status: 403 });
}
try {
const data = await fs.promises.readFile(fullPath);
const ext = 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' }
});
} catch (error) {
console.error('Failed to load:', fullPath, 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 = getSecureStorage();
const token = storage.get('authToken');
const userId = storage.get('userId');
const offlineMode = storage.get<boolean>('offlineMode', false);
const lastUserId = storage.get<string>('lastUserId');
const hasPin = !!storage.get<string>(`pin-${lastUserId}`);
console.log('[Startup] Token exists:', !!token);
console.log('[Startup] UserId exists:', !!userId);
console.log('[Startup] Offline mode:', offlineMode);
console.log('[Startup] Has PIN:', hasPin);
if (token) {
createMainWindow();
} else {
createLoginWindow();
}
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
const storage = getSecureStorage();
const token = storage.get('authToken');
if (token) {
createMainWindow();
} else {
createLoginWindow();
}
}
});
});
app.on('window-all-closed', () => {
// Quitter l'application quand toutes les fenêtres sont fermées
app.quit();
});