Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod db;
mod readany_cli;
mod storage;
mod sync;
mod transfer;
mod vector;

use std::sync::Mutex;
Expand Down Expand Up @@ -31,6 +32,8 @@ pub fn run() {
})
.invoke_handler(tauri::generate_handler![
sync::commands::sync_vacuum_into,
transfer::webdav_upload_file,
transfer::webdav_download_file,
sync::commands::sync_integrity_check,
sync::commands::sync_hash_file,
sync::commands::get_local_ip,
Expand Down
173 changes: 173 additions & 0 deletions packages/app/src-tauri/src/transfer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
//! Native WebDAV file transfer for large book/cover files.
//!
//! Streams directly between disk and the HTTP server inside Rust so
//! multi-megabyte payloads never enter the webview's JS heap — plugin-http
//! serializes request bodies as JS number arrays on the renderer main
//! thread, which froze the whole app during library sync.

use std::collections::HashMap;

use tauri::ipc::Channel;
use tauri_plugin_http::reqwest;

const TRANSFER_TIMEOUT_SECS: u64 = 300;

fn build_client(allow_insecure: Option<bool>) -> Result<reqwest::Client, String> {
let mut builder = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(TRANSFER_TIMEOUT_SECS));
if allow_insecure.unwrap_or(false) {
builder = builder
.danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(true);
}
builder
.build()
.map_err(|e| format!("failed to build HTTP client: {e}"))
}

fn to_header_map(headers: &HashMap<String, String>) -> reqwest::header::HeaderMap {
let mut map = reqwest::header::HeaderMap::new();
for (key, value) in headers {
if let (Ok(name), Ok(value)) = (
reqwest::header::HeaderName::from_bytes(key.as_bytes()),
reqwest::header::HeaderValue::from_str(value),
) {
map.insert(name, value);
}
}
map
}

#[derive(Clone, serde::Serialize)]
pub struct TransferProgress {
loaded: u64,
total: u64,
}

/// Same retry policy as the JS layer: retry transient failures (network
/// errors, 429, 5xx) up to 3 times with exponential backoff; 4xx fails fast.
async fn send_with_retry(
client: &reqwest::Client,
method: reqwest::Method,
url: &str,
headers: reqwest::header::HeaderMap,
body: Option<Vec<u8>>,
) -> Result<reqwest::Response, String> {
const MAX_ATTEMPTS: u32 = 3;
const BASE_DELAY_MS: u64 = 500;
let mut last_error = String::new();
for attempt in 0..MAX_ATTEMPTS {
if attempt > 0 {
let delay = BASE_DELAY_MS * 2u64.pow(attempt - 1);
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
}
let request = if method == reqwest::Method::PUT {
client.put(url)
} else if method == reqwest::Method::GET {
client.get(url)
} else {
client.request(method.clone(), url)
};
let request = request.headers(headers.clone());
let request = if let Some(data) = &body {
request.body(data.clone())
} else {
request
};
let response = match request.send().await {
Ok(response) => response,
Err(e) => {
last_error = format!("WebDAV {method} failed for {url}: {e}");
continue;
}
};
let status = response.status();
if status.is_success() {
return Ok(response);
}
if status.as_u16() == 429 || status.is_server_error() {
last_error = format!("WebDAV {method} failed for {url}: {status}");
continue;
}
return Err(format!("WebDAV {method} failed for {url}: {status}"));
}
Err(last_error)
}

/// Stream a local file to the server via PUT. The file is read on the Rust
/// side; the webview only receives success/failure.
#[tauri::command(async)]
pub async fn webdav_upload_file(
url: String,
file_path: String,
headers: HashMap<String, String>,
allow_insecure: Option<bool>,
) -> Result<(), String> {
let client = build_client(allow_insecure)?;
let data = tokio::fs::read(&file_path)
.await
.map_err(|e| format!("failed to read {file_path}: {e}"))?;
send_with_retry(
&client,
reqwest::Method::PUT,
&url,
to_header_map(&headers),
Some(data),
)
.await
.map(|_| ())
}

/// Stream a server file to local disk via GET, reporting progress over the
/// IPC channel (the JS side throttles UI updates).
#[tauri::command(async)]
pub async fn webdav_download_file(
url: String,
file_path: String,
headers: HashMap<String, String>,
allow_insecure: Option<bool>,
on_progress: Channel<TransferProgress>,
) -> Result<(), String> {
let client = build_client(allow_insecure)?;
let mut response = send_with_retry(
&client,
reqwest::Method::GET,
&url,
to_header_map(&headers),
None,
)
.await?;

let total = response.content_length().unwrap_or(0);
if let Some(parent) = std::path::Path::new(&file_path).parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("failed to create {parent:?}: {e}"))?;
}
let mut file = tokio::fs::File::create(&file_path)
.await
.map_err(|e| format!("failed to create {file_path}: {e}"))?;

let mut loaded: u64 = 0;
while let Some(chunk) = response
.chunk()
.await
.map_err(|e| format!("download read failed: {e}"))?
{
{
use tokio::io::AsyncWriteExt;
file.write_all(&chunk)
.await
.map_err(|e| format!("download write failed: {e}"))?;
}
loaded += chunk.len() as u64;
let _ = on_progress.send(TransferProgress { loaded, total });
}
{
use tokio::io::AsyncWriteExt;
file.flush()
.await
.map_err(|e| format!("download flush failed: {e}"))?;
}
Ok(())
}
36 changes: 36 additions & 0 deletions packages/app/src/components/settings/SyncSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export function SyncSettings() {
forceFullSync,
setAutoSync,
setSyncIntervalMins,
setConcurrency,
resetSync,
} = useSyncStore();

Expand Down Expand Up @@ -77,6 +78,7 @@ export function SyncSettings() {
const [saving, setSaving] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [syncIntervalInput, setSyncIntervalInput] = useState("30");
const [concurrencyInput, setConcurrencyInput] = useState("2");

// LAN dialog state
const [lanDialogOpen, setLanDialogOpen] = useState(false);
Expand Down Expand Up @@ -339,6 +341,13 @@ export function SyncSettings() {
await setSyncIntervalMins(nextValue);
}, [setSyncIntervalMins, syncIntervalInput]);

const handleConcurrencyBlur = useCallback(async () => {
const parsed = Number.parseInt(concurrencyInput, 10);
const nextValue = Number.isFinite(parsed) ? Math.max(1, Math.min(6, parsed)) : 2;
setConcurrencyInput(String(nextValue));
await setConcurrency(nextValue);
}, [setConcurrency, concurrencyInput]);

const statusLabel = () => {
if (isLanContext) {
switch (status) {
Expand Down Expand Up @@ -874,6 +883,33 @@ export function SyncSettings() {
</span>
</div>
</div>
<div className="flex items-center justify-between gap-3 border-t border-border/40 pt-3">
<div>
<span className="text-sm text-foreground">
{t("settings.syncConcurrency")}
</span>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("settings.syncConcurrencyDesc")}
</p>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={1}
max={6}
step={1}
value={concurrencyInput}
onChange={(e) => setConcurrencyInput(e.target.value)}
onBlur={() => void handleConcurrencyBlur()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
}}
className="w-20 rounded-md border border-input bg-background px-3 py-1.5 text-right text-sm text-foreground outline-none focus:border-primary"
/>
</div>
</div>
</>
)}
</div>
Expand Down
51 changes: 51 additions & 0 deletions packages/app/src/lib/platform/tauri-platform-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* All Tauri imports are dynamic so the module graph stays clean in SSR/test contexts.
*/
import type {
type FileTransferOptions,
FetchOptions,
FilePickerOptions,
IDatabase,
Expand Down Expand Up @@ -223,6 +224,56 @@ export class TauriPlatformService implements IPlatformService {
}
}

/**
* Native streaming upload: the file is read and PUT inside Rust, so
* multi-megabyte payloads never cross the webview main thread
* (plugin-http serializes bodies as JS number arrays, which froze sync).
*/
async uploadFile(
url: string,
filePath: string,
options?: FileTransferOptions,
): Promise<void> {
const { invoke } = await import("@tauri-apps/api/core");
try {
await invoke("webdav_upload_file", {
url,
filePath,
headers: options?.headers ?? {},
allowInsecure: options?.allowInsecure ?? false,
});
} catch (error) {
// Surface the server status inside the message — the sync layer's
// directory-heal retry matches on 403/404/409 in the message text.
throw new Error(error instanceof Error ? error.message : String(error));
}
}

/** Native streaming download to a local path, with progress via channel. */
async downloadFile(
url: string,
filePath: string,
options?: FileTransferOptions,
): Promise<void> {
const { invoke, Channel } = await import("@tauri-apps/api/core");
const onProgress = options?.onProgress;
const channel = new Channel<{ loaded: number; total: number }>();
if (onProgress) {
channel.onmessage = (payload) => onProgress(payload.loaded, payload.total);
}
try {
await invoke("webdav_download_file", {
url,
filePath,
headers: options?.headers ?? {},
allowInsecure: options?.allowInsecure ?? false,
onProgress: channel,
});
} catch (error) {
throw new Error(error instanceof Error ? error.message : String(error));
}
}

async createWebSocket(url: string, options?: WebSocketOptions): Promise<IWebSocket> {
const WebSocket = (await import("@tauri-apps/plugin-websocket")).default;
const ws = await WebSocket.connect(url, {
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/db/__tests__/book-queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,9 +320,9 @@ describe("book-queries", () => {
"book-1",
]);
expect(mockExecute).toHaveBeenCalledWith("DELETE FROM books WHERE id = ?", ["book-1"]);
expect(coreMocks.insertTombstone).toHaveBeenCalledWith(mockDb, "hl-1", "highlights");
expect(coreMocks.insertTombstone).toHaveBeenCalledWith(mockDb, "note-1", "notes");
expect(coreMocks.insertTombstone).toHaveBeenCalledWith(mockDb, "bm-1", "bookmarks");
expect(coreMocks.insertTombstone).toHaveBeenCalledWith(mockDb, "hl-1", "highlights", "book-1");
expect(coreMocks.insertTombstone).toHaveBeenCalledWith(mockDb, "note-1", "notes", "book-1");
expect(coreMocks.insertTombstone).toHaveBeenCalledWith(mockDb, "bm-1", "bookmarks", "book-1");
expect(coreMocks.insertTombstone).toHaveBeenCalledWith(mockDb, "book-1", "books");
});
});
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/db/__tests__/bookmark-queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ describe("bookmark-queries", () => {
mockExecute.mockResolvedValue(undefined);

await deleteBookmark("bm-1");
expect(coreMocks.insertTombstone).toHaveBeenCalledWith(mockDb, "bm-1", "bookmarks");
expect(coreMocks.insertTombstone).toHaveBeenCalledWith(mockDb, "bm-1", "bookmarks", "book-1");
expect(mockExecute).toHaveBeenCalledWith("DELETE FROM bookmarks WHERE id = ?", ["bm-1"]);
});
});
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/db/book-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,13 +374,13 @@ export async function deleteBook(id: string, options: DeleteBookOptions = {}): P
]);

for (const row of highlightRows) {
await insertTombstone(database, row.id, "highlights");
await insertTombstone(database, row.id, "highlights", id);
}
for (const row of noteRows) {
await insertTombstone(database, row.id, "notes");
await insertTombstone(database, row.id, "notes", id);
}
for (const row of bookmarkRows) {
await insertTombstone(database, row.id, "bookmarks");
await insertTombstone(database, row.id, "bookmarks", id);
}

await database.execute("DELETE FROM highlights WHERE book_id = ?", [id]);
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/db/bookmark-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export async function insertBookmark(bookmark: Bookmark): Promise<void> {

export async function deleteBookmark(id: string): Promise<void> {
const database = await getDB();
await insertTombstone(database, id, "bookmarks");
const rows = await database.select<{ book_id: string | null }>(
"SELECT book_id FROM bookmarks WHERE id = ?",
[id],
);
const tombstoneBookId: [] | [string] = rows[0]?.book_id ? [rows[0].book_id] : [];
await insertTombstone(database, id, "bookmarks", ...tombstoneBookId);
await database.execute("DELETE FROM bookmarks WHERE id = ?", [id]);
}
Loading