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.
This commit is contained in:
125
app/page.tsx
125
app/page.tsx
@@ -34,6 +34,7 @@ import {AIUsageContext} from "@/context/AIUsageContext";
|
||||
import OfflineProvider from "@/context/OfflineProvider";
|
||||
import OfflineContext from "@/context/OfflineContext";
|
||||
import OfflinePinSetup from "@/components/offline/OfflinePinSetup";
|
||||
import OfflinePinVerify from "@/components/offline/OfflinePinVerify";
|
||||
|
||||
const messagesMap = {
|
||||
fr: frMessages,
|
||||
@@ -72,6 +73,7 @@ function ScribeContent() {
|
||||
const [isTermsAccepted, setIsTermsAccepted] = useState<boolean>(false);
|
||||
const [homeStepsGuide, setHomeStepsGuide] = useState<boolean>(false);
|
||||
const [showPinSetup, setShowPinSetup] = useState<boolean>(false);
|
||||
const [showPinVerify, setShowPinVerify] = useState<boolean>(false);
|
||||
|
||||
const homeSteps: GuideStep[] = [
|
||||
{
|
||||
@@ -188,6 +190,52 @@ function ScribeContent() {
|
||||
checkPinSetup();
|
||||
}, [session.isConnected]); // Run when session connection status changes
|
||||
|
||||
async function handlePinVerifySuccess(userId: string): Promise<void> {
|
||||
console.log('[OfflinePin] PIN verified successfully for user:', userId);
|
||||
|
||||
try {
|
||||
// Initialize database with user's encryption key
|
||||
if (window.electron) {
|
||||
const encryptionKey:string|null = await window.electron.getUserEncryptionKey(userId);
|
||||
if (encryptionKey) {
|
||||
await window.electron.dbInitialize(userId, encryptionKey);
|
||||
|
||||
// Load user from local DB
|
||||
const localUser = await window.electron.invoke('db:user:info');
|
||||
if (localUser && localUser.success) {
|
||||
// Use local data and continue in offline mode
|
||||
setSession({
|
||||
isConnected: true,
|
||||
user: localUser.data,
|
||||
accessToken: 'offline', // Special offline token
|
||||
});
|
||||
setShowPinVerify(false);
|
||||
setCurrentCredits(localUser.data.creditsBalance || 0);
|
||||
setAmountSpent(localUser.data.aiUsage || 0);
|
||||
|
||||
console.log('[OfflinePin] Running in offline mode');
|
||||
} else {
|
||||
errorMessage(t("homePage.errors.localDataError"));
|
||||
if (window.electron) {
|
||||
//window.electron.logout();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errorMessage(t("homePage.errors.encryptionKeyError"));
|
||||
if (window.electron) {
|
||||
//window.electron.logout();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[OfflinePin] Error initializing offline mode:', error);
|
||||
errorMessage(t("homePage.errors.offlineModeError"));
|
||||
if (window.electron) {
|
||||
//window.electron.logout();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleHomeTour(): Promise<void> {
|
||||
try {
|
||||
const response: boolean = await System.authPostToServer<boolean>('logs/tour', {
|
||||
@@ -211,7 +259,6 @@ function ScribeContent() {
|
||||
}
|
||||
|
||||
async function checkAuthentification(): Promise<void> {
|
||||
// Essayer de récupérer le token depuis electron-store en priorité
|
||||
let token: string | null = null;
|
||||
|
||||
if (typeof window !== 'undefined' && window.electron) {
|
||||
@@ -222,11 +269,6 @@ function ScribeContent() {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback sur les cookies si pas d'Electron
|
||||
if (!token) {
|
||||
token = System.getCookie('token');
|
||||
}
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const user: UserProps = await System.authGetQueryToServer<UserProps>('user/infos', token, locale);
|
||||
@@ -240,6 +282,8 @@ function ScribeContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('user: ' , user);
|
||||
|
||||
// Initialize user in Electron (sets userId and creates/gets encryption key)
|
||||
if (window.electron && user.id) {
|
||||
try {
|
||||
@@ -306,35 +350,19 @@ function ScribeContent() {
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.log('[Auth] Server error, checking offline mode...');
|
||||
|
||||
// Check if we can use offline mode
|
||||
if (window.electron) {
|
||||
try {
|
||||
// Check offline mode status
|
||||
const offlineStatus = await window.electron.invoke('offline:mode:get');
|
||||
const offlineStatus = await window.electron.offlineModeGet();
|
||||
|
||||
// If offline mode is enabled and we have local data
|
||||
if (offlineStatus.enabled && offlineStatus.hasPin) {
|
||||
console.log('[Auth] Offline mode enabled, loading local user data');
|
||||
|
||||
// Try to load user from local DB
|
||||
try {
|
||||
const localUser = await window.electron.invoke('db:user:info');
|
||||
if (localUser && localUser.success) {
|
||||
// Use local data
|
||||
setSession({
|
||||
isConnected: true,
|
||||
user: localUser.data,
|
||||
accessToken: 'offline', // Special offline token
|
||||
});
|
||||
setIsLoading(false);
|
||||
|
||||
// Show offline mode notification
|
||||
console.log('[Auth] Running in offline mode');
|
||||
return;
|
||||
}
|
||||
} catch (dbError) {
|
||||
console.error('[Auth] Failed to load local user:', dbError);
|
||||
if (offlineStatus.hasPin && offlineStatus.lastUserId) {
|
||||
console.log('[Auth] Server unreachable but PIN configured, showing PIN verification');
|
||||
setShowPinVerify(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
} else {
|
||||
if (window.electron) {
|
||||
await window.electron.removeToken();
|
||||
window.electron.logout();
|
||||
}
|
||||
}
|
||||
} catch (offlineError) {
|
||||
@@ -342,23 +370,28 @@ function ScribeContent() {
|
||||
}
|
||||
}
|
||||
|
||||
// If not in offline mode or failed to load local data, show error and logout
|
||||
// If not in offline mode or no PIN configured, show error and logout
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("homePage.errors.authenticationError"));
|
||||
}
|
||||
// Token invalide/erreur auth, supprimer et logout
|
||||
if (window.electron) {
|
||||
await window.electron.removeToken();
|
||||
window.electron.logout();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Pas de token - en Electron cela ne devrait jamais arriver
|
||||
// car main.ts vérifie le token avant d'ouvrir mainWindow
|
||||
// Si on arrive ici, c'est une erreur - fermer et ouvrir login
|
||||
if (window.electron) {
|
||||
try {
|
||||
const offlineStatus = await window.electron.offlineModeGet();
|
||||
|
||||
if (offlineStatus.hasPin && offlineStatus.lastUserId) {
|
||||
console.log('[Auth] No token but PIN configured, showing PIN verification for offline mode');
|
||||
setShowPinVerify(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Auth] Error checking offline mode:', error);
|
||||
}
|
||||
|
||||
window.electron.logout();
|
||||
}
|
||||
}
|
||||
@@ -475,6 +508,16 @@ function ScribeContent() {
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
showPinVerify && window.electron && (
|
||||
<OfflinePinVerify
|
||||
onSuccess={handlePinVerifySuccess}
|
||||
onCancel={():void => {
|
||||
//window.electron.logout();
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</AIUsageContext.Provider>
|
||||
</ChapterContext.Provider>
|
||||
</BookContext.Provider>
|
||||
|
||||
80
context/AlertProvider.tsx
Normal file
80
context/AlertProvider.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import type {Context, Dispatch, JSX, ReactNode, SetStateAction} from 'react';
|
||||
import {createContext, useCallback, useState} from 'react';
|
||||
import AlertStack from '@/components/AlertStack';
|
||||
|
||||
export type AlertType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
export interface Alert {
|
||||
id: string;
|
||||
type: AlertType;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AlertContextProps {
|
||||
successMessage: (message: string) => void;
|
||||
errorMessage: (message: string) => void;
|
||||
infoMessage: (message: string) => void;
|
||||
warningMessage: (message: string) => void;
|
||||
}
|
||||
|
||||
interface AlertProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AlertContext: Context<AlertContextProps> = createContext<AlertContextProps>({
|
||||
successMessage: (_message: string): void => {
|
||||
},
|
||||
errorMessage: (_message: string): void => {
|
||||
},
|
||||
infoMessage: (_message: string): void => {
|
||||
},
|
||||
warningMessage: (_message: string): void => {
|
||||
},
|
||||
});
|
||||
|
||||
export function AlertProvider({children}: AlertProviderProps): JSX.Element {
|
||||
const [alerts, setAlerts]: [Alert[], Dispatch<SetStateAction<Alert[]>>] = useState<Alert[]>([]);
|
||||
|
||||
const addAlert: (type: AlertType, message: string) => void = useCallback((type: AlertType, message: string): void => {
|
||||
const id: string = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
||||
const newAlert: Alert = {id, type, message};
|
||||
|
||||
setAlerts((prev: Alert[]): Alert[] => [...prev, newAlert]);
|
||||
}, []);
|
||||
|
||||
const removeAlert: (id: string) => void = useCallback((id: string): void => {
|
||||
setAlerts((prev: Alert[]): Alert[] => prev.filter((alert: Alert): boolean => alert.id !== id));
|
||||
}, []);
|
||||
|
||||
const successMessage: (message: string) => void = useCallback((message: string): void => {
|
||||
addAlert('success', message);
|
||||
}, [addAlert]);
|
||||
|
||||
const errorMessage: (message: string) => void = useCallback((message: string): void => {
|
||||
addAlert('error', message);
|
||||
}, [addAlert]);
|
||||
|
||||
const infoMessage: (message: string) => void = useCallback((message: string): void => {
|
||||
addAlert('info', message);
|
||||
}, [addAlert]);
|
||||
|
||||
const warningMessage: (message: string) => void = useCallback((message: string): void => {
|
||||
addAlert('warning', message);
|
||||
}, [addAlert]);
|
||||
|
||||
return (
|
||||
<AlertContext.Provider
|
||||
value={{
|
||||
successMessage,
|
||||
errorMessage,
|
||||
infoMessage,
|
||||
warningMessage,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<AlertStack alerts={alerts} onClose={removeAlert}/>
|
||||
</AlertContext.Provider>
|
||||
);
|
||||
}
|
||||
13
context/BookContext.ts
Executable file
13
context/BookContext.ts
Executable file
@@ -0,0 +1,13 @@
|
||||
import {Context, createContext, Dispatch, SetStateAction} from "react";
|
||||
import {BookProps} from "@/lib/models/Book";
|
||||
|
||||
export interface BookContextProps {
|
||||
book: BookProps | null,
|
||||
setBook?: Dispatch<SetStateAction<BookProps | null>>
|
||||
}
|
||||
|
||||
export const BookContext: Context<BookContextProps> = createContext<BookContextProps>({
|
||||
book: null,
|
||||
setBook: () => {
|
||||
}
|
||||
})
|
||||
14
context/ChapterContext.ts
Executable file
14
context/ChapterContext.ts
Executable file
@@ -0,0 +1,14 @@
|
||||
import {ChapterProps} from "@/lib/models/Chapter";
|
||||
import {createContext, Dispatch, SetStateAction} from "react";
|
||||
|
||||
|
||||
export interface ChapterContextProps {
|
||||
chapter: ChapterProps | undefined,
|
||||
setChapter: Dispatch<SetStateAction<ChapterProps | undefined>>
|
||||
}
|
||||
|
||||
export const ChapterContext = createContext<ChapterContextProps>({
|
||||
chapter: undefined,
|
||||
setChapter: () => {
|
||||
}
|
||||
})
|
||||
10
context/EditorContext.ts
Executable file
10
context/EditorContext.ts
Executable file
@@ -0,0 +1,10 @@
|
||||
import {createContext} from "react";
|
||||
import {Editor} from "@tiptap/core";
|
||||
|
||||
export interface EditorContextProps {
|
||||
editor: Editor | null
|
||||
}
|
||||
|
||||
export const EditorContext = createContext<EditorContextProps>({
|
||||
editor: null
|
||||
});
|
||||
14
context/LangContext.ts
Normal file
14
context/LangContext.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import {Context, createContext, Dispatch, SetStateAction} from "react";
|
||||
|
||||
export type SupportedLocale = 'fr' | 'en';
|
||||
|
||||
export interface LangContextProps {
|
||||
lang: SupportedLocale;
|
||||
setLang: Dispatch<SetStateAction<SupportedLocale>>;
|
||||
}
|
||||
|
||||
export const LangContext: Context<LangContextProps> = createContext<LangContextProps>({
|
||||
lang: 'fr',
|
||||
setLang: (): void => {
|
||||
}
|
||||
});
|
||||
35
context/OfflineContext.ts
Normal file
35
context/OfflineContext.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createContext, Dispatch, SetStateAction } from 'react';
|
||||
|
||||
export interface OfflineMode {
|
||||
isManuallyOffline: boolean;
|
||||
isNetworkOnline: boolean;
|
||||
isOffline: boolean;
|
||||
isDatabaseInitialized: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface OfflineContextType {
|
||||
offlineMode: OfflineMode;
|
||||
setOfflineMode: Dispatch<SetStateAction<OfflineMode>>;
|
||||
toggleOfflineMode: () => void;
|
||||
initializeDatabase: (userId: string, encryptionKey?: string) => Promise<boolean>;
|
||||
isCurrentlyOffline: () => boolean;
|
||||
}
|
||||
|
||||
export const defaultOfflineMode: OfflineMode = {
|
||||
isManuallyOffline: false,
|
||||
isNetworkOnline: typeof navigator !== 'undefined' ? navigator.onLine : true,
|
||||
isOffline: false,
|
||||
isDatabaseInitialized: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
const OfflineContext = createContext<OfflineContextType>({
|
||||
offlineMode: defaultOfflineMode,
|
||||
setOfflineMode: () => {},
|
||||
toggleOfflineMode: () => {},
|
||||
initializeDatabase: async () => false,
|
||||
isCurrentlyOffline: () => false
|
||||
});
|
||||
|
||||
export default OfflineContext;
|
||||
129
context/OfflineProvider.tsx
Normal file
129
context/OfflineProvider.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback, ReactNode } from 'react';
|
||||
import OfflineContext, { OfflineMode, defaultOfflineMode } from './OfflineContext';
|
||||
|
||||
interface OfflineProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function OfflineProvider({ children }: OfflineProviderProps) {
|
||||
const [offlineMode, setOfflineMode] = useState<OfflineMode>(defaultOfflineMode);
|
||||
|
||||
const initializeDatabase = useCallback(async (userId: string, encryptionKey?: string): Promise<boolean> => {
|
||||
try {
|
||||
if (typeof window === 'undefined' || !(window as any).electron) {
|
||||
console.warn('Not running in Electron, offline mode not available');
|
||||
return false;
|
||||
}
|
||||
|
||||
let userKey = encryptionKey;
|
||||
if (!userKey) {
|
||||
const storedKey = await (window as any).electron.getUserEncryptionKey(userId);
|
||||
if (storedKey) {
|
||||
userKey = storedKey;
|
||||
} else {
|
||||
const keyResult = await (window as any).electron.generateEncryptionKey(userId);
|
||||
if (!keyResult.success) {
|
||||
throw new Error(keyResult.error || 'Failed to generate encryption key');
|
||||
}
|
||||
userKey = keyResult.key;
|
||||
await (window as any).electron.setUserEncryptionKey(userId, userKey);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await (window as any).electron.dbInitialize(userId, userKey);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to initialize database');
|
||||
}
|
||||
|
||||
setOfflineMode(prev => ({
|
||||
...prev,
|
||||
isDatabaseInitialized: true,
|
||||
error: null
|
||||
}));
|
||||
|
||||
console.log('Database initialized successfully for user:', userId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize database:', error);
|
||||
setOfflineMode(prev => ({
|
||||
...prev,
|
||||
isDatabaseInitialized: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to initialize database'
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleOfflineMode = useCallback(() => {
|
||||
setOfflineMode(prev => {
|
||||
const newManuallyOffline = !prev.isManuallyOffline;
|
||||
const newIsOffline = newManuallyOffline || !prev.isNetworkOnline;
|
||||
|
||||
console.log('Toggle offline mode:', {
|
||||
wasManuallyOffline: prev.isManuallyOffline,
|
||||
nowManuallyOffline: newManuallyOffline,
|
||||
wasOffline: prev.isOffline,
|
||||
nowOffline: newIsOffline,
|
||||
networkOnline: prev.isNetworkOnline
|
||||
});
|
||||
|
||||
return {
|
||||
...prev,
|
||||
isManuallyOffline: newManuallyOffline,
|
||||
isOffline: newIsOffline
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isCurrentlyOffline = useCallback((): boolean => {
|
||||
return offlineMode.isOffline;
|
||||
}, [offlineMode.isOffline]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
setOfflineMode(prev => {
|
||||
const newIsOffline = prev.isManuallyOffline;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
isNetworkOnline: true,
|
||||
isOffline: newIsOffline
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleOffline = () => {
|
||||
setOfflineMode(prev => {
|
||||
return {
|
||||
...prev,
|
||||
isNetworkOnline: false,
|
||||
isOffline: true
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
offlineMode,
|
||||
setOfflineMode,
|
||||
toggleOfflineMode,
|
||||
initializeDatabase,
|
||||
isCurrentlyOffline
|
||||
};
|
||||
|
||||
return (
|
||||
<OfflineContext.Provider value={value}>
|
||||
{children}
|
||||
</OfflineContext.Provider>
|
||||
);
|
||||
}
|
||||
17
context/SessionContext.ts
Executable file
17
context/SessionContext.ts
Executable file
@@ -0,0 +1,17 @@
|
||||
import {SessionProps} from "@/lib/models/Session";
|
||||
import {Context, createContext, Dispatch, SetStateAction} from "react";
|
||||
|
||||
export interface SessionContextProps {
|
||||
session: SessionProps;
|
||||
setSession: Dispatch<SetStateAction<SessionProps>>;
|
||||
}
|
||||
|
||||
export const SessionContext: Context<SessionContextProps> = createContext<SessionContextProps>({
|
||||
session: {
|
||||
isConnected: false,
|
||||
accessToken: "",
|
||||
user: null,
|
||||
},
|
||||
setSession: () => {
|
||||
},
|
||||
})
|
||||
12
context/SettingBookContext.ts
Executable file
12
context/SettingBookContext.ts
Executable file
@@ -0,0 +1,12 @@
|
||||
import {Context, createContext, Dispatch, SetStateAction} from "react";
|
||||
|
||||
export interface SettingBookContextProps {
|
||||
bookSettingId: string;
|
||||
setBookSettingId: Dispatch<SetStateAction<string>>
|
||||
}
|
||||
|
||||
export const SettingBookContext: Context<SettingBookContextProps> = createContext<SettingBookContextProps>({
|
||||
bookSettingId: '',
|
||||
setBookSettingId: (): void => {
|
||||
}
|
||||
})
|
||||
25
context/UserContext.ts
Executable file
25
context/UserContext.ts
Executable file
@@ -0,0 +1,25 @@
|
||||
import {UserProps} from "@/lib/models/User";
|
||||
import {Context, createContext} from "react";
|
||||
|
||||
export interface UserContextProps {
|
||||
user: UserProps
|
||||
}
|
||||
|
||||
export const UserContext: Context<UserContextProps> = createContext<UserContextProps>({
|
||||
user: {
|
||||
id: '',
|
||||
name: '',
|
||||
lastName: '',
|
||||
username: '',
|
||||
writingLang: 0,
|
||||
writingLevel: 0,
|
||||
ritePoints: 0,
|
||||
groupId: 0,
|
||||
aiUsage: 0,
|
||||
apiKeys: {
|
||||
openai: false,
|
||||
anthropic: false,
|
||||
gemini: false,
|
||||
}
|
||||
}
|
||||
})
|
||||
10
context/WorldContext.ts
Executable file
10
context/WorldContext.ts
Executable file
@@ -0,0 +1,10 @@
|
||||
import {WorldProps} from "@/lib/models/World";
|
||||
import {createContext, Dispatch, SetStateAction} from "react";
|
||||
|
||||
export interface WorldContextProps {
|
||||
worlds: WorldProps[];
|
||||
setWorlds: Dispatch<SetStateAction<WorldProps[]>>;
|
||||
selectedWorldIndex: number;
|
||||
}
|
||||
|
||||
export const WorldContext = createContext<WorldContextProps>({} as WorldContextProps);
|
||||
2
electron.d.ts
vendored
2
electron.d.ts
vendored
@@ -41,7 +41,7 @@ export interface IElectronAPI {
|
||||
offlinePinSet: (pin: string) => Promise<{ success: boolean; error?: string }>;
|
||||
offlinePinVerify: (pin: string) => Promise<{ success: boolean; userId?: string; error?: string }>;
|
||||
offlineModeSet: (enabled: boolean, syncInterval?: number) => Promise<{ success: boolean }>;
|
||||
offlineModeGet: () => Promise<{ enabled: boolean; syncInterval: number; hasPin: boolean }>;
|
||||
offlineModeGet: () => Promise<{ enabled: boolean; syncInterval: number; hasPin: boolean; lastUserId?: string }>;
|
||||
offlineSyncCheck: () => Promise<{ shouldSync: boolean; daysSinceSync?: number; syncInterval?: number }>;
|
||||
}
|
||||
|
||||
|
||||
150
electron/ipc/offline.ipc.ts
Normal file
150
electron/ipc/offline.ipc.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { createHandler } from '../database/LocalSystem.js';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { getSecureStorage } from '../storage/SecureStorage.js';
|
||||
|
||||
interface SetPinData {
|
||||
pin: string;
|
||||
}
|
||||
|
||||
interface VerifyPinData {
|
||||
pin: string;
|
||||
}
|
||||
|
||||
interface OfflineModeData {
|
||||
enabled: boolean;
|
||||
syncInterval?: number; // days
|
||||
}
|
||||
|
||||
ipcMain.handle('offline:pin:set', async (_event, data: SetPinData) => {
|
||||
try {
|
||||
const storage = getSecureStorage();
|
||||
const userId = storage.get<string>('userId');
|
||||
|
||||
if (!userId) {
|
||||
return { success: false, error: 'No user logged in' };
|
||||
}
|
||||
|
||||
// Hash the PIN
|
||||
const hashedPin = await bcrypt.hash(data.pin, 10);
|
||||
|
||||
// Store hashed PIN
|
||||
storage.set(`pin-${userId}`, hashedPin);
|
||||
storage.save();
|
||||
|
||||
console.log('[Offline] PIN set for user');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[Offline] Error setting PIN:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
});
|
||||
|
||||
// Verify PIN for offline access
|
||||
ipcMain.handle('offline:pin:verify', async (_event, data: VerifyPinData) => {
|
||||
try {
|
||||
const storage = getSecureStorage();
|
||||
|
||||
// Try to get last known userId
|
||||
const lastUserId = storage.get<string>('lastUserId');
|
||||
if (!lastUserId) {
|
||||
return { success: false, error: 'No offline account found' };
|
||||
}
|
||||
|
||||
const hashedPin = storage.get<string>(`pin-${lastUserId}`);
|
||||
if (!hashedPin) {
|
||||
return { success: false, error: 'No PIN configured' };
|
||||
}
|
||||
|
||||
// Verify PIN
|
||||
const isValid = await bcrypt.compare(data.pin, hashedPin);
|
||||
|
||||
if (isValid) {
|
||||
// Set userId for session
|
||||
storage.set('userId', lastUserId);
|
||||
console.log('[Offline] PIN verified, user authenticated locally');
|
||||
return {
|
||||
success: true,
|
||||
userId: lastUserId
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: 'Invalid PIN' };
|
||||
} catch (error) {
|
||||
console.error('[Offline] Error verifying PIN:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
});
|
||||
|
||||
// Set offline mode preference
|
||||
ipcMain.handle('offline:mode:set', (_event, data: OfflineModeData) => {
|
||||
try {
|
||||
const storage = getSecureStorage();
|
||||
storage.set('offlineMode', data.enabled);
|
||||
|
||||
if (data.syncInterval) {
|
||||
storage.set('syncInterval', data.syncInterval);
|
||||
}
|
||||
|
||||
storage.save();
|
||||
console.log('[Offline] Mode set to:', data.enabled);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[Offline] Error setting mode:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
});
|
||||
|
||||
// Get offline mode status
|
||||
ipcMain.handle('offline:mode:get', () => {
|
||||
try {
|
||||
const storage = getSecureStorage();
|
||||
const offlineMode = storage.get<boolean>('offlineMode', false);
|
||||
const syncInterval = storage.get<number>('syncInterval', 30);
|
||||
const lastUserId = storage.get<string>('lastUserId');
|
||||
const hasPin = lastUserId ? !!storage.get<string>(`pin-${lastUserId}`) : false;
|
||||
|
||||
return {
|
||||
enabled: offlineMode,
|
||||
syncInterval,
|
||||
hasPin,
|
||||
lastUserId
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Offline] Error getting mode:', error);
|
||||
return {
|
||||
enabled: false,
|
||||
syncInterval: 30,
|
||||
hasPin: false
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Check if should sync
|
||||
ipcMain.handle('offline:sync:check', () => {
|
||||
try {
|
||||
const storage = getSecureStorage();
|
||||
const lastSync = storage.get<string>('lastSync');
|
||||
const syncInterval = storage.get<number>('syncInterval', 30) || 30;
|
||||
|
||||
if (!lastSync) {
|
||||
return { shouldSync: true };
|
||||
}
|
||||
|
||||
const daysSinceSync = Math.floor(
|
||||
(Date.now() - new Date(lastSync).getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
return {
|
||||
shouldSync: daysSinceSync >= syncInterval,
|
||||
daysSinceSync,
|
||||
syncInterval
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Offline] Error checking sync:', error);
|
||||
return { shouldSync: false };
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[IPC] Offline handlers registered');
|
||||
@@ -5,6 +5,8 @@ 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';
|
||||
@@ -120,7 +122,10 @@ function createMainWindow(): void {
|
||||
// IPC Handlers pour la gestion du token (OS-encrypted storage)
|
||||
ipcMain.handle('get-token', () => {
|
||||
const storage = getSecureStorage();
|
||||
return storage.get('authToken', null);
|
||||
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) => {
|
||||
@@ -156,12 +161,9 @@ ipcMain.handle('init-user', async (_event, userId: string) => {
|
||||
storage.set('lastUserId', userId); // Save for offline mode
|
||||
|
||||
try {
|
||||
const { getUserEncryptionKey, setUserEncryptionKey, hasUserEncryptionKey } = await import('./database/keyManager.js');
|
||||
|
||||
let encryptionKey: string | null = null;
|
||||
|
||||
if (!hasUserEncryptionKey(userId)) {
|
||||
const { generateUserEncryptionKey } = await import('./database/encryption.js');
|
||||
encryptionKey = generateUserEncryptionKey(userId);
|
||||
|
||||
console.log('[InitUser] Generated new encryption key for user');
|
||||
@@ -214,8 +216,10 @@ ipcMain.handle('init-user', async (_event, userId: string) => {
|
||||
});
|
||||
|
||||
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) {
|
||||
@@ -298,7 +302,6 @@ interface SyncUserData {
|
||||
|
||||
ipcMain.handle('db:user:sync', async (_event, data: SyncUserData): Promise<boolean> => {
|
||||
try {
|
||||
// Import User models dynamically to avoid circular dependencies
|
||||
const { default: User } = await import('./database/models/User.js');
|
||||
const { default: UserRepo } = await import('./database/repositories/user.repository.js');
|
||||
|
||||
@@ -339,8 +342,6 @@ ipcMain.handle('db:user:sync', async (_event, data: SyncUserData): Promise<boole
|
||||
*/
|
||||
ipcMain.handle('generate-encryption-key', async (_event, userId: string) => {
|
||||
try {
|
||||
// Import encryption module dynamically
|
||||
const { generateUserEncryptionKey } = await import('./database/encryption.js');
|
||||
const key = generateUserEncryptionKey(userId);
|
||||
return { success: true, key };
|
||||
} catch (error) {
|
||||
@@ -453,43 +454,8 @@ app.whenReady().then(() => {
|
||||
console.log('[Startup] Has PIN:', hasPin);
|
||||
|
||||
if (token) {
|
||||
// Token existe, ouvrir la fenêtre principale
|
||||
createMainWindow();
|
||||
} else if (offlineMode && hasPin && lastUserId) {
|
||||
// Mode offline activé avec PIN, ouvrir login offline
|
||||
console.log('[Startup] Opening offline login page');
|
||||
loginWindow = new BrowserWindow({
|
||||
width: 500,
|
||||
height: 900,
|
||||
resizable: false,
|
||||
...(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/offline`);
|
||||
loginWindow.webContents.openDevTools();
|
||||
} else {
|
||||
loginWindow.loadURL('app://./login/offline/index.html');
|
||||
}
|
||||
|
||||
loginWindow.once('ready-to-show', () => {
|
||||
loginWindow?.show();
|
||||
});
|
||||
|
||||
loginWindow.on('closed', () => {
|
||||
loginWindow = null;
|
||||
});
|
||||
} else {
|
||||
// Pas de token ou pas de mode offline, ouvrir la fenêtre de login normale
|
||||
createLoginWindow();
|
||||
}
|
||||
|
||||
|
||||
244
electron/storage/SecureStorage.ts
Normal file
244
electron/storage/SecureStorage.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { safeStorage, app } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* SecureStorage - Replacement for electron-store using Electron's safeStorage API
|
||||
*
|
||||
* Uses OS-level encryption:
|
||||
* - macOS: Keychain
|
||||
* - Windows: DPAPI (Data Protection API)
|
||||
* - Linux: gnome-libsecret/kwallet
|
||||
*
|
||||
* Security notes:
|
||||
* - Protects against physical theft (when PC is off)
|
||||
* - Protects against other users on same machine
|
||||
* - Does NOT protect against malware running under same user
|
||||
* - On Linux, check getStorageBackend() - if 'basic_text', encryption is weak
|
||||
*/
|
||||
class SecureStorage {
|
||||
private storePath: string;
|
||||
private cache: Map<string, string> = new Map();
|
||||
private isLoaded: boolean = false;
|
||||
private appReady: boolean = false;
|
||||
|
||||
constructor() {
|
||||
const userDataPath = app.getPath('userData');
|
||||
this.storePath = path.join(userDataPath, 'secure-config.json');
|
||||
|
||||
// Wait for app to be ready before using safeStorage
|
||||
if (app.isReady()) {
|
||||
this.appReady = true;
|
||||
} else {
|
||||
app.whenReady().then(() => {
|
||||
this.appReady = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure data is loaded from disk (lazy loading)
|
||||
*/
|
||||
private ensureLoaded(): void {
|
||||
if (!this.isLoaded) {
|
||||
this.loadFromDisk();
|
||||
this.isLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load encrypted data from disk into memory cache
|
||||
*/
|
||||
private loadFromDisk(): void {
|
||||
try {
|
||||
if (!fs.existsSync(this.storePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileData = fs.readFileSync(this.storePath, 'utf-8');
|
||||
const parsed = JSON.parse(fileData);
|
||||
|
||||
// Load all values and store in cache
|
||||
for (const [key, storedValue] of Object.entries(parsed)) {
|
||||
if (typeof storedValue !== 'string' || storedValue.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (storedValue.startsWith('encrypted:')) {
|
||||
// Decrypt encrypted value
|
||||
const encryptedBase64 = storedValue.substring('encrypted:'.length);
|
||||
const buffer = Buffer.from(encryptedBase64, 'base64');
|
||||
const decrypted = safeStorage.decryptString(buffer);
|
||||
this.cache.set(key, decrypted);
|
||||
} else if (storedValue.startsWith('plain:')) {
|
||||
// Load plain value
|
||||
const plainValue = storedValue.substring('plain:'.length);
|
||||
this.cache.set(key, plainValue);
|
||||
} else {
|
||||
// Legacy format (try to decrypt)
|
||||
try {
|
||||
const buffer = Buffer.from(storedValue, 'base64');
|
||||
const decrypted = safeStorage.decryptString(buffer);
|
||||
this.cache.set(key, decrypted);
|
||||
} catch {
|
||||
// If decrypt fails, assume it's plain text
|
||||
this.cache.set(key, storedValue);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[SecureStorage] Failed to load key '${key}':`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SecureStorage] Failed to load from disk:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save encrypted data from memory cache to disk
|
||||
*/
|
||||
private saveToDisk(): void {
|
||||
try {
|
||||
const data: Record<string, string> = {};
|
||||
|
||||
// Check if encryption is available
|
||||
const canEncrypt = safeStorage.isEncryptionAvailable();
|
||||
|
||||
for (const [key, value] of this.cache.entries()) {
|
||||
if (canEncrypt && safeStorage.isEncryptionAvailable()) {
|
||||
try {
|
||||
if (value && typeof value === 'string') {
|
||||
const buffer = safeStorage.encryptString(value);
|
||||
if (buffer && buffer.length > 0) {
|
||||
data[key] = `encrypted:${buffer.toString('base64')}`;
|
||||
} else {
|
||||
throw new Error(`Failed to encrypt key '${key}'`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Invalid value for key '${key}'`);
|
||||
}
|
||||
} catch (encryptError) {
|
||||
console.error(`[SecureStorage] CRITICAL: Cannot encrypt key '${key}':`, encryptError);
|
||||
throw encryptError;
|
||||
}
|
||||
} else {
|
||||
throw new Error('Encryption not available - cannot save securely');
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(this.storePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(this.storePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
} catch (error) {
|
||||
console.error('[SecureStorage] Failed to save to disk:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from secure storage
|
||||
* @param key - Storage key
|
||||
* @param defaultValue - Default value if key doesn't exist
|
||||
* @returns Stored value or default
|
||||
*/
|
||||
get<T = string>(key: string, defaultValue: T | null = null): T | null {
|
||||
this.ensureLoaded();
|
||||
const value = this.cache.get(key);
|
||||
if (value === undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// Try to parse as JSON for objects/arrays
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
// Return as-is if not JSON
|
||||
return value as unknown as T;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in secure storage (kept in memory only)
|
||||
* @param key - Storage key
|
||||
* @param value - Value to store
|
||||
*/
|
||||
set(key: string, value: unknown): void {
|
||||
this.ensureLoaded();
|
||||
// Convert to string (JSON if object/array)
|
||||
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
|
||||
this.cache.set(key, stringValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a value from secure storage (memory only)
|
||||
* @param key - Storage key
|
||||
*/
|
||||
delete(key: string): void {
|
||||
this.ensureLoaded();
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key exists in secure storage
|
||||
* @param key - Storage key
|
||||
* @returns True if key exists
|
||||
*/
|
||||
has(key: string): boolean {
|
||||
this.ensureLoaded();
|
||||
return this.cache.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all data from secure storage (memory only)
|
||||
*/
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually save to disk (encrypted with safeStorage)
|
||||
* Call this when you want to persist data
|
||||
*/
|
||||
save(): void {
|
||||
this.saveToDisk();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if encryption is available
|
||||
* @returns True if OS-level encryption is available
|
||||
*/
|
||||
isEncryptionAvailable(): boolean {
|
||||
return safeStorage.isEncryptionAvailable();
|
||||
}
|
||||
}
|
||||
|
||||
// Store singleton in global scope to avoid multiple instances with dynamic imports
|
||||
declare global {
|
||||
var __secureStorageInstance: SecureStorage | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SecureStorage singleton instance
|
||||
* @returns SecureStorage instance
|
||||
*/
|
||||
export function getSecureStorage(): SecureStorage {
|
||||
if (!global.__secureStorageInstance) {
|
||||
global.__secureStorageInstance = new SecureStorage();
|
||||
|
||||
// Log encryption availability
|
||||
if (!global.__secureStorageInstance.isEncryptionAvailable()) {
|
||||
console.warn(
|
||||
'[SecureStorage] WARNING: OS-level encryption is not available. ' +
|
||||
'Data will still be stored but with reduced security.'
|
||||
);
|
||||
}
|
||||
}
|
||||
return global.__secureStorageInstance;
|
||||
}
|
||||
|
||||
export default SecureStorage;
|
||||
@@ -833,7 +833,10 @@
|
||||
"userNotFound": "User not found",
|
||||
"authenticationError": "Error during authentication",
|
||||
"termsAcceptError": "Error accepting terms of service",
|
||||
"lastChapterError": "Error retrieving last chapter"
|
||||
"lastChapterError": "Error retrieving last chapter",
|
||||
"localDataError": "Unable to load local data",
|
||||
"encryptionKeyError": "Encryption key not found",
|
||||
"offlineModeError": "Error initializing offline mode"
|
||||
}
|
||||
},
|
||||
"shortStoryGenerator": {
|
||||
|
||||
@@ -834,7 +834,10 @@
|
||||
"userNotFound": "Utilisateur non trouvé",
|
||||
"authenticationError": "Erreur pendant l'authentification",
|
||||
"termsAcceptError": "Erreur lors de l'acceptation des conditions d'utilisation",
|
||||
"lastChapterError": "Erreur lors de la récupération du dernier chapitre"
|
||||
"lastChapterError": "Erreur lors de la récupération du dernier chapitre",
|
||||
"localDataError": "Impossible de charger les données locales",
|
||||
"encryptionKeyError": "Clé de chiffrement non trouvée",
|
||||
"offlineModeError": "Erreur lors de l'initialisation du mode hors ligne"
|
||||
}
|
||||
},
|
||||
"shortStoryGenerator": {
|
||||
|
||||
@@ -1,397 +0,0 @@
|
||||
import System from '@/lib/models/System';
|
||||
|
||||
/**
|
||||
* SyncService - Handles bidirectional synchronization between local DB and server
|
||||
* Implements conflict resolution and retry logic
|
||||
*/
|
||||
export class SyncService {
|
||||
private syncInterval: NodeJS.Timeout | null = null;
|
||||
private isSyncing: boolean = false;
|
||||
private isOnline: boolean = navigator.onLine;
|
||||
private accessToken: string | null = null;
|
||||
|
||||
constructor() {
|
||||
// Listen to online/offline events
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('online', () => {
|
||||
this.isOnline = true;
|
||||
this.onlineStatusChanged(true);
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
this.isOnline = false;
|
||||
this.onlineStatusChanged(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start automatic sync every interval
|
||||
* @param intervalMs - Sync interval in milliseconds (default 30 seconds)
|
||||
*/
|
||||
startAutoSync(intervalMs: number = 30000): void {
|
||||
if (this.syncInterval) {
|
||||
clearInterval(this.syncInterval);
|
||||
}
|
||||
|
||||
this.syncInterval = setInterval(() => {
|
||||
if (this.isOnline && !this.isSyncing) {
|
||||
this.sync();
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
console.log(`Auto-sync started with interval: ${intervalMs}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop automatic sync
|
||||
*/
|
||||
stopAutoSync(): void {
|
||||
if (this.syncInterval) {
|
||||
clearInterval(this.syncInterval);
|
||||
this.syncInterval = null;
|
||||
}
|
||||
console.log('Auto-sync stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set access token for API requests
|
||||
*/
|
||||
setAccessToken(token: string): void {
|
||||
this.accessToken = token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently online
|
||||
*/
|
||||
getOnlineStatus(): boolean {
|
||||
return this.isOnline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force set online/offline status (for manual toggle)
|
||||
*/
|
||||
setOnlineStatus(online: boolean): void {
|
||||
this.isOnline = online;
|
||||
this.onlineStatusChanged(online);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle online/offline status change
|
||||
*/
|
||||
private onlineStatusChanged(online: boolean): void {
|
||||
console.log(`Network status changed: ${online ? 'ONLINE' : 'OFFLINE'}`);
|
||||
|
||||
if (online && !this.isSyncing) {
|
||||
// When going online, trigger immediate sync
|
||||
setTimeout(() => this.sync(), 1000);
|
||||
}
|
||||
|
||||
// Notify listeners (will be implemented in OfflineContext)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('offline-status-changed', { detail: { online } }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform full bidirectional sync
|
||||
*/
|
||||
async sync(): Promise<SyncResult> {
|
||||
if (!this.isOnline) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Cannot sync while offline',
|
||||
pushedChanges: 0,
|
||||
pulledChanges: 0
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isSyncing) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Sync already in progress',
|
||||
pushedChanges: 0,
|
||||
pulledChanges: 0
|
||||
};
|
||||
}
|
||||
|
||||
this.isSyncing = true;
|
||||
console.log('Starting sync...');
|
||||
|
||||
try {
|
||||
// Check Electron API availability
|
||||
if (typeof window === 'undefined' || !(window as any).electron) {
|
||||
throw new Error('Electron API not available');
|
||||
}
|
||||
|
||||
// Step 1: Push local changes to server
|
||||
const pushedChanges = await this.pushChanges();
|
||||
|
||||
// Step 2: Pull server changes to local
|
||||
const pulledChanges = await this.pullChanges();
|
||||
|
||||
console.log(`Sync completed: pushed ${pushedChanges}, pulled ${pulledChanges} changes`);
|
||||
|
||||
// Dispatch sync completion event
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('sync-completed', {
|
||||
detail: { pushedChanges, pulledChanges }
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
pushedChanges,
|
||||
pulledChanges
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Sync failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
pushedChanges: 0,
|
||||
pulledChanges: 0
|
||||
};
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push local changes to server
|
||||
*/
|
||||
private async pushChanges(): Promise<number> {
|
||||
if (!this.accessToken) {
|
||||
console.warn('No access token available for sync');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get pending changes via Electron IPC
|
||||
const result = await (window as any).electron.dbGetPendingChanges(50);
|
||||
if (!result.success) {
|
||||
console.error('Failed to get pending changes:', result.error);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const pendingChanges = result.data || [];
|
||||
|
||||
if (pendingChanges.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(`Pushing ${pendingChanges.length} pending changes...`);
|
||||
|
||||
let successCount = 0;
|
||||
const syncedIds: number[] = [];
|
||||
|
||||
for (const change of pendingChanges) {
|
||||
try {
|
||||
const success = await this.pushSingleChange(change);
|
||||
if (success) {
|
||||
successCount++;
|
||||
syncedIds.push(change.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to push change ${change.id}:`, error);
|
||||
// Continue with next change
|
||||
}
|
||||
}
|
||||
|
||||
// Mark successfully synced changes via IPC
|
||||
if (syncedIds.length > 0) {
|
||||
// TODO: Add IPC handler for marking synced
|
||||
console.log('Synced changes:', syncedIds);
|
||||
}
|
||||
|
||||
return successCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a single change to server
|
||||
*/
|
||||
private async pushSingleChange(change: any): Promise<boolean> {
|
||||
if (!this.accessToken) return false;
|
||||
|
||||
const { table_name, operation, record_id, data } = change;
|
||||
let url = '';
|
||||
let method: 'POST' | 'PUT' | 'DELETE' = 'POST';
|
||||
|
||||
// Map table names to API endpoints
|
||||
switch (table_name) {
|
||||
case 'erit_books':
|
||||
url = operation === 'DELETE' ? `books/${record_id}` : 'books';
|
||||
method = operation === 'DELETE' ? 'DELETE' : operation === 'INSERT' ? 'POST' : 'PUT';
|
||||
break;
|
||||
|
||||
case 'book_chapters':
|
||||
url = operation === 'DELETE' ? `chapters/${record_id}` : 'chapters';
|
||||
method = operation === 'DELETE' ? 'DELETE' : operation === 'INSERT' ? 'POST' : 'PUT';
|
||||
break;
|
||||
|
||||
case 'book_characters':
|
||||
url = operation === 'DELETE' ? `characters/${record_id}` : 'characters';
|
||||
method = operation === 'DELETE' ? 'DELETE' : operation === 'INSERT' ? 'POST' : 'PUT';
|
||||
break;
|
||||
|
||||
case 'ai_conversations':
|
||||
url = operation === 'DELETE' ? `ai/conversations/${record_id}` : 'ai/conversations';
|
||||
method = operation === 'DELETE' ? 'DELETE' : operation === 'INSERT' ? 'POST' : 'PUT';
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`Unknown table for sync: ${table_name}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (method === 'DELETE') {
|
||||
await System.authDeleteToServer(url, {}, this.accessToken);
|
||||
} else if (method === 'PUT') {
|
||||
await System.authPutToServer(url, JSON.parse(data), this.accessToken);
|
||||
} else {
|
||||
await System.authPostToServer(url, JSON.parse(data), this.accessToken);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to sync ${table_name} ${operation}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull changes from server
|
||||
*/
|
||||
private async pullChanges(): Promise<number> {
|
||||
if (!this.accessToken) {
|
||||
console.warn('No access token available for sync');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get sync status via Electron IPC
|
||||
const statusResult = await (window as any).electron.dbGetSyncStatus();
|
||||
if (!statusResult.success) {
|
||||
console.error('Failed to get sync status:', statusResult.error);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const syncStatus = statusResult.data || [];
|
||||
|
||||
let totalPulled = 0;
|
||||
|
||||
// Pull updates for each table
|
||||
for (const status of syncStatus) {
|
||||
try {
|
||||
const count = await this.pullTableChanges(status.table, status.lastSync);
|
||||
totalPulled += count;
|
||||
} catch (error) {
|
||||
console.error(`Failed to pull changes for ${status.table}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return totalPulled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull changes for a specific table
|
||||
*/
|
||||
private async pullTableChanges(tableName: string, lastSync: number): Promise<number> {
|
||||
if (!this.accessToken) return 0;
|
||||
|
||||
// Map table names to API endpoints
|
||||
let endpoint = '';
|
||||
|
||||
switch (tableName) {
|
||||
case 'erit_books':
|
||||
endpoint = 'books';
|
||||
break;
|
||||
case 'book_chapters':
|
||||
endpoint = 'chapters';
|
||||
break;
|
||||
case 'book_characters':
|
||||
endpoint = 'characters';
|
||||
break;
|
||||
case 'ai_conversations':
|
||||
endpoint = 'ai/conversations';
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// Request changes since last sync
|
||||
const response = await System.authGetQueryToServer<any>(
|
||||
`${endpoint}/sync?since=${lastSync}`,
|
||||
this.accessToken
|
||||
);
|
||||
|
||||
if (!response || !response.data) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const changes = Array.isArray(response.data) ? response.data : [response.data];
|
||||
|
||||
// Apply changes to local database
|
||||
// This would require implementing merge logic for each table
|
||||
// For now, we'll just log the changes
|
||||
|
||||
console.log(`Pulled ${changes.length} changes for ${tableName}`);
|
||||
|
||||
// Update last sync time via IPC
|
||||
// TODO: Add IPC handler for updating last sync
|
||||
|
||||
return changes.length;
|
||||
} catch (error) {
|
||||
console.error(`Failed to pull changes for ${tableName}:`, error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve conflicts between local and server data
|
||||
* Strategy: Server wins (can be customized)
|
||||
*/
|
||||
private resolveConflict(localData: any, serverData: any): any {
|
||||
// Simple strategy: server wins
|
||||
// TODO: Implement more sophisticated conflict resolution
|
||||
console.warn('Conflict detected, using server data');
|
||||
return serverData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync progress
|
||||
*/
|
||||
getSyncProgress(): SyncProgress {
|
||||
// This will be called synchronously, so we return cached state
|
||||
// The actual sync status is updated via events
|
||||
return {
|
||||
isSyncing: this.isSyncing,
|
||||
pendingChanges: 0, // Will be updated via IPC
|
||||
isOnline: this.isOnline
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
pushedChanges: number;
|
||||
pulledChanges: number;
|
||||
}
|
||||
|
||||
export interface SyncProgress {
|
||||
isSyncing: boolean;
|
||||
pendingChanges: number;
|
||||
isOnline: boolean;
|
||||
tables?: { table: string; lastSync: number; pending: number }[];
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let syncServiceInstance: SyncService | null = null;
|
||||
|
||||
export function getSyncService(): SyncService {
|
||||
if (!syncServiceInstance) {
|
||||
syncServiceInstance = new SyncService();
|
||||
}
|
||||
return syncServiceInstance;
|
||||
}
|
||||
106
lib/utils/db-error-handler.ts
Normal file
106
lib/utils/db-error-handler.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Database Error Handler for Frontend
|
||||
* Handles errors from Electron IPC calls
|
||||
*/
|
||||
|
||||
export interface SerializedError {
|
||||
name: string;
|
||||
message: string;
|
||||
messageFr: string;
|
||||
messageEn: string;
|
||||
statusCode: number;
|
||||
stack?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a serialized database error
|
||||
*/
|
||||
export function isDbError(error: unknown): error is SerializedError {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'name' in error &&
|
||||
'messageFr' in error &&
|
||||
'messageEn' in error &&
|
||||
'statusCode' in error
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error message based on current language
|
||||
*/
|
||||
export function getErrorMessage(error: SerializedError, lang: 'fr' | 'en' = 'fr'): string {
|
||||
return lang === 'fr' ? error.messageFr : error.messageEn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle database operation with error catching
|
||||
* Use this to wrap all IPC calls
|
||||
*/
|
||||
export async function handleDbOperation<T>(
|
||||
operation: () => Promise<T>,
|
||||
onError?: (error: SerializedError) => void,
|
||||
lang: 'fr' | 'en' = 'fr'
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error: unknown) {
|
||||
if (isDbError(error)) {
|
||||
const errorMessage = getErrorMessage(error, lang);
|
||||
console.error(`[DB Error ${error.statusCode}]: ${errorMessage}`);
|
||||
|
||||
if (onError) {
|
||||
onError(error);
|
||||
} else {
|
||||
// Default: throw with localized message
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Not a database error, rethrow as-is
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* React Hook for database operations
|
||||
* Example usage in a React component:
|
||||
*
|
||||
* const { data, error, loading, execute } = useDbOperation();
|
||||
*
|
||||
* const loadBooks = async () => {
|
||||
* await execute(() => window.electron.invoke('db:book:getAll'));
|
||||
* };
|
||||
*/
|
||||
export function useDbOperation<T>() {
|
||||
const [data, setData] = React.useState<T | null>(null);
|
||||
const [error, setError] = React.useState<SerializedError | null>(null);
|
||||
const [loading, setLoading] = React.useState<boolean>(false);
|
||||
|
||||
const execute = async (
|
||||
operation: () => Promise<T>,
|
||||
lang: 'fr' | 'en' = 'fr'
|
||||
): Promise<T | null> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await handleDbOperation(
|
||||
operation,
|
||||
(err) => setError(err),
|
||||
lang
|
||||
);
|
||||
setData(result);
|
||||
setLoading(false);
|
||||
return result;
|
||||
} catch (err) {
|
||||
setLoading(false);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return { data, error, loading, execute };
|
||||
}
|
||||
|
||||
// For non-React usage
|
||||
import React from 'react';
|
||||
Reference in New Issue
Block a user