Integrate offline support and improve error handling across app

- Add `OfflineContext` to manage offline state and interactions within components.
- Refactor session logic in `ScribeControllerBar` and `page.tsx` to handle offline scenarios (e.g., check connectivity before enabling GPT features).
- Enhance offline PIN setup and verification with better flow and error messaging.
- Optimize database IPC handlers to initialize and sync data in offline mode.
- Refactor code to clean up redundant logs and ensure stricter typings.
- Improve consistency and structure in handling online and offline operations for smoother user experience.
This commit is contained in:
natreex
2025-11-26 15:25:53 -05:00
parent 5cceceaea9
commit ac95e00127
10 changed files with 95 additions and 159 deletions

View File

@@ -259,7 +259,7 @@ export default class BookRepo {
let result:RunResult
try {
const db: Database = System.getDb();
result = db.run('INSERT INTO erit_books (book_id,type,author_id, title, hashed_title, sub_title, hashed_sub_title, summary, serie_id, desired_release_date, erit_books.desired_word_count) VALUES (?,?,?,?,?,?,?,?,?,?,?)', [bookId, type, userId, encryptedTitle, hashedTitle, encryptedSubTitle, hashedSubTitle, encryptedSummary, serie, publicationDate ? publicationDate : null, desiredWordCount]);
result = db.run('INSERT INTO erit_books (book_id, type, author_id, title, hashed_title, sub_title, hashed_sub_title, summary, serie_id, desired_release_date, desired_word_count) VALUES (?,?,?,?,?,?,?,?,?,?,?)', [bookId, type, userId, encryptedTitle, hashedTitle, encryptedSubTitle, hashedSubTitle, encryptedSummary, serie, publicationDate ? publicationDate : null, desiredWordCount]);
} catch (err: unknown) {
if (err instanceof Error) {
console.error(`DB Error: ${err.message}`);

View File

@@ -97,7 +97,7 @@ ipcMain.handle('db:book:books', createHandler<void, BookProps[]>(
// GET /book/:id - Get single book
ipcMain.handle('db:book:bookBasicInformation', createHandler<string, BookProps>(
async function(userId: string, bookId: string, lang: 'fr' | 'en'):Promise<BookProps> {
return await Book.getBook(bookId, userId);
return await Book.getBook(userId, bookId);
}
)
);

View File

@@ -2,6 +2,7 @@ import { ipcMain } from 'electron';
import { createHandler } from '../database/LocalSystem.js';
import * as bcrypt from 'bcrypt';
import { getSecureStorage } from '../storage/SecureStorage.js';
import { getDatabaseService } from '../database/database.service.js';
interface SetPinData {
pin: string;
@@ -62,6 +63,17 @@ ipcMain.handle('offline:pin:verify', async (_event, data: VerifyPinData) => {
if (isValid) {
// Set userId for session
storage.set('userId', lastUserId);
// Initialize database for offline use
const encryptionKey = storage.get<string>(`encryptionKey-${lastUserId}`);
if (encryptionKey) {
const db = getDatabaseService();
db.initialize(lastUserId, encryptionKey);
} else {
console.error('[Offline] No encryption key found for user');
return { success: false, error: 'No encryption key found' };
}
console.log('[Offline] PIN verified, user authenticated locally');
return {
success: true,

View File

@@ -1,12 +1,11 @@
import { app, BrowserWindow, ipcMain, nativeImage, protocol, safeStorage } from 'electron';
import {app, BrowserWindow, ipcMain, nativeImage, protocol, safeStorage} from 'electron';
import * as path from 'path';
import * as url from 'url';
import { fileURLToPath } 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 {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';
@@ -58,7 +57,6 @@ function createLoginWindow(): void {
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,
@@ -91,7 +89,6 @@ 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,
@@ -121,11 +118,8 @@ function createMainWindow(): void {
// 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;
const storage:SecureStorage = getSecureStorage();
return storage.get('authToken', null);
});
ipcMain.handle('set-token', (_event, token: string) => {
@@ -154,11 +148,9 @@ ipcMain.handle('set-lang', (_event, lang: 'fr' | 'en') => {
// 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();
const storage:SecureStorage = getSecureStorage();
storage.set('userId', userId);
storage.set('lastUserId', userId); // Save for offline mode
storage.set('lastUserId', userId);
try {
let encryptionKey: string | null = null;
@@ -166,22 +158,16 @@ ipcMain.handle('init-user', async (_event, userId: string) => {
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);
const savedKey:string = 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 {
@@ -195,9 +181,7 @@ ipcMain.handle('init-user', async (_event, userId: string) => {
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)');
@@ -219,28 +203,21 @@ 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');
}
@@ -252,31 +229,21 @@ ipcMain.on('login-success', async (_event, token: string) => {
}, 500);
});
ipcMain.on('logout', () => {
ipcMain.on('logout', ():void => {
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'));
const storage:SecureStorage = getSecureStorage();
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();
const db:DatabaseService = getDatabaseService();
db.close();
} catch (error) {
console.error('[Logout] Error closing database:', error);
@@ -306,17 +273,10 @@ ipcMain.handle('db:user:sync', async (_event, data: SyncUserData): Promise<boole
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,
@@ -326,7 +286,6 @@ ipcMain.handle('db:user:sync', async (_event, data: SyncUserData): Promise<boole
'',
lang
);
console.log(`[DB] User ${data.userId} synced successfully`);
return true;
}
} catch (error) {
@@ -335,14 +294,12 @@ ipcMain.handle('db:user:sync', async (_event, data: SyncUserData): Promise<boole
}
});
// ========== DATABASE IPC HANDLERS ==========
/**
* Generate user encryption key
*/
ipcMain.handle('generate-encryption-key', async (_event, userId: string) => {
try {
const key = generateUserEncryptionKey(userId);
const key:string = generateUserEncryptionKey(userId);
return { success: true, key };
} catch (error) {
console.error('Failed to generate encryption key:', error);
@@ -357,16 +314,15 @@ ipcMain.handle('generate-encryption-key', async (_event, userId: string) => {
* 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;
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 = getSecureStorage();
const storage:SecureStorage = getSecureStorage();
storage.set(`encryptionKey-${userId}`, encryptionKey);
return true;
});
@@ -376,7 +332,7 @@ ipcMain.handle('set-user-encryption-key', (_event, userId: string, encryptionKey
*/
ipcMain.handle('db-initialize', (_event, userId: string, encryptionKey: string) => {
try {
const db = getDatabaseService();
const db:DatabaseService = getDatabaseService();
db.initialize(userId, encryptionKey);
return { success: true };
} catch (error) {
@@ -388,28 +344,21 @@ ipcMain.handle('db-initialize', (_event, userId: string, encryptionKey: string)
}
});
// 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é)
let filePath:string = request.url.replace('app://', '').replace(/^\.\//, '');
const fullPath:string = path.normalize(path.join(outPath, filePath));
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 ext:string = path.extname(fullPath).toLowerCase();
const mimeTypes: Record<string, string> = {
'.html': 'text/html',
'.css': 'text/css',
@@ -428,7 +377,6 @@ app.whenReady().then(() => {
headers: { 'Content-Type': mimeTypes[ext] || 'application/octet-stream' }
});
} catch (error) {
console.error('Failed to load:', fullPath, error);
return new Response('Not found', { status: 404 });
}
});