diff --git a/.gitignore b/.gitignore index f84e181..d37c918 100644 --- a/.gitignore +++ b/.gitignore @@ -6,10 +6,6 @@ node_modules # Testing /coverage -# Next.js -/.next/ -/out/ - # Production /dist /build @@ -32,7 +28,17 @@ yarn-error.log* # TypeScript *.tsbuildinfo + +# Next.js +.next/ +/out/ next-env.d.ts -# Electron -/release +# Electron build output +/release/ + +# Tauri build output +src-tauri/target/ + +# IDE +.idea/ diff --git a/app/layout.tsx b/app/layout.tsx deleted file mode 100644 index 159a6bf..0000000 --- a/app/layout.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type {Metadata} from "next"; -import "./globals.css"; -import {ReactNode} from "react"; -import ScribeShell from "@/components/layout/ScribeShell"; - -export const metadata: Metadata = { - title: "ERitors Scribe", - description: "Les meilleurs livres sont ceux qui ont le meilleur plan.", - icons: { - icon: "/eritors-favicon-white.png" - }, - robots: { - index: false, - follow: false, - }, -}; - -export default function RootLayout( - { - children, - }: Readonly<{ - children: ReactNode; - }>) { - return ( - - - {children} - - - ); -} diff --git a/app/main.tsx b/app/main.tsx index 390f38d..9bf48d0 100644 --- a/app/main.tsx +++ b/app/main.tsx @@ -8,7 +8,7 @@ import * as tauri from '@/lib/tauri'; import PulseLoader from '@/components/ui/PulseLoader'; import {useTranslations} from '@/lib/i18n'; -listen('auth-success', () => window.location.reload()); +listen('auth-success', () => window.location.reload()).then(); type MigrationState = 'pending' | 'error' | 'done'; diff --git a/app/page.tsx b/app/page.tsx deleted file mode 100644 index 5741499..0000000 --- a/app/page.tsx +++ /dev/null @@ -1,6 +0,0 @@ -'use client'; -import BookList from '@/components/book/BookList'; - -export default function HomePage() { - return ; -} diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist deleted file mode 100644 index 8f574f5..0000000 --- a/build/entitlements.mac.plist +++ /dev/null @@ -1,12 +0,0 @@ - - - - - com.apple.security.cs.allow-jit - - com.apple.security.cs.allow-unsigned-executable-memory - - com.apple.security.cs.debugger - - - diff --git a/build/icon.png b/build/icon.png deleted file mode 100644 index 03b9d6b..0000000 Binary files a/build/icon.png and /dev/null differ diff --git a/build/icons/mac/icon.icns b/build/icons/mac/icon.icns deleted file mode 100644 index 5d280ba..0000000 Binary files a/build/icons/mac/icon.icns and /dev/null differ diff --git a/build/icons/png/1024x1024.png b/build/icons/png/1024x1024.png deleted file mode 100644 index 802a596..0000000 Binary files a/build/icons/png/1024x1024.png and /dev/null differ diff --git a/build/icons/png/128x128.png b/build/icons/png/128x128.png deleted file mode 100644 index c59a10f..0000000 Binary files a/build/icons/png/128x128.png and /dev/null differ diff --git a/build/icons/png/16x16.png b/build/icons/png/16x16.png deleted file mode 100644 index 5675ff2..0000000 Binary files a/build/icons/png/16x16.png and /dev/null differ diff --git a/build/icons/png/24x24.png b/build/icons/png/24x24.png deleted file mode 100644 index 2cc76b1..0000000 Binary files a/build/icons/png/24x24.png and /dev/null differ diff --git a/build/icons/png/256x256.png b/build/icons/png/256x256.png deleted file mode 100644 index c93e762..0000000 Binary files a/build/icons/png/256x256.png and /dev/null differ diff --git a/build/icons/png/32x32.png b/build/icons/png/32x32.png deleted file mode 100644 index 04f51c8..0000000 Binary files a/build/icons/png/32x32.png and /dev/null differ diff --git a/build/icons/png/48x48.png b/build/icons/png/48x48.png deleted file mode 100644 index 3ea788c..0000000 Binary files a/build/icons/png/48x48.png and /dev/null differ diff --git a/build/icons/png/512x512.png b/build/icons/png/512x512.png deleted file mode 100644 index 2f7215f..0000000 Binary files a/build/icons/png/512x512.png and /dev/null differ diff --git a/build/icons/png/64x64.png b/build/icons/png/64x64.png deleted file mode 100644 index 933b1b4..0000000 Binary files a/build/icons/png/64x64.png and /dev/null differ diff --git a/build/icons/win/icon.ico b/build/icons/win/icon.ico deleted file mode 100644 index acfe9cf..0000000 Binary files a/build/icons/win/icon.ico and /dev/null differ diff --git a/hooks/useAutoUpdate.ts b/hooks/useAutoUpdate.ts new file mode 100644 index 0000000..c7f214b --- /dev/null +++ b/hooks/useAutoUpdate.ts @@ -0,0 +1,67 @@ +import {useEffect, useState} from 'react'; +import {check, Update} from '@tauri-apps/plugin-updater'; +import {relaunch} from '@tauri-apps/plugin-process'; + +export interface UpdateState { + available: boolean; + version: string; + notes: string; + downloading: boolean; + progress: number; + install: () => Promise; + dismiss: () => void; +} + +export function useAutoUpdate(): UpdateState { + const [available, setAvailable] = useState(false); + const [version, setVersion] = useState(''); + const [notes, setNotes] = useState(''); + const [downloading, setDownloading] = useState(false); + const [progress, setProgress] = useState(0); + const [update, setUpdate] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function checkForUpdate() { + try { + const result = await check(); + if (cancelled || !result) return; + setUpdate(result); + setAvailable(true); + setVersion(result.version); + setNotes(result.body ?? ''); + } catch (_) { + // silently fail — no update or offline + } + } + + checkForUpdate(); + return () => { cancelled = true; }; + }, []); + + async function install() { + if (!update) return; + setDownloading(true); + try { + await update.downloadAndInstall((event) => { + if (event.event === 'Started' && event.data.contentLength) { + setProgress(0); + } else if (event.event === 'Progress') { + setProgress((prev) => prev + event.data.chunkLength); + } else if (event.event === 'Finished') { + setProgress(100); + } + }); + await relaunch(); + } catch (_) { + setDownloading(false); + } + } + + function dismiss() { + setAvailable(false); + } + + return {available, version, notes, downloading, progress, install, dismiss}; +} diff --git a/public/file.svg b/public/file.svg deleted file mode 100755 index 004145c..0000000 --- a/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg deleted file mode 100755 index 567f17b..0000000 --- a/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/next.svg b/public/next.svg deleted file mode 100755 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100755 index e43af76..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/public/window.svg b/public/window.svg deleted file mode 100755 index b2b2a44..0000000 --- a/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src-tauri/src/domains/migration/commands.rs b/src-tauri/src/domains/migration/commands.rs new file mode 100644 index 0000000..879173b --- /dev/null +++ b/src-tauri/src/domains/migration/commands.rs @@ -0,0 +1,378 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use tauri::State; + +use crate::crypto::key_manager; +use crate::db::connection::DbManager; +use crate::db::schema; +use crate::error::{AppError, AppResult}; +use crate::shared::session::SessionState; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportMigrationData { + pub migration_file_path: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MigrationResult { + pub success: bool, + pub user_id: Option, + pub error: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MigrationCheckResult { + pub found: bool, + pub user_id: Option, + pub has_db: bool, + pub migration_path: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AutoMigrationResult { + pub migrated: bool, + pub user_id: Option, + pub token_migrated: bool, + pub key_migrated: bool, + pub pin_migrated: bool, + pub db_migrated: bool, + pub error: Option, +} + +#[derive(Deserialize)] +struct ElectronMigrationFile { + version: u32, + user_id: String, + encryption_key: String, + pin_hash: Option, + db_source: String, +} + +// ─── Electron safeStorage vault format ─────────────────────────────────────── + +#[derive(Deserialize, Default)] +struct ElectronVault { + #[serde(default)] + token: Option, + #[serde(flatten)] + extra: HashMap, +} + +// ─── Auto-migration from Electron ──────────────────────────────────────────── + +fn electron_data_paths() -> Vec { + let home = dirs_next::home_dir().unwrap_or_else(|| PathBuf::from(".")); + let mut paths = Vec::new(); + + #[cfg(target_os = "macos")] + { + let app_support = home.join("Library").join("Application Support"); + paths.push(app_support.join("ERitors Scribe")); + paths.push(app_support.join("Electron")); + } + + #[cfg(target_os = "windows")] + { + if let Some(appdata) = dirs_next::data_dir() { + paths.push(appdata.join("ERitors Scribe")); + paths.push(appdata.join("Electron")); + } + } + + paths +} + +fn find_electron_vault(data_path: &PathBuf) -> Option { + let candidate = data_path.join("secure-config.json"); + if candidate.exists() { return Some(candidate); } + None +} + +fn find_electron_dbs(data_path: &PathBuf) -> Vec<(String, PathBuf)> { + let mut result = Vec::new(); + if let Ok(entries) = std::fs::read_dir(data_path) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with("eritors-local-") && name.ends_with(".db") { + let user_id = name + .trim_start_matches("eritors-local-") + .trim_end_matches(".db") + .to_string(); + result.push((user_id, entry.path())); + } + } + } + result +} + +#[cfg(target_os = "macos")] +fn decrypt_electron_value(encrypted_b64: &str) -> Option { + use aes::Aes128; + use cbc::cipher::{block_padding::Pkcs7, BlockDecryptMut as _, KeyIvInit}; + use pbkdf2::pbkdf2_hmac; + use sha1::Sha1; + + type Aes128CbcDec = cbc::Decryptor; + + let b64 = if encrypted_b64.starts_with("encrypted:") { + &encrypted_b64[10..] + } else { + encrypted_b64 + }; + + let data = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + b64, + ).ok()?; + + if !data.starts_with(b"v10") { + return None; + } + let ciphertext = &data[3..]; + + let app_names = ["ERitors Scribe", "Electron"]; + for app_name in &app_names { + let service = format!("{} Safe Storage", app_name); + if let Ok(entry) = keyring::Entry::new(&service, app_name) { + if let Ok(password) = entry.get_password() { + let mut key = [0u8; 16]; + pbkdf2_hmac::( + password.as_bytes(), + b"saltysalt", + 1003, + &mut key, + ); + let iv = [0x20u8; 16]; + let mut buf = ciphertext.to_vec(); + if let Ok(decryptor) = Aes128CbcDec::new_from_slices(&key, &iv) { + if let Ok(decrypted) = decryptor.decrypt_padded_mut::(&mut buf) { + if let Ok(s) = String::from_utf8(decrypted.to_vec()) { + return Some(s); + } + } + } + } + } + } + None +} + +#[cfg(not(target_os = "macos"))] +fn decrypt_electron_value(_encrypted_b64: &str) -> Option { + None +} + +fn read_electron_vault(vault_path: &PathBuf) -> HashMap { + let mut result = HashMap::new(); + let Ok(content) = std::fs::read_to_string(vault_path) else { return result; }; + let Ok(parsed) = serde_json::from_str::>(&content) else { return result; }; + + for (key, value) in &parsed { + if let Some(s) = value.as_str() { + if let Some(decrypted) = decrypt_electron_value(s) { + result.insert(key.clone(), decrypted); + } else if !s.starts_with("encrypted:") { + result.insert(key.clone(), s.to_string()); + } + } + } + result +} + +/// Called automatically at startup in production — detects and migrates Electron data. +#[tauri::command] +pub fn auto_migrate_electron( + db: State, + session: State, +) -> AppResult { + let already_migrated = key_manager::get_last_user_id().ok().flatten(); + if already_migrated.is_some() { + return Ok(AutoMigrationResult { + migrated: false, user_id: None, + token_migrated: false, key_migrated: false, + pin_migrated: false, db_migrated: false, + error: None, + }); + } + + for data_path in electron_data_paths() { + if !data_path.exists() { continue; } + + let dbs = find_electron_dbs(&data_path); + if dbs.is_empty() { continue; } + + let vault_data = if let Some(vault_path) = find_electron_vault(&data_path) { + read_electron_vault(&vault_path) + } else { + HashMap::new() + }; + + let token = vault_data.get("token").cloned(); + let last_user_id = vault_data.get("lastUserId").cloned() + .or_else(|| dbs.first().map(|(uid, _)| uid.clone())); + + let Some(user_id) = last_user_id else { continue; }; + + let encryption_key = vault_data.get(&format!("encryptionKey-{}", user_id)).cloned(); + let pin_hash = vault_data.get(&format!("pinHash-{}", user_id)).cloned(); + + // Copy DB files + let mut db_migrated = false; + for (uid, db_path) in &dbs { + if uid != &user_id { continue; } + { + let mut db_manager = db.lock() + .map_err(|e| AppError::Internal(format!("DB lock: {}", e)))?; + db_manager.initialize(uid)?; + let dest = db_manager.get_db_path(uid); + if let Some(parent) = dest.parent() { + let _ = std::fs::create_dir_all(parent); + } + if std::fs::copy(db_path, &dest).is_ok() { + for ext in &["db-wal", "db-shm"] { + let src_extra = db_path.with_extension(ext); + if src_extra.exists() { + let dst_extra = dest.with_extension(ext); + let _ = std::fs::copy(&src_extra, &dst_extra); + } + } + db_migrated = true; + } + let conn = db_manager.get_connection(uid)?; + schema::initialize_schema(conn)?; + if !cfg!(debug_assertions) { schema::run_migrations(conn)?; } + } + } + + // Store secrets + let token_migrated = if let Some(ref t) = token { + key_manager::set_token(t).is_ok() + } else { false }; + + let key_migrated = if let Some(ref k) = encryption_key { + key_manager::set_user_encryption_key(&user_id, k).is_ok() + } else { false }; + + let pin_migrated = if let Some(ref p) = pin_hash { + key_manager::set_pin_hash(&user_id, p).is_ok() + } else { false }; + + key_manager::set_last_user_id(&user_id)?; + + let mut session_guard = session.lock() + .map_err(|e| AppError::Internal(format!("Session lock: {}", e)))?; + session_guard.user_id = Some(user_id.clone()); + + return Ok(AutoMigrationResult { + migrated: true, + user_id: Some(user_id), + token_migrated, + key_migrated, + pin_migrated, + db_migrated, + error: None, + }); + } + + Ok(AutoMigrationResult { + migrated: false, user_id: None, + token_migrated: false, key_migrated: false, + pin_migrated: false, db_migrated: false, + error: None, + }) +} + +/// Check if an Electron migration file + DB exist at the given path (legacy). +#[tauri::command] +pub fn check_electron_migration(migration_file_path: String) -> AppResult { + let path = PathBuf::from(&migration_file_path); + if !path.exists() { + return Ok(MigrationCheckResult { found: false, user_id: None, has_db: false, migration_path: None }); + } + let content = std::fs::read_to_string(&path).map_err(AppError::Io)?; + let migration: ElectronMigrationFile = serde_json::from_str(&content)?; + let db_name = format!("eritors-local-{}.db", migration.user_id); + let db_path = path.parent().unwrap_or(&path).join(&db_name); + Ok(MigrationCheckResult { + found: true, + user_id: Some(migration.user_id), + has_db: db_path.exists(), + migration_path: Some(migration_file_path), + }) +} + +/// Import from Electron export file (legacy). +#[tauri::command] +pub fn import_from_electron( + data: ImportMigrationData, + db: State, + session: State, +) -> AppResult { + let migration_path = PathBuf::from(&data.migration_file_path); + if !migration_path.exists() { + return Ok(MigrationResult { success: false, user_id: None, error: Some("Migration file not found".into()) }); + } + let content = std::fs::read_to_string(&migration_path)?; + let migration: ElectronMigrationFile = serde_json::from_str(&content)?; + if migration.version != 1 { + return Ok(MigrationResult { success: false, user_id: None, error: Some(format!("Unsupported version: {}", migration.version)) }); + } + let source_db = migration_path.parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join(format!("eritors-local-{}.db", migration.user_id)); + if !source_db.exists() { + let electron_db = PathBuf::from(&migration.db_source); + if !electron_db.exists() { + return Ok(MigrationResult { success: false, user_id: None, error: Some("Database file not found.".into()) }); + } + copy_db_files(&electron_db, &migration.user_id, &db)?; + } else { + copy_db_files(&source_db, &migration.user_id, &db)?; + } + key_manager::set_user_encryption_key(&migration.user_id, &migration.encryption_key)?; + if let Some(ref pin_hash) = migration.pin_hash { + key_manager::set_pin_hash(&migration.user_id, pin_hash)?; + } + key_manager::set_last_user_id(&migration.user_id)?; + { + let mut db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock: {}", e)))?; + db_manager.initialize(&migration.user_id)?; + let conn = db_manager.get_connection(&migration.user_id)?; + schema::initialize_schema(conn)?; + if !cfg!(debug_assertions) { schema::run_migrations(conn)?; } + } + let mut session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock: {}", e)))?; + session_guard.user_id = Some(migration.user_id.clone()); + cleanup_migration_files(&migration_path, &migration.user_id); + Ok(MigrationResult { success: true, user_id: Some(migration.user_id), error: None }) +} + +fn cleanup_migration_files(migration_path: &PathBuf, user_id: &str) { + let _ = std::fs::remove_file(migration_path); + if let Some(parent) = migration_path.parent() { + let db_copy = parent.join(format!("eritors-local-{}.db", user_id)); + let _ = std::fs::remove_file(&db_copy); + let _ = std::fs::remove_file(db_copy.with_extension("db-wal")); + let _ = std::fs::remove_file(db_copy.with_extension("db-shm")); + } +} + +fn copy_db_files(source_db: &PathBuf, user_id: &str, db: &State) -> AppResult<()> { + let db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock: {}", e)))?; + let dest_db = db_manager.get_db_path(user_id); + if let Some(parent) = dest_db.parent() { std::fs::create_dir_all(parent)?; } + std::fs::copy(source_db, &dest_db)?; + for ext in &["db-wal", "db-shm"] { + let src = source_db.with_extension(ext); + if src.exists() { + let dst = dest_db.with_file_name(format!("eritors-local-{}.{}", user_id, ext)); + let _ = std::fs::copy(&src, &dst); + } + } + Ok(()) +} diff --git a/src-tauri/src/domains/migration/mod.rs b/src-tauri/src/domains/migration/mod.rs new file mode 100644 index 0000000..82b6da3 --- /dev/null +++ b/src-tauri/src/domains/migration/mod.rs @@ -0,0 +1 @@ +pub mod commands; diff --git a/src-tauri/src/shared/crash_reporter.rs b/src-tauri/src/shared/crash_reporter.rs new file mode 100644 index 0000000..41508af --- /dev/null +++ b/src-tauri/src/shared/crash_reporter.rs @@ -0,0 +1,56 @@ +use serde_json::json; +use std::panic; + +const API_URL: &str = if cfg!(debug_assertions) { "http://localhost:3001/" } else { "https://api.eritors.com/" }; +const APP_NAME: &str = "Eritors Scribe"; +const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + +fn get_platform() -> &'static str { + if cfg!(target_os = "macos") { "desktop-macos" } + else if cfg!(target_os = "windows") { "desktop-windows" } + else if cfg!(target_os = "linux") { "desktop-linux" } + else { "desktop" } +} + +fn get_os_version() -> String { + format!("{} {}", std::env::consts::OS, std::env::consts::ARCH) +} + +fn send_crash_report(error_type: &str, error_message: &str, stack_trace: &str) { + let payload = json!({ + "appName": APP_NAME, + "appVersion": APP_VERSION, + "platform": get_platform(), + "osVersion": get_os_version(), + "errorType": error_type, + "errorMessage": error_message, + "stackTrace": stack_trace, + }); + + let url = format!("{}crash-report", API_URL); + let _ = reqwest::blocking::Client::new() + .post(&url) + .json(&payload) + .timeout(std::time::Duration::from_secs(5)) + .send(); +} + +pub fn init_panic_hook() { + let default_hook = panic::take_hook(); + + panic::set_hook(Box::new(move |info| { + let message = if let Some(s) = info.payload().downcast_ref::<&str>() { + s.to_string() + } else if let Some(s) = info.payload().downcast_ref::() { + s.clone() + } else { + "Unknown panic".to_string() + }; + + let location = info.location().map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())).unwrap_or_default(); + let stack_trace = format!("panic at {}\n{}", location, std::backtrace::Backtrace::force_capture()); + + send_crash_report("RustPanic", &message, &stack_trace); + default_hook(info); + })); +}