From 2b6d4cc48ba8342ce1f857d2b812fb89b2905302 Mon Sep 17 00:00:00 2001 From: natreex Date: Sun, 5 Apr 2026 11:36:12 -0400 Subject: [PATCH] Update book handling and improve offline/online sync logic - Enhanced the `BookProps` struct with updated field mappings for better API compatibility. - Improved offline/online sync workflows in `BookList` by adding `localBook` property handling and new item count methods for segmented tracking of local/online items. - Updated click handlers in `BookList` to fetch data based on connectivity state and prioritize local data when offline. - Refactored the decryption and vault handling logic in Rust to remove obsolete legacy methods and standardize debug behavior. - Introduced `ScribeShell` layout component with foundational logic for book/chapter syncing and offline queue handling. - Added `init_panic_hook` to improve crash reporting during Rust app initialization. --- components/book/BookList.tsx | 107 +++++- components/layout/ScribeShell.tsx | 470 ++++++++++++++++++++++++++ src-tauri/src/crypto/key_manager.rs | 50 +-- src-tauri/src/domains/book/service.rs | 4 + src-tauri/src/lib.rs | 3 + src-tauri/src/shared/mod.rs | 1 + 6 files changed, 585 insertions(+), 50 deletions(-) create mode 100644 components/layout/ScribeShell.tsx diff --git a/components/book/BookList.tsx b/components/book/BookList.tsx index 7b6fda1..96ccbf3 100644 --- a/components/book/BookList.tsx +++ b/components/book/BookList.tsx @@ -6,9 +6,10 @@ import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; import {AlertContext, AlertContextProps} from "@/context/AlertContext"; import SearchBook from "./SearchBook"; import {useRouter} from "@/lib/navigation"; -import {Book, ChevronLeft, ChevronRight, Download, Settings, Trash2} from 'lucide-react'; +import {Book, ChevronLeft, ChevronRight, Cloud, Download, HardDrive, Settings, Trash2} from 'lucide-react'; import Badge from "@/components/ui/Badge"; import {SessionContext, SessionContextProps} from "@/context/SessionContext"; +import {BookContext, BookContextProps} from "@/context/BookContext"; import {BookProps, BookTypeLimit} from "@/lib/types/book"; import {getBookTypeLabel} from "@/lib/utils/book"; import BookCard from "@/components/book/BookCard"; @@ -41,6 +42,7 @@ export default function BookList() { serverOnlyBooks, localOnlyBooks }: BooksSyncContextProps = useContext(BooksSyncContext); const {isCurrentlyOffline, offlineMode}: OfflineContextType = useContext(OfflineContext); + const {setBook}: BookContextProps = useContext(BookContext); const [searchQuery, setSearchQuery] = useState(''); const [groupedItems, setGroupedItems] = useState>({}); @@ -215,11 +217,11 @@ export default function BookList() { }); // Transformer les livres avec leur image - const transformedBooks: BookProps[] = booksResponse.map((book: BookProps & { bookType?: string }): BookProps => { + const transformedBooks: BookProps[] = booksResponse.map((book: BookProps): BookProps => { const imageDataUrl: string = book.coverImage ? 'data:image/jpeg;base64,' + book.coverImage : ''; return { bookId: book.bookId, - type: book.type || book.bookType || '', + type: book.type, title: book.title, subTitle: book.subTitle, summary: book.summary, @@ -228,6 +230,7 @@ export default function BookList() { desiredWordCount: book.desiredWordCount, totalWordCount: 0, coverImage: imageDataUrl, + localBook: book.localBook, }; }); @@ -338,13 +341,21 @@ export default function BookList() { return filtered; } - function getTotalItemsCount(items: CategoryItem[]): number { + function getOnlineItemsCount(items: CategoryItem[]): number { return items.reduce((count: number, item: CategoryItem): number => { - if (item.type === 'book') { - return count + 1; - } + if (item.type === 'book' && !item.book?.localBook) return count + 1; if (item.type === 'series' && item.series) { - return count + item.series.books.length; + return count + item.series.books.filter((b: BookProps): boolean => !b.localBook).length; + } + return count; + }, 0); + } + + function getLocalItemsCount(items: CategoryItem[]): number { + return items.reduce((count: number, item: CategoryItem): number => { + if (item.type === 'book' && item.book?.localBook) return count + 1; + if (item.type === 'series' && item.series) { + return count + item.series.books.filter((b: BookProps): boolean => !!b.localBook).length; } return count; }, 0); @@ -367,8 +378,60 @@ export default function BookList() { return 'synced'; } - function handleBookClick(bookId: string): void { - router.push(`/book/${bookId}`); + async function handleBookClick(bookId: string): Promise { + try { + let localBookOnly: boolean = false; + let bookResponse: BookProps | null = null; + + if (isCurrentlyOffline()) { + if (!offlineMode.isDatabaseInitialized) { + errorMessage(t("bookList.errorBookDetails")); + return; + } + bookResponse = await tauri.getBookBasicInformation(bookId); + if (bookResponse) localBookOnly = true; + } else { + const isOfflineBook = localOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId); + if (isOfflineBook) { + bookResponse = await tauri.getBookBasicInformation(bookId); + localBookOnly = true; + } + if (!bookResponse) { + bookResponse = await apiGet( + 'book/basic-information', accessToken, lang, {id: bookId} + ); + } + } + + if (!bookResponse) { + errorMessage(t("bookList.errorBookDetails")); + return; + } + + setBook({ + bookId: bookId, + type: bookResponse.type, + title: bookResponse.title || '', + subTitle: bookResponse.subTitle || '', + summary: bookResponse.summary || '', + serie: bookResponse.serie, + seriesId: bookResponse.seriesId, + publicationDate: bookResponse.publicationDate || '', + desiredWordCount: bookResponse.desiredWordCount || 0, + totalWordCount: bookResponse.totalWordCount ?? 0, + localBook: localBookOnly, + coverImage: bookResponse.coverImage ? 'data:image/jpeg;base64,' + bookResponse.coverImage : '', + quillsenseEnabled: bookResponse.quillsenseEnabled, + tools: bookResponse.tools, + }); + router.push(`/book/${bookId}`); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("bookList.errorBookDetails")); + } + } } function handleSeriesSettingsClick(seriesId: string): void { @@ -429,7 +492,8 @@ export default function BookList() { {Object.entries(filteredItems).map(([category, items]: [string, CategoryItem[]], index: number) => { - const itemCount: number = getTotalItemsCount(items); + const onlineCount: number = getOnlineItemsCount(items); + const localCount: number = getLocalItemsCount(items); const typeLimit: BookTypeLimit | undefined = bookLimits?.[category] ?? undefined; const isLimitReached: boolean = typeLimit !== undefined && typeLimit.current >= typeLimit.max; const categoryLabel: string = t(getBookTypeLabel(category)); @@ -442,12 +506,21 @@ export default function BookList() { {categoryLabel} - - {typeLimit !== undefined - ? `${typeLimit.current}/${typeLimit.max}` - : itemCount - } {t("bookList.works")} - +
+ {!isCurrentlyOffline() && ( + + {typeLimit !== undefined + ? `${typeLimit.current}/${typeLimit.max}` + : onlineCount + } + + )} + {isDesktop && localCount > 0 && ( + + {localCount} + + )} +
diff --git a/components/layout/ScribeShell.tsx b/components/layout/ScribeShell.tsx new file mode 100644 index 0000000..7f49aa4 --- /dev/null +++ b/components/layout/ScribeShell.tsx @@ -0,0 +1,470 @@ +import React, {ReactNode, useCallback, useContext, useEffect, useRef, useState} from 'react'; +import {BookContext} from '@/context/BookContext'; +import {ChapterProps} from '@/lib/types/chapter'; +import {ChapterContext} from '@/context/ChapterContext'; +import {EditorContext} from '@/context/EditorContext'; +import {Editor, useEditor} from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import Underline from '@tiptap/extension-underline'; +import TextAlign from '@tiptap/extension-text-align'; +import {AlertContext, AlertContextProps, AlertProvider} from '@/context/AlertContext'; +import {usePathname} from '@/lib/navigation'; +import {apiPost} from '@/lib/api/client'; +import {getCookie} from '@/lib/utils/cookies'; +import {SessionContext, SessionContextProps} from '@/context/SessionContext'; +import {BookProps} from '@/lib/types/book'; +import ScribeControllerBar from '@/components/layout/ScribeControllerBar'; +import ScribeLeftBar from '@/components/leftbar/ScribeLeftBar'; +import ComposerRightBar from '@/components/rightbar/ComposerRightBar'; +import ScribeFooterBar from '@/components/layout/ScribeFooterBar'; +import GuideTour from '@/components/GuideTour'; +import TermsOfUse from '@/components/TermsOfUse'; +import {useTranslations, changeLanguage} from '@/lib/i18n'; +import {isSupportedLocale, LangContext, LangContextProps, SupportedLocale} from '@/context/LangContext'; +import {ThemeContext} from '@/context/ThemeContext'; +import useTheme from '@/hooks/useTheme'; +import {AIUsageContext} from '@/context/AIUsageContext'; +import {BookSyncCompare, compareBookSyncs, SyncedBook} from '@/lib/types/synced-book'; +import {SeriesSyncCompare, compareSeriesSyncs, SyncedSeries} from '@/lib/types/synced-series'; +import {BooksSyncContext} from '@/context/BooksSyncContext'; +import {SeriesSyncContext} from '@/context/SeriesSyncContext'; +import {LocalSyncQueueContext, LocalSyncOperation} from '@/context/SyncQueueContext'; +import useAuthentication from '@/hooks/useAuthentication'; +import useOnboarding from '@/hooks/useOnboarding'; +import {SettingBookContext} from '@/context/SettingBookContext'; +import PulseLoader from '@/components/ui/PulseLoader'; +import {initCrashReporter} from '@/lib/crashReporter'; +import OfflineProvider from '@/context/OfflineProvider'; +import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; +import OfflinePinSetup from '@/components/offline/OfflinePinSetup'; +import OfflinePinVerify from '@/components/offline/OfflinePinVerify'; +import {isDesktop} from '@/lib/configs'; +import * as tauri from '@/lib/tauri'; +import useSyncBooks from '@/hooks/useSyncBooks'; +import useSyncSeries from '@/hooks/useSyncSeries'; + +function AutoSyncOnReconnect() { + const {offlineMode}: OfflineContextType = useContext(OfflineContext); + const {session}: SessionContextProps = useContext(SessionContext); + const {syncAllToServer: syncAllBooksToServer, syncAllFromServer: syncAllBooksFromServer, refreshBooks, booksToSyncToServer, booksToSyncFromServer} = useSyncBooks(); + const {syncAllToServer: syncAllSeriesToServer, syncAllFromServer: syncAllSeriesFromServer, refreshSeries, seriesToSyncToServer, seriesToSyncFromServer} = useSyncSeries(); + const isSyncingRef = useRef(false); + const hasRefreshedRef = useRef(false); + + const saveLastOnlineTimestamp = useCallback((): void => { + const timestamp: number = Math.floor(Date.now() / 1000); + localStorage.setItem('lastOnlineTimestamp', timestamp.toString()); + }, []); + + // Refresh sync data when online + authenticated + DB ready + useEffect((): void => { + if (!offlineMode.isOffline && session.isConnected && offlineMode.isDatabaseInitialized) { + hasRefreshedRef.current = true; + Promise.all([refreshBooks(), refreshSeries()]); + } + }, [offlineMode.isOffline, session.isConnected, offlineMode.isDatabaseInitialized]); + + // Auto-sync when diffs become available + useEffect((): void => { + if (offlineMode.isOffline || !session.isConnected || isSyncingRef.current || !hasRefreshedRef.current) return; + + const syncPromises: Promise[] = []; + + if (booksToSyncToServer.length > 0) syncPromises.push(syncAllBooksToServer()); + if (booksToSyncFromServer.length > 0) syncPromises.push(syncAllBooksFromServer()); + if (seriesToSyncToServer.length > 0) syncPromises.push(syncAllSeriesToServer()); + if (seriesToSyncFromServer.length > 0) syncPromises.push(syncAllSeriesFromServer()); + + if (syncPromises.length > 0) { + isSyncingRef.current = true; + Promise.all(syncPromises).then((): void => { + saveLastOnlineTimestamp(); + isSyncingRef.current = false; + }).catch((): void => { + isSyncingRef.current = false; + }); + } + }, [booksToSyncToServer, booksToSyncFromServer, seriesToSyncToServer, seriesToSyncFromServer]); + + // Update lastOnlineTimestamp every 5 minutes while online + useEffect((): (() => void) | void => { + if (!offlineMode.isOffline && session.isConnected) { + const intervalId: ReturnType = setInterval((): void => { + saveLastOnlineTimestamp(); + }, 5 * 60 * 1000); + return (): void => clearInterval(intervalId); + } + }, [offlineMode.isOffline, session.isConnected, saveLastOnlineTimestamp]); + + return null; +} + +function ScribeContent({children}: { children: ReactNode }) { + const t = useTranslations(); + const {lang: locale}: LangContextProps = useContext(LangContext); + const {errorMessage}: AlertContextProps = useContext(AlertContext); + const {isCurrentlyOffline, offlineMode}: OfflineContextType = useContext(OfflineContext); + + const { + session, setSession, isLoading, + currentCredits, setCurrentCredits, + amountSpent, setAmountSpent, + showPinSetup, setShowPinSetup, + showPinVerify, setShowPinVerify, + handlePinVerifySuccess + } = useAuthentication(); + + const { + isTermsAccepted, homeStepsGuide, setHomeStepsGuide, + handleTermsAcceptance, handleHomeTour, homeSteps + } = useOnboarding({session, setSession}); + + const editor: Editor | null = useEditor({ + extensions: [ + StarterKit, + Underline, + TextAlign.configure({ + types: ['heading', 'paragraph'], + }), + ], + injectCSS: false, + immediatelyRender: false, + shouldRerenderOnTransaction: true, + }); + + const pathname: string = usePathname(); + const [currentChapter, setCurrentChapter] = useState(undefined); + const [currentBook, setCurrentBook] = useState(null); + const [bookSettingId, setBookSettingId] = useState(''); + + const [serverSyncedBooks, setServerSyncedBooks] = useState([]); + const [localSyncedBooks, setLocalSyncedBooks] = useState([]); + const [bookSyncDiffsFromServer, setBookSyncDiffsFromServer] = useState([]); + const [bookSyncDiffsToServer, setBookSyncDiffsToServer] = useState([]); + const [serverOnlyBooks, setServerOnlyBooks] = useState([]); + const [localOnlyBooks, setLocalOnlyBooks] = useState([]); + + const [serverSyncedSeries, setServerSyncedSeries] = useState([]); + const [localSyncedSeries, setLocalSyncedSeries] = useState([]); + const [seriesSyncDiffsFromServer, setSeriesSyncDiffsFromServer] = useState([]); + const [seriesSyncDiffsToServer, setSeriesSyncDiffsToServer] = useState([]); + const [serverOnlySeries, setServerOnlySeries] = useState([]); + const [localOnlySeries, setLocalOnlySeries] = useState([]); + + const [localSyncQueue, setLocalSyncQueue] = useState([]); + const [isQueueProcessing, setIsQueueProcessing] = useState(false); + + function addToLocalSyncQueue(channel: string, data: Record): void { + const operation: LocalSyncOperation = { + id: `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, + channel, + data, + timestamp: Date.now(), + }; + setLocalSyncQueue((prev: LocalSyncOperation[]): LocalSyncOperation[] => [...prev, operation]); + } + + useEffect((): void => { + if (localSyncQueue.length === 0 || isQueueProcessing) return; + + async function processQueue(): Promise { + setIsQueueProcessing(true); + const queueCopy: LocalSyncOperation[] = [...localSyncQueue]; + for (const operation of queueCopy) { + try { + await tauri.invoke(operation.channel, {data: operation.data}); + setLocalSyncQueue((prev: LocalSyncOperation[]): LocalSyncOperation[] => + prev.filter((op: LocalSyncOperation): boolean => op.id !== operation.id) + ); + } catch (_e) { /* queue retry on next cycle */ } + } + setIsQueueProcessing(false); + } + + processQueue().then(); + }, [localSyncQueue, isQueueProcessing]); + + useEffect((): void => { + if (pathname === '/') { + setCurrentBook(null); + setCurrentChapter(undefined); + } + }, [pathname]); + + useEffect((): void => { + if (!session.isConnected) return; + // On desktop with DB initialized, AutoSyncOnReconnect handles fetching + tombstone exchange + if (isDesktop && offlineMode.isDatabaseInitialized) return; + getBooks().then(); + getSeries().then(); + }, [session, offlineMode.isDatabaseInitialized]); + + useEffect((): void => { + const diffsFromServer: BookSyncCompare[] = []; + const diffsToServer: BookSyncCompare[] = []; + + serverSyncedBooks.forEach((serverBook: SyncedBook): void => { + const localBook: SyncedBook | undefined = localSyncedBooks.find((book: SyncedBook): boolean => book.id === serverBook.id); + if (!localBook) return; + const diff: BookSyncCompare | null = compareBookSyncs(serverBook, localBook); + if (diff) diffsFromServer.push(diff); + }); + + localSyncedBooks.forEach((localBook: SyncedBook): void => { + const serverBook: SyncedBook | undefined = serverSyncedBooks.find((book: SyncedBook): boolean => book.id === localBook.id); + if (!serverBook) return; + const diff: BookSyncCompare | null = compareBookSyncs(localBook, serverBook); + if (diff) diffsToServer.push(diff); + }); + + setBookSyncDiffsFromServer(diffsFromServer); + setBookSyncDiffsToServer(diffsToServer); + setServerOnlyBooks(serverSyncedBooks.filter((serverBook: SyncedBook): boolean => !localSyncedBooks.find((localBook: SyncedBook): boolean => localBook.id === serverBook.id))); + setLocalOnlyBooks(localSyncedBooks.filter((localBook: SyncedBook): boolean => !serverSyncedBooks.find((serverBook: SyncedBook): boolean => serverBook.id === localBook.id))); + }, [localSyncedBooks, serverSyncedBooks]); + + useEffect((): void => { + const diffsFromServer: SeriesSyncCompare[] = []; + const diffsToServer: SeriesSyncCompare[] = []; + + serverSyncedSeries.forEach((serverSeries: SyncedSeries): void => { + const localSeries: SyncedSeries | undefined = localSyncedSeries.find((series: SyncedSeries): boolean => series.id === serverSeries.id); + if (!localSeries) return; + const diff: SeriesSyncCompare | null = compareSeriesSyncs(serverSeries, localSeries); + if (diff) diffsFromServer.push(diff); + }); + + localSyncedSeries.forEach((localSeries: SyncedSeries): void => { + const serverSeries: SyncedSeries | undefined = serverSyncedSeries.find((series: SyncedSeries): boolean => series.id === localSeries.id); + if (!serverSeries) return; + const diff: SeriesSyncCompare | null = compareSeriesSyncs(localSeries, serverSeries); + if (diff) diffsToServer.push(diff); + }); + + setSeriesSyncDiffsFromServer(diffsFromServer); + setSeriesSyncDiffsToServer(diffsToServer); + setServerOnlySeries(serverSyncedSeries.filter((s: SyncedSeries): boolean => !localSyncedSeries.find((l: SyncedSeries): boolean => l.id === s.id))); + setLocalOnlySeries(localSyncedSeries.filter((l: SyncedSeries): boolean => !serverSyncedSeries.find((s: SyncedSeries): boolean => s.id === l.id))); + }, [localSyncedSeries, serverSyncedSeries]); + + async function getBooks(): Promise { + try { + let localBooksResponse: SyncedBook[] = []; + let serverBooksResponse: SyncedBook[] = []; + + if (!isCurrentlyOffline()) { + if (isDesktop && offlineMode.isDatabaseInitialized) { + localBooksResponse = await tauri.getSyncedBooks() as SyncedBook[]; + + const lastOnlineStr: string | null = localStorage.getItem('lastOnlineTimestamp'); + const lastOnlineTimestamp: number = lastOnlineStr ? parseInt(lastOnlineStr, 10) : 0; + + const localTombstones: tauri.TombstoneRecord[] = await tauri.getTombstonesSince(lastOnlineTimestamp) as tauri.TombstoneRecord[]; + + const serverResponse: { books: SyncedBook[]; tombstones: tauri.TombstoneRecord[] } = await apiPost<{ + books: SyncedBook[]; tombstones: tauri.TombstoneRecord[]; + }>('books/synced', { lastOnlineTimestamp, tombstones: localTombstones }, session.accessToken, locale); + + serverBooksResponse = serverResponse.books; + await tauri.applyBookTombstones(serverResponse.tombstones ?? []); + } else { + const serverResponse: { books: SyncedBook[] } = await apiPost<{ + books: SyncedBook[] + }>('books/synced', { lastOnlineTimestamp: 0, tombstones: [] }, session.accessToken, locale); + if (serverResponse) serverBooksResponse = serverResponse.books; + } + } else { + if (isDesktop && offlineMode.isDatabaseInitialized) { + localBooksResponse = await tauri.getSyncedBooks() as SyncedBook[]; + } + } + + setServerSyncedBooks(serverBooksResponse); + setLocalSyncedBooks(localBooksResponse); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('homePage.errors.fetchBooksError')); + } + } + } + + async function getSeries(): Promise { + try { + let localSeriesResponse: SyncedSeries[] = []; + let serverSeriesResponse: SyncedSeries[] = []; + + if (!isCurrentlyOffline()) { + if (isDesktop && offlineMode.isDatabaseInitialized) { + localSeriesResponse = await tauri.getSyncedSeries() as SyncedSeries[]; + + const lastOnlineStr: string | null = localStorage.getItem('lastOnlineTimestamp'); + const lastOnlineTimestamp: number = lastOnlineStr ? parseInt(lastOnlineStr, 10) : 0; + + const localTombstones: tauri.TombstoneRecord[] = await tauri.getTombstonesSince(lastOnlineTimestamp) as tauri.TombstoneRecord[]; + + const serverResponse: { series: SyncedSeries[]; tombstones: tauri.TombstoneRecord[] } = await apiPost<{ + series: SyncedSeries[]; tombstones: tauri.TombstoneRecord[]; + }>('series/synced', { lastOnlineTimestamp, tombstones: localTombstones }, session.accessToken, locale); + + serverSeriesResponse = serverResponse.series; + await tauri.applySeriesTombstones(serverResponse.tombstones ?? []); + } else { + const serverResponse: { series: SyncedSeries[] } = await apiPost<{ + series: SyncedSeries[] + }>('series/synced', { lastOnlineTimestamp: 0, tombstones: [] }, session.accessToken, locale); + if (serverResponse) serverSeriesResponse = serverResponse.series; + } + } else { + if (isDesktop && offlineMode.isDatabaseInitialized) { + localSeriesResponse = await tauri.getSyncedSeries() as SyncedSeries[]; + } + } + + setServerSyncedSeries(serverSeriesResponse); + setLocalSyncedSeries(localSeriesResponse); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('homePage.errors.fetchBooksError')); + } + } + } + + if (isLoading) { + return ( +
+
+
+ ERitors Logo +
+ +
+
+ ); + } + + return ( + + + + + {isDesktop && } + + + + +
+ + +
+ +
+ {children} +
+ +
+ +
+
+ {homeStepsGuide && + setHomeStepsGuide(false)}/> + } + {!isCurrentlyOffline() && !isTermsAccepted && } + {isDesktop && showPinSetup && ( + setShowPinSetup(false)} + onSuccess={(): void => setShowPinSetup(false)} + /> + )} + {isDesktop && showPinVerify && ( + {}} + /> + )} +
+
+
+
+
+
+
+
+ ); +} + +export default function ScribeShell({children}: { children: ReactNode }) { + const [locale, setLocale] = useState('fr'); + const {theme, setTheme} = useTheme(); + + useEffect((): void => { + initCrashReporter(); + }, []); + + useEffect((): void => { + const lang: string | null = getCookie('lang'); + if (lang && isSupportedLocale(lang)) { + setLocale(lang); + } + }, []); + + useEffect((): void => { + changeLanguage(locale); + }, [locale]); + + return ( + + + + + {children} + + + + + ); +} diff --git a/src-tauri/src/crypto/key_manager.rs b/src-tauri/src/crypto/key_manager.rs index b1e07ea..5eb858a 100644 --- a/src-tauri/src/crypto/key_manager.rs +++ b/src-tauri/src/crypto/key_manager.rs @@ -6,7 +6,6 @@ use aes::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use rand::RngCore; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use crate::error::{AppError, AppResult}; @@ -44,9 +43,6 @@ const KEYRING_USER: &str = "vault-key"; /// Falls back to the old derivation method if the keyring is unavailable, /// and attempts to migrate the key into the keyring for next time. fn get_vault_key() -> [u8; 32] { - if cfg!(debug_assertions) { - return derive_machine_key_legacy(); - } let entry = keyring::Entry::new(SERVICE_NAME, KEYRING_USER); if let Ok(entry) = &entry { if let Ok(stored) = entry.get_password() { @@ -68,19 +64,6 @@ fn get_vault_key() -> [u8; 32] { key } -/// Legacy derivation for migrating vaults created before keyring support. -fn derive_machine_key_legacy() -> [u8; 32] { - let mut hasher = Sha256::new(); - hasher.update(SERVICE_NAME.as_bytes()); - if let Ok(name) = hostname::get() { - hasher.update(name.as_encoded_bytes()); - } - if let Some(dir) = dirs_next::home_dir() { - hasher.update(dir.to_string_lossy().as_bytes()); - } - hasher.finalize().into() -} - fn encrypt_vault(data: &[u8], key: &[u8; 32]) -> AppResult> { let mut iv = [0u8; IV_LENGTH]; rand::rng().fill_bytes(&mut iv); @@ -119,6 +102,12 @@ fn read_vault() -> AppResult { } let content = fs::read_to_string(&path) .map_err(|e| AppError::Keyring(format!("Failed to read vault: {}", e)))?; + + if cfg!(debug_assertions) { + return serde_json::from_str::(&content) + .map_err(|e| AppError::Keyring(format!("Vault JSON parse error: {}", e))); + } + let raw = BASE64.decode(content.trim()) .map_err(|e| AppError::Keyring(format!("Vault corrupted (base64): {}", e)))?; @@ -129,34 +118,29 @@ fn read_vault() -> AppResult { } } - let legacy_key = derive_machine_key_legacy(); - if let Ok(decrypted) = decrypt_vault(&raw, &legacy_key) { - if let Ok(vault) = serde_json::from_slice::(&decrypted) { - let _ = write_vault_with_key(&vault, &key); - return Ok(vault); - } - } - - Ok(SecureVault::default()) + Err(AppError::Keyring("Vault decryption failed — cannot read existing vault".into())) } -fn write_vault_with_key(vault: &SecureVault, key: &[u8; 32]) -> AppResult<()> { +fn write_vault(vault: &SecureVault) -> AppResult<()> { let path = vault_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| AppError::Internal(format!("Failed to create vault dir: {}", e)))?; } + + if cfg!(debug_assertions) { + let json = serde_json::to_string_pretty(vault) + .map_err(|e| AppError::Internal(format!("Failed to serialize vault: {}", e)))?; + return fs::write(&path, json).map_err(|e| AppError::Internal(format!("Failed to write vault: {}", e))); + } + + let key = get_vault_key(); let json = serde_json::to_string(vault) .map_err(|e| AppError::Internal(format!("Failed to serialize vault: {}", e)))?; - let encrypted = encrypt_vault(json.as_bytes(), key)?; + let encrypted = encrypt_vault(json.as_bytes(), &key)?; let encoded = BASE64.encode(&encrypted); fs::write(&path, encoded).map_err(|e| AppError::Internal(format!("Failed to write vault: {}", e))) } -fn write_vault(vault: &SecureVault) -> AppResult<()> { - let key = get_vault_key(); - write_vault_with_key(vault, &key) -} - // ===== Public API (same signatures as before) ===== pub fn get_user_encryption_key(user_id: &str) -> AppResult { diff --git a/src-tauri/src/domains/book/service.rs b/src-tauri/src/domains/book/service.rs index f23331b..85c227a 100644 --- a/src-tauri/src/domains/book/service.rs +++ b/src-tauri/src/domains/book/service.rs @@ -35,15 +35,19 @@ pub struct BookToolsSettings { #[serde(rename_all = "camelCase")] pub struct BookProps { pub book_id: String, + #[serde(rename = "type")] pub book_type: String, pub author_id: String, pub title: String, pub sub_title: String, pub summary: String, + #[serde(rename = "serie")] pub serie_id: i64, pub series_id: Option, + #[serde(rename = "publicationDate")] pub desired_release_date: String, pub desired_word_count: i64, + #[serde(rename = "totalWordCount")] pub word_count: i64, pub cover_image: String, pub book_meta: Option, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8dbe764..29e80c2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,10 +6,13 @@ mod helpers; mod shared; use db::connection::create_db_manager; +use shared::crash_reporter::init_panic_hook; use shared::session::create_session; use std::path::PathBuf; pub fn run() { + init_panic_hook(); + let app_data_dir = dirs_next::data_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("com.eritors.scribe.desktop"); diff --git a/src-tauri/src/shared/mod.rs b/src-tauri/src/shared/mod.rs index a3f9952..8b1b8ba 100644 --- a/src-tauri/src/shared/mod.rs +++ b/src-tauri/src/shared/mod.rs @@ -1,2 +1,3 @@ +pub mod crash_reporter; pub mod session; pub mod types;