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);
+ }));
+}