From 44885f290138abf7469e26bf243b354259a4daec Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 1 Feb 2025 12:17:21 +0100 Subject: [PATCH] sync: add download/upload progress indicator (#277) * sync: add download/upload progress indicator * chore: apt update before installing system dependencies --- .github/workflows/pull-request.yml | 6 +- Cargo.lock | 17 ++ apps/readest-app/src-tauri/Cargo.toml | 9 + apps/readest-app/src-tauri/src/lib.rs | 9 +- .../src-tauri/src/transfer_file.rs | 203 ++++++++++++++++++ .../src/app/library/components/BookItem.tsx | 56 +++-- .../src/app/library/components/Bookshelf.tsx | 15 +- apps/readest-app/src/app/library/page.tsx | 54 ++++- .../app/reader/components/tts/TTSControl.tsx | 2 +- .../src/components/BookDetailModal.tsx | 7 +- apps/readest-app/src/libs/storage.ts | 66 ++++-- apps/readest-app/src/services/appService.ts | 111 +++++++--- apps/readest-app/src/store/libraryStore.ts | 13 +- apps/readest-app/src/types/system.ts | 5 +- .../src/utils/{ui.ts => throttle.ts} | 14 +- apps/readest-app/src/utils/transfer.ts | 125 +++++++++++ 16 files changed, 607 insertions(+), 105 deletions(-) create mode 100644 apps/readest-app/src-tauri/src/transfer_file.rs rename apps/readest-app/src/utils/{ui.ts => throttle.ts} (66%) create mode 100644 apps/readest-app/src/utils/transfer.ts diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index aad515d0..56acaf77 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -9,7 +9,7 @@ jobs: rust_lint: runs-on: ubuntu-latest env: - RUSTFLAGS: "-C target-cpu=skylake" + RUSTFLAGS: '-C target-cpu=skylake' steps: - uses: actions/checkout@v4 with: @@ -20,7 +20,9 @@ jobs: toolchain: stable override: true - name: Install system dependencies - run: sudo apt-get install -y libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libsoup-3.0-dev + run: | + sudo apt-get update + sudo apt-get install -y libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libsoup-3.0-dev - name: Format working-directory: apps/readest-app/src-tauri run: cargo fmt --check diff --git a/Cargo.lock b/Cargo.lock index 7ac114ac..76952005 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7,9 +7,12 @@ name = "Readest" version = "0.2.1" dependencies = [ "cocoa 0.25.0", + "futures-util", "log", "objc", "rand 0.8.5", + "read-progress-stream", + "reqwest", "serde", "serde_json", "tauri", @@ -29,6 +32,9 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tauri-plugin-window-state", + "thiserror 2.0.11", + "tokio", + "tokio-util", ] [[package]] @@ -3941,6 +3947,17 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "read-progress-stream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6435842fc2fea44b528719eb8c32203bbc1bb2f5b619fbe0c0a3d8350fd8d2a8" +dependencies = [ + "bytes", + "futures", + "pin-project-lite", +] + [[package]] name = "redox_syscall" version = "0.5.8" diff --git a/apps/readest-app/src-tauri/Cargo.toml b/apps/readest-app/src-tauri/Cargo.toml index 2784c45d..31440ff5 100644 --- a/apps/readest-app/src-tauri/Cargo.toml +++ b/apps/readest-app/src-tauri/Cargo.toml @@ -21,6 +21,15 @@ tauri-build = { version = "2.0.3", features = [] } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" +thiserror = "2" +tokio = { version = "1", features = ["fs"] } +tokio-util = { version = "0.7", features = ["codec"] } +futures-util = "0.3" +read-progress-stream = "1.0.0" +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "stream", +] } # FIXME: remove the devtools feature in production tauri = { version = "2.1.1", features = [ "protocol-asset", "devtools"] } tauri-build = "2.0.3" diff --git a/apps/readest-app/src-tauri/src/lib.rs b/apps/readest-app/src-tauri/src/lib.rs index 354a63ca..30551e3a 100644 --- a/apps/readest-app/src-tauri/src/lib.rs +++ b/apps/readest-app/src-tauri/src/lib.rs @@ -11,6 +11,9 @@ mod menu; #[cfg(target_os = "macos")] mod traffic_light_plugin; +mod transfer_file; +use transfer_file::{download_file, upload_file}; + #[cfg(target_os = "macos")] use tauri::TitleBarStyle; @@ -80,7 +83,11 @@ pub fn run() { let builder = tauri::Builder::default() .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_oauth::init()) - .invoke_handler(tauri::generate_handler![start_server]) + .invoke_handler(tauri::generate_handler![ + start_server, + download_file, + upload_file + ]) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_http::init()) diff --git a/apps/readest-app/src-tauri/src/transfer_file.rs b/apps/readest-app/src-tauri/src/transfer_file.rs new file mode 100644 index 00000000..a58d098f --- /dev/null +++ b/apps/readest-app/src-tauri/src/transfer_file.rs @@ -0,0 +1,203 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Upload files from disk to a remote server over HTTP. +//! +//! Download files from a remote HTTP server to disk. + +use futures_util::TryStreamExt; +use serde::{ser::Serializer, Serialize}; +use tauri::{command, ipc::Channel}; +use tokio::{ + fs::File, + io::{AsyncWriteExt, BufWriter}, +}; +use tokio_util::codec::{BytesCodec, FramedRead}; + +use read_progress_stream::ReadProgressStream; + +use std::collections::HashMap; +use std::time::Instant; + +type Result = std::result::Result; + +// The TransferStats struct tracks both transfer speed and cumulative transfer progress. +pub struct TransferStats { + accumulated_chunk_len: usize, // Total length of chunks transferred in the current period + accumulated_time: u128, // Total time taken for the transfers in the current period + pub transfer_speed: u64, // Calculated transfer speed in bytes per second + pub total_transferred: u64, // Cumulative total of all transferred data + start_time: Instant, // Time when the current period started + granularity: u32, // Time period (in milliseconds) over which the transfer speed is calculated +} + +impl TransferStats { + // Initializes a new TransferStats instance with the specified granularity. + pub fn start(granularity: u32) -> Self { + Self { + accumulated_chunk_len: 0, + accumulated_time: 0, + transfer_speed: 0, + total_transferred: 0, + start_time: Instant::now(), + granularity, + } + } + // Records the transfer of a data chunk and updates both transfer speed and total progress. + pub fn record_chunk_transfer(&mut self, chunk_len: usize) { + let now = Instant::now(); + let it_took = now.duration_since(self.start_time).as_millis(); + self.accumulated_chunk_len += chunk_len; + self.total_transferred += chunk_len as u64; + self.accumulated_time += it_took; + + // Calculate transfer speed if accumulated time exceeds granularity. + if self.accumulated_time >= self.granularity as u128 { + self.transfer_speed = + (self.accumulated_chunk_len as u128 / self.accumulated_time * 1024) as u64; + self.accumulated_chunk_len = 0; + self.accumulated_time = 0; + } + + // Reset the start time for the next period. + self.start_time = now; + } +} + +// Provides a default implementation for TransferStats with a granularity of 500 milliseconds. +impl Default for TransferStats { + fn default() -> Self { + Self::start(500) // Default granularity is 500 ms + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Request(#[from] reqwest::Error), + #[error("{0}")] + ContentLength(String), + #[error("request failed with status code {0}: {1}")] + HttpErrorCode(u16, String), +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProgressPayload { + progress: u64, + total: u64, + transfer_speed: u64, +} + +#[command] +pub async fn download_file( + url: &str, + file_path: &str, + headers: HashMap, + body: Option, + on_progress: Channel, +) -> Result<()> { + let client = reqwest::Client::new(); + let mut request = if let Some(body) = body { + client.post(url).body(body) + } else { + client.get(url) + }; + // Loop trought the headers keys and values + // and add them to the request object. + for (key, value) in headers { + request = request.header(&key, value); + } + + let response = request.send().await?; + if !response.status().is_success() { + return Err(Error::HttpErrorCode( + response.status().as_u16(), + response.text().await.unwrap_or_default(), + )); + } + let total = response.content_length().unwrap_or(0); + + let mut file = BufWriter::new(File::create(file_path).await?); + let mut stream = response.bytes_stream(); + + let mut stats = TransferStats::default(); + while let Some(chunk) = stream.try_next().await? { + file.write_all(&chunk).await?; + stats.record_chunk_transfer(chunk.len()); + let _ = on_progress.send(ProgressPayload { + progress: stats.total_transferred, + total, + transfer_speed: stats.transfer_speed, + }); + } + file.flush().await?; + + Ok(()) +} + +#[command] +pub async fn upload_file( + url: &str, + file_path: &str, + method: &str, + headers: HashMap, + on_progress: Channel, +) -> Result { + let file = File::open(file_path).await?; + let file_len = file.metadata().await.unwrap().len(); + + let client = reqwest::Client::new(); + let mut request = match method.to_uppercase().as_str() { + "POST" => client.post(url), + "PUT" => client.put(url), + _ => return Err(Error::ContentLength("Invalid HTTP method".into())), + }; + + request = request + .header(reqwest::header::CONTENT_LENGTH, file_len) + .body(file_to_body(on_progress.clone(), file, file_len)); + + for (key, value) in headers { + request = request.header(&key, value); + } + + let response = request.send().await?; + if response.status().is_success() { + response.text().await.map_err(Into::into) + } else { + Err(Error::HttpErrorCode( + response.status().as_u16(), + response.text().await.unwrap_or_default(), + )) + } +} + +fn file_to_body(channel: Channel, file: File, file_len: u64) -> reqwest::Body { + let stream = FramedRead::new(file, BytesCodec::new()).map_ok(|r| r.freeze()); + + let mut stats = TransferStats::default(); + reqwest::Body::wrap_stream(ReadProgressStream::new( + stream, + Box::new(move |progress_chunk, _progress_total| { + stats.record_chunk_transfer(progress_chunk as usize); + let _ = channel.send(ProgressPayload { + progress: stats.total_transferred, + total: file_len, + transfer_speed: stats.transfer_speed, + }); + }), + )) +} diff --git a/apps/readest-app/src/app/library/components/BookItem.tsx b/apps/readest-app/src/app/library/components/BookItem.tsx index eb0186a0..c4f629e5 100644 --- a/apps/readest-app/src/app/library/components/BookItem.tsx +++ b/apps/readest-app/src/app/library/components/BookItem.tsx @@ -3,16 +3,17 @@ import Image from 'next/image'; import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md'; import { CiCircleMore } from 'react-icons/ci'; import { LiaCloudUploadAltSolid, LiaCloudDownloadAltSolid } from 'react-icons/lia'; -import { useResponsiveSize } from '@/hooks/useResponsiveSize'; -import { isWebAppPlatform } from '@/services/environment'; -import ReadingProgress from './ReadingProgress'; + import { Book } from '@/types/book'; +import { useResponsiveSize } from '@/hooks/useResponsiveSize'; +import ReadingProgress from './ReadingProgress'; interface BookItemProps { book: Book; isSelectMode: boolean; selectedBooks: string[]; clickedBookHash: string | null; + transferProgress: number | null; handleBookClick: (book: Book) => void; handleBookUpload: (book: Book) => void; handleBookDownload: (book: Book) => void; @@ -25,6 +26,7 @@ const BookItem: React.FC = ({ isSelectMode, selectedBooks, clickedBookHash, + transferProgress, handleBookClick, handleBookUpload, handleBookDownload, @@ -78,7 +80,7 @@ const BookItem: React.FC = ({ )} -
+

{book.title} @@ -89,21 +91,37 @@ const BookItem: React.FC = ({ > {book.progress && }
- + {transferProgress !== null ? ( + transferProgress === 100 ? null : ( +
+ ) + ) : ( + + )}
diff --git a/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx b/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx index 085633b5..3972a823 100644 --- a/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx +++ b/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx @@ -9,7 +9,7 @@ import { getPopupPosition, Position } from '@/utils/sel'; import { eventDispatcher } from '@/utils/event'; import { parseSSMLLang } from '@/utils/ssml'; import { getOSPlatform } from '@/utils/misc'; -import { throttle } from '@/utils/ui'; +import { throttle } from '@/utils/throttle'; import { isPWA } from '@/services/environment'; import Popup from '@/components/Popup'; import TTSPanel from './TTSPanel'; diff --git a/apps/readest-app/src/components/BookDetailModal.tsx b/apps/readest-app/src/components/BookDetailModal.tsx index b02dc3ab..db09cdbd 100644 --- a/apps/readest-app/src/components/BookDetailModal.tsx +++ b/apps/readest-app/src/components/BookDetailModal.tsx @@ -24,7 +24,7 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => { const [loading, setLoading] = useState(false); const [showDeleteAlert, setShowDeleteAlert] = useState(false); const [bookMeta, setBookMeta] = useState(null); - const { envConfig } = useEnv(); + const { envConfig, appService } = useEnv(); const { settings } = useSettingsStore(); const { updateBook } = useLibraryStore(); @@ -50,8 +50,9 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => { setShowDeleteAlert(true); }; - const confirmDelete = () => { - updateBook(envConfig, book, true); + const confirmDelete = async () => { + await appService?.deleteBook(book, !!book.uploadedAt); + await updateBook(envConfig, book); handleClose(); setShowDeleteAlert(false); }; diff --git a/apps/readest-app/src/libs/storage.ts b/apps/readest-app/src/libs/storage.ts index e0f131ec..3a6f242c 100644 --- a/apps/readest-app/src/libs/storage.ts +++ b/apps/readest-app/src/libs/storage.ts @@ -1,5 +1,13 @@ import { getAPIBaseUrl, isWebAppPlatform } from '@/services/environment'; import { getAccessToken, getUserID } from '@/utils/access'; +import { + tauriUpload, + tauriDownload, + webUpload, + webDownload, + ProgressHandler, + ProgressPayload, +} from '@/utils/transfer'; const API_ENDPOINTS = { upload: getAPIBaseUrl() + '/storage/upload', @@ -28,7 +36,31 @@ const fetchWithAuth = async (url: string, options: RequestInit) => { return response; }; -export const uploadFile = async (file: File, bookHash?: string) => { +export const createProgressHandler = ( + totalFiles: number, + completedFilesRef: { count: number }, + onProgress?: ProgressHandler, +) => { + return (progress: ProgressPayload) => { + const fileProgress = progress.progress / progress.total; + const overallProgress = ((completedFilesRef.count + fileProgress) / totalFiles) * 100; + + if (onProgress) { + onProgress({ + progress: overallProgress, + total: 100, + transferSpeed: progress.transferSpeed, + }); + } + }; +}; + +export const uploadFile = async ( + file: File, + fileFullPath: string, + onProgress?: ProgressHandler, + bookHash?: string, +) => { try { const response = await fetchWithAuth(API_ENDPOINTS.upload, { method: 'POST', @@ -43,17 +75,10 @@ export const uploadFile = async (file: File, bookHash?: string) => { }); const { uploadUrl } = await response.json(); - const fetch = isWebAppPlatform() - ? window.fetch - : (await import('@tauri-apps/plugin-http')).fetch; - const uploadResponse = await fetch(uploadUrl, { - method: 'PUT', - body: isWebAppPlatform() ? file : await file.arrayBuffer(), - }); - - if (!uploadResponse.ok) { - console.error('File upload failed:', await uploadResponse.text()); - throw new Error('File upload failed'); + if (isWebAppPlatform()) { + await webUpload(file, uploadUrl, onProgress); + } else { + await tauriUpload(uploadUrl, fileFullPath, 'PUT', onProgress); } } catch (error) { console.error('File upload failed:', error); @@ -61,7 +86,11 @@ export const uploadFile = async (file: File, bookHash?: string) => { } }; -export const downloadFile = async (filePath: string) => { +export const downloadFile = async ( + filePath: string, + fileFullPath: string, + onProgress?: ProgressHandler, +) => { try { const userId = await getUserID(); if (!userId) { @@ -78,13 +107,12 @@ export const downloadFile = async (filePath: string) => { const { downloadUrl } = await response.json(); - const downloadResponse = await fetch(downloadUrl); - if (!downloadResponse.ok) { - console.error('Error downloading file:', await downloadResponse.text()); - throw new Error('File download failed'); + if (isWebAppPlatform()) { + return await webDownload(downloadUrl, onProgress); + } else { + await tauriDownload(downloadUrl, fileFullPath, onProgress); + return; } - - return await downloadResponse.blob(); } catch (error) { console.error('File download failed:', error); throw new Error('File download failed'); diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index 15af3383..8e62b5e0 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -29,9 +29,11 @@ import { CLOUD_BOOKS_SUBDIR, DEFAULT_MOBILE_VIEW_SETTINGS, } from './constants'; +import { isWebAppPlatform } from './environment'; import { getOSPlatform, isValidURL } from '@/utils/misc'; import { deserializeConfig, serializeConfig } from '@/utils/serializer'; -import { downloadFile, uploadFile, deleteFile } from '@/libs/storage'; +import { downloadFile, uploadFile, deleteFile, createProgressHandler } from '@/libs/storage'; +import { ProgressHandler } from '@/utils/transfer'; export abstract class BaseAppService implements AppService { isMobile: boolean = ['android', 'ios'].includes(getOSPlatform()); @@ -195,34 +197,62 @@ export abstract class BaseAppService implements AppService { } async deleteBook(book: Book, includingUploaded = false): Promise { - for (const fp of [getFilename(book), getCoverFilename(book)]) { - if (await this.fs.exists(fp, 'Books')) { - await this.fs.removeFile(fp, 'Books'); - } + const fps = [getFilename(book), getCoverFilename(book)]; + const localDeleteFps = ( + await Promise.all(fps.map(async (fp) => ((await this.fs.exists(fp, 'Books')) ? fp : null))) + ).filter(Boolean) as string[]; + for (const fp of localDeleteFps) { + await this.fs.removeFile(fp, 'Books'); + } + for (const fp of fps) { if (includingUploaded) { console.log('Deleting uploaded file:', fp); const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`; - await deleteFile(cfp); + try { + deleteFile(cfp); + } catch (error) { + console.log('Failed to delete uploaded file:', error); + } } } + book.deletedAt = Date.now(); + book.downloadedAt = null; + if (includingUploaded) { + book.uploadedAt = null; + } } - async uploadBook(book: Book): Promise { + async uploadBook(book: Book, onProgress?: ProgressHandler): Promise { let file: File; let uploaded = false; - for (const fp of [getFilename(book), getCoverFilename(book)]) { - if (await this.fs.exists(fp, 'Books')) { - const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`; - if (this.appPlatform === 'web') { - const content = await this.fs.readFile(fp, 'Books', 'binary'); - file = new File([content], cfp); - } else { - file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), cfp).open(); - } - console.log('Uploading file:', fp); - await uploadFile(file, book.hash); - uploaded = true; + const completedFiles = { count: 0 }; + const fps = ( + await Promise.all( + [getCoverFilename(book), getFilename(book)].map(async (fp) => + (await this.fs.exists(fp, 'Books')) ? fp : null, + ), + ) + ).filter(Boolean) as string[]; + if (!fps.includes(getFilename(book)) && book.url) { + // download the book from the URL + const fileobj = await new RemoteFile(book.url).open(); + await this.fs.writeFile(getFilename(book), 'Books', await fileobj.arrayBuffer()); + fps.push(getFilename(book)); + } + const handleProgress = createProgressHandler(fps.length, completedFiles, onProgress); + for (const fp of fps) { + const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`; + const fullpath = `${this.localBooksDir}/${fp}`; + if (this.appPlatform === 'web') { + const content = await this.fs.readFile(fp, 'Books', 'binary'); + file = new File([content], cfp); + } else { + file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), cfp).open(); } + console.log('Uploading file:', fp); + await uploadFile(file, fullpath, handleProgress, book.hash); + uploaded = true; + completedFiles.count++; } if (uploaded) { book.deletedAt = null; @@ -234,31 +264,50 @@ export abstract class BaseAppService implements AppService { } } - async downloadBook(book: Book, onlyCover = false): Promise { - const bookFp = getFilename(book); - const coverFp = getCoverFilename(book); - const fps = [coverFp]; - if (!onlyCover) { - fps.push(bookFp); - } + async downloadBook(book: Book, onlyCover = false, onProgress?: ProgressHandler): Promise { + const fps = onlyCover ? [getCoverFilename(book)] : [getCoverFilename(book), getFilename(book)]; let bookDownloaded = false; + const completedFiles = { count: 0 }; + const toDownloadFps = ( + await Promise.all( + [getFilename(book), getCoverFilename(book)].map(async (fp) => + (await this.fs.exists(fp, 'Books')) ? null : fp, + ), + ) + ).filter(Boolean) as string[]; + const handleProgress = createProgressHandler(toDownloadFps.length, completedFiles, onProgress); for (const fp of fps) { let downloaded = false; - const existed = await this.fs.exists(fp, 'Books'); + const existed = !toDownloadFps.includes(fp); if (existed) { downloaded = true; } else { console.log('Downloading file:', fp); const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`; - const fileobj = (await downloadFile(cfp)) as Blob; + const fullpath = `${this.localBooksDir}/${fp}`; if (!(await this.fs.exists(getDir(book), 'Books'))) { await this.fs.createDir(getDir(book), 'Books'); } - await this.fs.writeFile(fp, 'Books', await fileobj.arrayBuffer()); - downloaded = true; + try { + const result = await downloadFile(cfp, fullpath, handleProgress); + if (isWebAppPlatform()) { + const fileobj = result as Blob; + await this.fs.writeFile(fp, 'Books', await fileobj.arrayBuffer()); + downloaded = true; + } else { + downloaded = await this.fs.exists(fp, 'Books'); + } + } catch { + if (fp === getCoverFilename(book)) { + console.log('Failed to download cover image:', fp); + } else { + throw new Error('Failed to download book file'); + } + } + completedFiles.count++; } - if (fp === bookFp) { + if (fp === getFilename(book)) { bookDownloaded = downloaded; } } diff --git a/apps/readest-app/src/store/libraryStore.ts b/apps/readest-app/src/store/libraryStore.ts index 133adb77..ea563465 100644 --- a/apps/readest-app/src/store/libraryStore.ts +++ b/apps/readest-app/src/store/libraryStore.ts @@ -7,7 +7,7 @@ interface LibraryState { checkOpenWithBooks: boolean; clearOpenWithBooks: () => void; setLibrary: (books: Book[]) => void; - updateBook: (envConfig: EnvConfigType, book: Book, isDelete?: boolean) => void; + updateBook: (envConfig: EnvConfigType, book: Book) => void; } export const useLibraryStore = create((set, get) => ({ @@ -15,19 +15,12 @@ export const useLibraryStore = create((set, get) => ({ checkOpenWithBooks: true, clearOpenWithBooks: () => set({ checkOpenWithBooks: false }), setLibrary: (books) => set({ library: books }), - updateBook: async (envConfig: EnvConfigType, book: Book, isDelete = false) => { + updateBook: async (envConfig: EnvConfigType, book: Book) => { const appService = await envConfig.getAppService(); const { library } = get(); const bookIndex = library.findIndex((b) => b.hash === book.hash); if (bookIndex !== -1) { - if (isDelete) { - appService.deleteBook(book, !!book.uploadedAt); - book.deletedAt = Date.now(); - book.uploadedAt = null; - book.downloadedAt = null; - } else { - library[bookIndex] = book; - } + library[bookIndex] = book; } set({ library }); appService.saveLibraryBooks(library); diff --git a/apps/readest-app/src/types/system.ts b/apps/readest-app/src/types/system.ts index b97625e2..089ce065 100644 --- a/apps/readest-app/src/types/system.ts +++ b/apps/readest-app/src/types/system.ts @@ -1,6 +1,7 @@ import { SystemSettings } from './settings'; import { Book, BookConfig, BookContent } from './book'; import { BookDoc } from '@/libs/document'; +import { ProgressHandler } from '@/utils/transfer'; export type AppPlatform = 'web' | 'tauri'; export type BaseDir = 'Books' | 'Settings' | 'Data' | 'Log' | 'Cache' | 'None'; @@ -40,8 +41,8 @@ export interface AppService { overwrite?: boolean, ): Promise; deleteBook(book: Book, includingUploaded?: boolean): Promise; - uploadBook(book: Book): Promise; - downloadBook(book: Book, onlyCover?: boolean): Promise; + uploadBook(book: Book, onProgress?: ProgressHandler): Promise; + downloadBook(book: Book, onlyCover?: boolean, onProgress?: ProgressHandler): Promise; loadBookConfig(book: Book, settings: SystemSettings): Promise; fetchBookDetails(book: Book, settings: SystemSettings): Promise; saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings): Promise; diff --git a/apps/readest-app/src/utils/ui.ts b/apps/readest-app/src/utils/throttle.ts similarity index 66% rename from apps/readest-app/src/utils/ui.ts rename to apps/readest-app/src/utils/throttle.ts index 42047e06..5d71e00f 100644 --- a/apps/readest-app/src/utils/ui.ts +++ b/apps/readest-app/src/utils/throttle.ts @@ -4,6 +4,7 @@ export const throttle = ) => void | Promise) => void) => { let lastCall = 0; let timeout: ReturnType | null = null; + let lastArgs: Parameters | null = null; return (...args: Parameters): void => { const now = Date.now(); @@ -21,8 +22,17 @@ export const throttle = ) => void | Promise { + timeout = null; + if (lastArgs) { + func(...(lastArgs as Parameters)); + lastArgs = null; + } + }, remaining); + } } }; }; diff --git a/apps/readest-app/src/utils/transfer.ts b/apps/readest-app/src/utils/transfer.ts new file mode 100644 index 00000000..ed24933b --- /dev/null +++ b/apps/readest-app/src/utils/transfer.ts @@ -0,0 +1,125 @@ +import { invoke, Channel } from '@tauri-apps/api/core'; + +export type UploadMethod = 'POST' | 'PUT'; + +export interface ProgressPayload { + progress: number; + total: number; + transferSpeed: number; +} + +export type ProgressHandler = (progress: ProgressPayload) => void; + +export const webUpload = (file: File, uploadUrl: string, onProgress?: ProgressHandler) => { + return new Promise((resolve, reject) => { + const startTime = Date.now(); + const xhr = new XMLHttpRequest(); + xhr.open('PUT', uploadUrl, true); + + xhr.upload.onprogress = (event) => { + if (onProgress && event.lengthComputable) { + onProgress({ + progress: event.loaded, + total: event.total, + transferSpeed: event.loaded / ((Date.now() - startTime) / 1000), + }); + } + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + reject(new Error(`Upload failed with status ${xhr.status}`)); + } + }; + + xhr.onerror = () => reject(new Error('Upload failed')); + + xhr.send(file); + }); +}; + +export const webDownload = async (downloadUrl: string, onProgress?: ProgressHandler) => { + const response = await fetch(downloadUrl); + if (!response.ok) throw new Error('File download failed'); + + const contentLength = response.headers.get('Content-Length'); + if (!contentLength) throw new Error('Cannot track progress: Content-Length missing'); + + const totalSize = parseInt(contentLength, 10); + let receivedSize = 0; + const reader = response.body!.getReader(); + const chunks: Uint8Array[] = []; + + const startTime = Date.now(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + chunks.push(value); + receivedSize += value.length; + + if (onProgress) { + onProgress({ + progress: receivedSize, + total: totalSize, + transferSpeed: receivedSize / ((Date.now() - startTime) / 1000), + }); + } + } + + return new Blob(chunks); +}; + +export const tauriUpload = async ( + url: string, + filePath: string, + method: UploadMethod, + progressHandler?: ProgressHandler, + headers?: Map, +): Promise => { + const ids = new Uint32Array(1); + window.crypto.getRandomValues(ids); + const id = ids[0]; + + const onProgress = new Channel(); + if (progressHandler) { + onProgress.onmessage = progressHandler; + } + + return await invoke('upload_file', { + id, + url, + filePath, + method, + headers: headers ?? {}, + onProgress, + }); +}; + +export const tauriDownload = async ( + url: string, + filePath: string, + progressHandler?: ProgressHandler, + headers?: Map, + body?: string, +): Promise => { + const ids = new Uint32Array(1); + window.crypto.getRandomValues(ids); + const id = ids[0]; + + const onProgress = new Channel(); + if (progressHandler) { + onProgress.onmessage = progressHandler; + } + + await invoke('download_file', { + id, + url, + filePath, + headers: headers ?? {}, + onProgress, + body, + }); +};