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:
natreex
2025-11-19 22:01:24 -05:00
parent f85c2d2269
commit 9e51cc93a8
20 changed files with 961 additions and 484 deletions

View 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;