From 75078ee8e4590211f48fa729f3ba3f94374e360d Mon Sep 17 00:00:00 2001 From: Creeper19472 Date: Tue, 16 Jun 2026 22:47:19 +0800 Subject: [PATCH 1/5] feat: implement file sorting with web worker support for improved performance --- src-tauri/src/commands/browsing.rs | 7 +- src-tauri/src/commands/shared_helpers.rs | 15 +- src/lib/components/files/FileTable.svelte | 262 ++++++++++++++++------ src/lib/files/file-sort.worker.ts | 30 +++ src/lib/files/sort-worker-client.ts | 84 +++++++ src/lib/files/sorting.ts | 88 ++++---- src/routes/home/files/+page.svelte | 80 ++++++- 7 files changed, 438 insertions(+), 128 deletions(-) create mode 100644 src/lib/files/file-sort.worker.ts create mode 100644 src/lib/files/sort-worker-client.ts diff --git a/src-tauri/src/commands/browsing.rs b/src-tauri/src/commands/browsing.rs index b18713e..e9b73f4 100644 --- a/src-tauri/src/commands/browsing.rs +++ b/src-tauri/src/commands/browsing.rs @@ -46,7 +46,7 @@ pub async fn list_directory( } .ok_or_else(|| "Not logged in".to_string())?; - let resp = send_action_request( + let resp = send_typed_action_request::( &conn, "list_directory", serde_json::json!({"folder_id": folder_id}), @@ -59,10 +59,7 @@ pub async fn list_directory( return Err(format!("Server returned {}: {}", resp.code, resp.message)); } - let data: ListDirectoryResponse = serde_json::from_value(resp.data) - .map_err(|e| format!("Invalid list_directory response: {e}"))?; - - Ok(data) + Ok(resp.data) } /// Request a document download from the CFMS server. diff --git a/src-tauri/src/commands/shared_helpers.rs b/src-tauri/src/commands/shared_helpers.rs index ee1dc56..9b09d79 100644 --- a/src-tauri/src/commands/shared_helpers.rs +++ b/src-tauri/src/commands/shared_helpers.rs @@ -12,6 +12,19 @@ async fn send_action_request( username: &str, token: &str, ) -> Result { + send_typed_action_request(conn, action, data, username, token).await +} + +async fn send_typed_action_request( + conn: &cfms_transport::Connection, + action: &str, + data: serde_json::Value, + username: &str, + token: &str, +) -> Result, String> +where + T: serde::de::DeserializeOwned, +{ let random_bytes: [u8; 16] = rand::thread_rng().r#gen(); let nonce = hex::encode(random_bytes); @@ -42,7 +55,7 @@ async fn send_action_request( .await .ok_or_else(|| format!("Connection closed before {action} response"))?; - serde_json::from_slice::(&response_bytes) + serde_json::from_slice::>(&response_bytes) .map_err(|e| format!("Invalid {action} response: {e}")) } diff --git a/src/lib/components/files/FileTable.svelte b/src/lib/components/files/FileTable.svelte index f26e739..d9f232b 100644 --- a/src/lib/components/files/FileTable.svelte +++ b/src/lib/components/files/FileTable.svelte @@ -1,4 +1,5 @@ {#if loading} @@ -51,9 +151,14 @@ {#if !loading}
-
+
@@ -92,80 +197,95 @@

{/if} - {#if canGoToParent} - - {/if} + {#if rowCount > 0} + - {#each folders as folder (folder.id)} - - {/each} - - {#each documents as doc (doc.id)} - + {:else if row.kind === 'folder'} + {:else} - - - + {/if} - - {doc.title} - - - {formatBytes(doc.size)} - - - {formatDate(doc.last_modified)} - - - {/each} + {/each} + + + {/if}
{/if} + + diff --git a/src/lib/files/file-sort.worker.ts b/src/lib/files/file-sort.worker.ts new file mode 100644 index 0000000..5c1200c --- /dev/null +++ b/src/lib/files/file-sort.worker.ts @@ -0,0 +1,30 @@ +/// + +import type { ServerDirectoryEntry, ServerDocumentEntry } from '$lib/api'; +import { sortFileEntries, type SortDirection, type SortField } from './sorting'; + +interface SortRequest { + id: number; + folders: ServerDirectoryEntry[]; + documents: ServerDocumentEntry[]; + field: SortField; + direction: SortDirection; +} + +interface SortResponse { + id: number; + folders: ServerDirectoryEntry[]; + documents: ServerDocumentEntry[]; +} + +const workerScope = self as DedicatedWorkerGlobalScope; + +workerScope.onmessage = (event: MessageEvent) => { + const { id, folders, documents, field, direction } = event.data; + const sorted = sortFileEntries(folders, documents, field, direction); + workerScope.postMessage({ + id, + folders: sorted.folders, + documents: sorted.documents, + } satisfies SortResponse); +}; diff --git a/src/lib/files/sort-worker-client.ts b/src/lib/files/sort-worker-client.ts new file mode 100644 index 0000000..81ca0b0 --- /dev/null +++ b/src/lib/files/sort-worker-client.ts @@ -0,0 +1,84 @@ +import type { ServerDirectoryEntry, ServerDocumentEntry } from '$lib/api'; +import { + sortFileEntries, + type SortDirection, + type SortField, + type SortedFileEntries, +} from './sorting'; + +const WORKER_SORT_THRESHOLD = 400; + +interface SortResponse extends SortedFileEntries { + id: number; +} + +let worker: Worker | null = null; +let nextRequestId = 1; +const pending = new Map< + number, + { + resolve: (value: SortedFileEntries) => void; + reject: (reason?: unknown) => void; + } +>(); + +export function shouldDeferFileSort(foldersLength: number, documentsLength: number): boolean { + return foldersLength + documentsLength >= WORKER_SORT_THRESHOLD; +} + +export function sortFileEntriesAsync( + folders: ServerDirectoryEntry[], + documents: ServerDocumentEntry[], + field: SortField, + direction: SortDirection, +): Promise { + if (!shouldDeferFileSort(folders.length, documents.length) || typeof Worker === 'undefined') { + return Promise.resolve(sortFileEntries(folders, documents, field, direction)); + } + + const sortWorker = getSortWorker(); + if (!sortWorker) { + return Promise.resolve(sortFileEntries(folders, documents, field, direction)); + } + + const id = nextRequestId; + nextRequestId += 1; + + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + sortWorker.postMessage({ id, folders, documents, field, direction }); + }); +} + +function getSortWorker(): Worker | null { + if (worker) return worker; + + try { + worker = new Worker(new URL('./file-sort.worker.ts', import.meta.url), { type: 'module' }); + } catch (error) { + console.warn('File sort worker is unavailable; falling back to main-thread sorting.', error); + worker = null; + return null; + } + + worker.onmessage = (event: MessageEvent) => { + const { id, folders, documents } = event.data; + const task = pending.get(id); + if (!task) return; + + pending.delete(id); + task.resolve({ folders, documents }); + }; + + worker.onerror = (event) => { + const error = event.error ?? new Error(event.message); + for (const task of pending.values()) { + task.reject(error); + } + pending.clear(); + worker?.terminate(); + worker = null; + }; + + return worker; +} diff --git a/src/lib/files/sorting.ts b/src/lib/files/sorting.ts index 6ca3f6c..02c8791 100644 --- a/src/lib/files/sorting.ts +++ b/src/lib/files/sorting.ts @@ -3,10 +3,26 @@ import type { ServerDirectoryEntry, ServerDocumentEntry } from '$lib/api'; export type SortField = 'name' | 'size' | 'modified'; export type SortDirection = 'asc' | 'desc'; -interface SortableFileEntry { - name: string; - size: number; - modified: number; +const fileNameCollator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: 'base', +}); + +export interface SortedFileEntries { + folders: ServerDirectoryEntry[]; + documents: ServerDocumentEntry[]; +} + +export function sortFileEntries( + folders: ServerDirectoryEntry[], + documents: ServerDocumentEntry[], + field: SortField, + direction: SortDirection, +): SortedFileEntries { + return { + folders: sortFolders(folders, field, direction), + documents: sortDocuments(documents, field, direction), + }; } export function sortFolders( @@ -14,20 +30,8 @@ export function sortFolders( field: SortField, direction: SortDirection, ): ServerDirectoryEntry[] { - return [...input].sort((a, b) => compareFileEntries( - { - name: a.name, - size: 0, - modified: a.created_time ?? 0, - }, - { - name: b.name, - size: 0, - modified: b.created_time ?? 0, - }, - field, - direction, - )); + const sign = direction === 'asc' ? 1 : -1; + return [...input].sort((a, b) => sign * compareFolderEntries(a, b, field)); } export function sortDocuments( @@ -35,34 +39,38 @@ export function sortDocuments( field: SortField, direction: SortDirection, ): ServerDocumentEntry[] { - return [...input].sort((a, b) => compareFileEntries( - { - name: a.title, - size: a.size ?? 0, - modified: a.last_modified ?? 0, - }, - { - name: b.title, - size: b.size ?? 0, - modified: b.last_modified ?? 0, - }, - field, - direction, - )); + const sign = direction === 'asc' ? 1 : -1; + return [...input].sort((a, b) => sign * compareDocumentEntries(a, b, field)); } -function compareFileEntries( - a: SortableFileEntry, - b: SortableFileEntry, +function compareFolderEntries( + a: ServerDirectoryEntry, + b: ServerDirectoryEntry, field: SortField, - direction: SortDirection, ): number { - const sign = direction === 'asc' ? 1 : -1; if (field === 'name') { - return sign * a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }); + return compareNames(a.name, b.name); } if (field === 'size') { - return sign * ((a.size - b.size) || a.name.localeCompare(b.name)); + return compareNames(a.name, b.name); } - return sign * ((a.modified - b.modified) || a.name.localeCompare(b.name)); + return ((a.created_time ?? 0) - (b.created_time ?? 0)) || compareNames(a.name, b.name); +} + +function compareDocumentEntries( + a: ServerDocumentEntry, + b: ServerDocumentEntry, + field: SortField, +): number { + if (field === 'name') { + return compareNames(a.title, b.title); + } + if (field === 'size') { + return ((a.size ?? 0) - (b.size ?? 0)) || compareNames(a.title, b.title); + } + return ((a.last_modified ?? 0) - (b.last_modified ?? 0)) || compareNames(a.title, b.title); +} + +function compareNames(a: string, b: string): number { + return fileNameCollator.compare(a, b); } diff --git a/src/routes/home/files/+page.svelte b/src/routes/home/files/+page.svelte index dc4e42a..28b6999 100644 --- a/src/routes/home/files/+page.svelte +++ b/src/routes/home/files/+page.svelte @@ -81,7 +81,8 @@ } from '$lib/file-preferences'; import { formatBytes, formatDate, formatError, formatUnknown, isPickerCancel } from '$lib/files/formatting'; import { graphLineColor, graphWidth, laneX, buildRevisionRows } from '$lib/files/revision-graph'; - import { sortDocuments, sortFolders, type SortDirection, type SortField } from '$lib/files/sorting'; + import { sortFileEntries, type SortDirection, type SortField } from '$lib/files/sorting'; + import { shouldDeferFileSort, sortFileEntriesAsync } from '$lib/files/sort-worker-client'; import { isAndroidTreeUri, uploadDisplayName } from '$lib/files/upload-names'; import { shortIdentifier } from '$lib/identifiers'; import type { IconName } from '$lib/icons'; @@ -107,6 +108,7 @@ let selectedDocumentIds = $state>(new Set()); let sortField = $state('name'); let sortDirection = $state('asc'); + let sortRequestId = 0; // Context menu state let contextMenu = $state<{ @@ -245,6 +247,65 @@ // --- Data loading --- + function applyDirectoryEntries( + nextFolders: ServerDirectoryEntry[], + nextDocuments: ServerDocumentEntry[], + ) { + const requestId = ++sortRequestId; + if (shouldDeferFileSort(nextFolders.length, nextDocuments.length)) { + folders = nextFolders; + documents = nextDocuments; + queueDirectorySort(requestId, nextFolders, nextDocuments, true); + return; + } + + const sorted = sortFileEntries(nextFolders, nextDocuments, sortField, sortDirection); + if (requestId !== sortRequestId) return; + folders = sorted.folders; + documents = sorted.documents; + } + + function sortCurrentDirectory(deferUntilPaint = false) { + const requestId = ++sortRequestId; + queueDirectorySort(requestId, folders, documents, deferUntilPaint); + } + + function queueDirectorySort( + requestId: number, + sourceFolders: ServerDirectoryEntry[], + sourceDocuments: ServerDocumentEntry[], + deferUntilPaint: boolean, + ) { + const field = sortField; + const direction = sortDirection; + + const performSort = async () => { + try { + const sorted = await sortFileEntriesAsync(sourceFolders, sourceDocuments, field, direction); + if (requestId !== sortRequestId || field !== sortField || direction !== sortDirection) return; + folders = sorted.folders; + documents = sorted.documents; + } catch (err) { + console.warn('Background file sort failed; falling back to main-thread sorting.', err); + if (requestId !== sortRequestId || field !== sortField || direction !== sortDirection) return; + const sorted = sortFileEntries(sourceFolders, sourceDocuments, field, direction); + folders = sorted.folders; + documents = sorted.documents; + } + }; + + if (deferUntilPaint && typeof requestAnimationFrame !== 'undefined') { + requestAnimationFrame(() => { + setTimeout(() => { + void performSort(); + }, 0); + }); + return; + } + + void performSort(); + } + async function loadDirectory(folderId: string | null, preserveOnError = false): Promise { loading = true; error = null; @@ -254,8 +315,7 @@ const normalizedFolderId = normalizeDirectoryId(folderId); const resp = await listDirectory(normalizedFolderId); currentFolderId = normalizedFolderId; - folders = resp.folders; - documents = resp.documents; + applyDirectoryEntries(resp.folders, resp.documents); parentId = normalizeDirectoryId(resp.parent_id); return true; } catch (e) { @@ -371,8 +431,8 @@ } function selectAllVisible() { - selectedFolderIds = new Set(filteredFolders.map((folder) => folder.id)); - selectedDocumentIds = new Set(filteredDocuments.map((doc) => doc.id)); + selectedFolderIds = new Set(folders.map((folder) => folder.id)); + selectedDocumentIds = new Set(documents.map((doc) => doc.id)); } function toggleAllVisibleSelection() { @@ -1495,10 +1555,12 @@ function setSort(field: SortField) { if (sortField === field) { sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; + sortCurrentDirectory(shouldDeferFileSort(folders.length, documents.length)); return; } sortField = field; sortDirection = field === 'name' ? 'asc' : 'desc'; + sortCurrentDirectory(shouldDeferFileSort(folders.length, documents.length)); } function sortIcon(field: SortField): IconName { @@ -1572,10 +1634,6 @@ }; }); - // --- Display lists --- - - const filteredFolders = $derived.by(() => sortFolders(folders, sortField, sortDirection)); - const filteredDocuments = $derived.by(() => sortDocuments(documents, sortField, sortDirection));
Date: Wed, 17 Jun 2026 12:19:32 +0800 Subject: [PATCH 2/5] fix: update document size handling to support null values and improve formatting --- crates/core/src/types.rs | 42 ++++++++++++++++++++++++++++-- src/lib/api/types.ts | 2 +- src/lib/files/formatting.ts | 5 ++-- src/routes/home/files/+page.svelte | 2 +- 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index bcacf08..db78ee5 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -285,9 +285,9 @@ pub struct ServerDocumentEntry { pub id: String, /// Display title of the document. pub title: String, - /// File size in bytes. + /// File size in bytes, or `None` when the server cannot determine it. #[serde(default)] - pub size: u64, + pub size: Option, /// Last modification timestamp (Unix seconds). #[serde(default)] pub last_modified: Option, @@ -475,3 +475,41 @@ pub struct RecentFileRecord { #[serde(default, alias = "visited_at")] pub visited_at: u64, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn list_directory_preserves_unknown_document_size() { + let raw = r#"{ + "folders": [], + "documents": [ + { + "id": "with-null-size", + "title": "Null size", + "size": null, + "last_modified": null + }, + { + "id": "without-size", + "title": "Missing size", + "last_modified": 1710000000.0 + }, + { + "id": "with-size", + "title": "Known size", + "size": 4096, + "last_modified": 1710000001.0 + } + ], + "parent_id": null + }"#; + + let parsed: ListDirectoryResponse = serde_json::from_str(raw).unwrap(); + + assert_eq!(parsed.documents[0].size, None); + assert_eq!(parsed.documents[1].size, None); + assert_eq!(parsed.documents[2].size, Some(4096)); + } +} diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 5828798..116fbb5 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -74,7 +74,7 @@ export interface ServerDirectoryEntry { export interface ServerDocumentEntry { id: string; title: string; - size: number; + size: number | null; last_modified: number | null; } diff --git a/src/lib/files/formatting.ts b/src/lib/files/formatting.ts index 29a16db..834af2b 100644 --- a/src/lib/files/formatting.ts +++ b/src/lib/files/formatting.ts @@ -1,5 +1,6 @@ -export function formatBytes(bytes: number): string { - if (bytes === 0) return '—'; +export function formatBytes(bytes: number | null | undefined): string { + if (bytes === null || bytes === undefined) return '—'; + if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KiB', 'MiB', 'GiB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); diff --git a/src/routes/home/files/+page.svelte b/src/routes/home/files/+page.svelte index 28b6999..17e9444 100644 --- a/src/routes/home/files/+page.svelte +++ b/src/routes/home/files/+page.svelte @@ -1905,7 +1905,7 @@ > {document.name ?? document.title} - {formatBytes(document.size ?? 0)} + {formatBytes(document.size)} {/each}
From 9a3be375b494a877ea2525b3aae167c67125c189 Mon Sep 17 00:00:00 2001 From: Creeper19472 Date: Wed, 17 Jun 2026 14:53:48 +0800 Subject: [PATCH 3/5] fix: rewrap DEK and upload it to the server in advance --- src-tauri/src/commands/auth_connection.rs | 288 +++++++++++++--------- 1 file changed, 172 insertions(+), 116 deletions(-) diff --git a/src-tauri/src/commands/auth_connection.rs b/src-tauri/src/commands/auth_connection.rs index 6dfa232..8762fe7 100644 --- a/src-tauri/src/commands/auth_connection.rs +++ b/src-tauri/src/commands/auth_connection.rs @@ -28,40 +28,8 @@ pub async fn login( } .ok_or_else(|| "Not connected to a server".to_string())?; - // --- Build login request payload --- - let mut request = serde_json::json!({ - "action": "login", - "data": { - "username": &username, - "password": &password, - }, - }); - if let Some(ref token) = twofa_token { - request["data"]["2fa_token"] = serde_json::Value::String(token.clone()); - } - - // --- Send login request over a client stream --- - let mut stream = conn - .create_stream() - .await - .map_err(|e| format!("Failed to create stream: {e}"))?; - - let request_bytes = - serde_json::to_vec(&request).map_err(|e| format!("Failed to encode login request: {e}"))?; - - stream - .send(&conn, request_bytes) - .await - .map_err(|e| format!("Failed to send login request: {e}"))?; - - // --- Read response --- - let response_bytes = stream - .recv() - .await - .ok_or_else(|| "Connection closed before login response".to_string())?; - - let response: cfms_core::Response = serde_json::from_slice(&response_bytes) - .map_err(|e| format!("Invalid login response from server: {e}"))?; + let response = + send_login_request(&conn, &username, &password, twofa_token.as_deref()).await?; tracing::info!( "Login response: code={}, message={}", @@ -74,74 +42,7 @@ pub async fn login( 200 => { let data = &response.data; - // Extract token early — needed for the DEK setup calls below. - let token = data["token"] - .as_str() - .ok_or_else(|| "Server did not return a token".to_string())? - .to_string(); - - // Store auth state from server response. - { - let mut u = state.inner.username.write().await; - *u = Some(username.clone()); - } - { - let mut t = state.inner.token.write().await; - *t = Some(token.clone()); - } - { - let exp = data["exp"].as_i64().unwrap_or(unix_now() + 3600); - let mut e = state.inner.token_exp.write().await; - *e = Some(exp); - } - { - let nickname = data["nickname"].as_str().unwrap_or(&username).to_string(); - let mut n = state.inner.nickname.write().await; - *n = Some(nickname); - } - { - let perms: Vec = data["permissions"] - .as_array() - .map(|a| { - a.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - let mut p = state.inner.permissions.write().await; - *p = perms; - } - { - let grps: Vec = data["groups"] - .as_array() - .map(|a| { - a.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - let mut g = state.inner.groups.write().await; - *g = grps; - } - // Clear any pending 2FA flag. - state - .inner - .pending_2fa - .store(false, std::sync::atomic::Ordering::SeqCst); - - { - let mut d = state.inner.dek.write().await; - *d = None; - } - let server_preference_dek = match extract_preference_dek_content(data) { - Ok(content) => content.map(ToOwned::to_owned), - Err(error) => { - tracing::warn!("Login response contained an unusable preference DEK: {error}"); - None - } - }; - remember_server_preference_dek(&state.inner, server_preference_dek).await; - state.tasks.clear(); + apply_successful_login_response(&state, &username, data, true, true).await?; let mut status = build_auth_status(&state.inner).await; status["needs_preference_dek_setup"] = serde_json::Value::Bool(true); @@ -233,6 +134,124 @@ pub async fn login( } } +async fn send_login_request( + conn: &cfms_transport::Connection, + username: &str, + password: &str, + twofa_token: Option<&str>, +) -> Result { + let mut request = serde_json::json!({ + "action": "login", + "data": { + "username": username, + "password": password, + }, + }); + if let Some(token) = twofa_token { + request["data"]["2fa_token"] = serde_json::Value::String(token.to_string()); + } + + let mut stream = conn + .create_stream() + .await + .map_err(|e| format!("Failed to create stream: {e}"))?; + + let request_bytes = + serde_json::to_vec(&request).map_err(|e| format!("Failed to encode login request: {e}"))?; + + stream + .send(conn, request_bytes) + .await + .map_err(|e| format!("Failed to send login request: {e}"))?; + + let response_bytes = stream + .recv() + .await + .ok_or_else(|| "Connection closed before login response".to_string())?; + + serde_json::from_slice(&response_bytes) + .map_err(|e| format!("Invalid login response from server: {e}")) +} + +async fn apply_successful_login_response( + state: &AppHandleState, + username: &str, + data: &serde_json::Value, + clear_dek: bool, + clear_tasks: bool, +) -> Result { + let token = data["token"] + .as_str() + .ok_or_else(|| "Server did not return a token".to_string())? + .to_string(); + + { + let mut u = state.inner.username.write().await; + *u = Some(username.to_string()); + } + { + let mut t = state.inner.token.write().await; + *t = Some(token.clone()); + } + { + let exp = data["exp"].as_i64().unwrap_or(unix_now() + 3600); + let mut e = state.inner.token_exp.write().await; + *e = Some(exp); + } + { + let nickname = data["nickname"].as_str().unwrap_or(username).to_string(); + let mut n = state.inner.nickname.write().await; + *n = Some(nickname); + } + { + let perms: Vec = data["permissions"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let mut p = state.inner.permissions.write().await; + *p = perms; + } + { + let grps: Vec = data["groups"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let mut g = state.inner.groups.write().await; + *g = grps; + } + state + .inner + .pending_2fa + .store(false, std::sync::atomic::Ordering::SeqCst); + + if clear_dek { + let mut d = state.inner.dek.write().await; + *d = None; + } + + let server_preference_dek = match extract_preference_dek_content(data) { + Ok(content) => content.map(ToOwned::to_owned), + Err(error) => { + tracing::warn!("Login response contained an unusable preference DEK: {error}"); + None + } + }; + remember_server_preference_dek(&state.inner, server_preference_dek).await; + if clear_tasks { + state.tasks.clear(); + } + + Ok(token) +} + /// Change a user's password via the server's `set_passwd` action. /// /// This supports the *self-change* flow used when the server rejects a login @@ -260,6 +279,30 @@ pub async fn change_password( } .ok_or_else(|| "Not connected to a server".to_string())?; + let mut prepared_dek_rewrap = if let Some(existing_dek) = state.inner.dek.read().await.clone() { + match get_connection_auth(&state).await { + Ok((auth_conn, auth_username, auth_token)) + if auth_username == username && auth_token != "pending_2fa" => + { + let encrypted = rewrap_and_upload_preference_dek( + &auth_conn, + *existing_dek, + &new_password, + &auth_username, + &auth_token, + ) + .await + .map_err(|e| { + format!("Failed to prepare preference DEK rewrap before password change: {e}") + })?; + Some((existing_dek, encrypted, auth_conn, auth_username, auth_token)) + } + Ok(_) | Err(_) => None, + } + } else { + None + }; + let request = serde_json::json!({ "action": "set_passwd", "data": { @@ -302,23 +345,36 @@ pub async fn change_password( ); if response.code != 200 { + if let Some((dek, _, auth_conn, auth_username, auth_token)) = prepared_dek_rewrap.take() { + match rewrap_and_upload_preference_dek( + &auth_conn, + *dek, + &old_password, + &auth_username, + &auth_token, + ) + .await + { + Ok(encrypted) => remember_server_preference_dek(&state.inner, Some(encrypted)).await, + Err(error) => { + tracing::warn!( + "Failed to roll back prepared preference DEK after password change rejection: {error}" + ); + return Err(format!( + "({}) {}; additionally failed to restore the previous preference DEK: {}", + response.code, response.message, error + )); + } + } + } return Err(format!("({}) {}", response.code, response.message)); } - if let Some(existing_dek) = state.inner.dek.read().await.clone() { - let (auth_conn, auth_username, auth_token) = get_connection_auth(&state) - .await - .map_err(|e| format!("Password changed, but failed to rewrap preference DEK: {e}"))?; - - let encrypted = rewrap_and_upload_preference_dek( - &auth_conn, - *existing_dek, - &new_password, - &auth_username, - &auth_token, - ) - .await - .map_err(|e| format!("Password changed, but failed to rewrap preference DEK: {e}"))?; + if let Some((dek, encrypted, _, _, _)) = prepared_dek_rewrap.take() { + { + let mut stored_dek = state.inner.dek.write().await; + *stored_dek = Some(dek); + } remember_server_preference_dek(&state.inner, Some(encrypted)).await; } From 02bfe0c7ed19c065ab578e2b5e58a95c7a7faa2b Mon Sep 17 00:00:00 2001 From: Creeper19472 Date: Wed, 17 Jun 2026 15:08:13 +0800 Subject: [PATCH 4/5] fix: update document size handling to support null values and improve formatting feat: enhance upload handling with zero-byte support and refactor parsing logic --- crates/core/src/types.rs | 7 +++++ crates/transfer/src/upload.rs | 56 ++++++++++++++++++++++++++++------- src/lib/api/types.ts | 4 +-- src/lib/files/formatting.ts | 2 +- 4 files changed, 55 insertions(+), 14 deletions(-) diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index db78ee5..f07f7d3 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -501,6 +501,12 @@ mod tests { "title": "Known size", "size": 4096, "last_modified": 1710000001.0 + }, + { + "id": "with-zero-size", + "title": "Zero size", + "size": 0, + "last_modified": 1710000002.0 } ], "parent_id": null @@ -511,5 +517,6 @@ mod tests { assert_eq!(parsed.documents[0].size, None); assert_eq!(parsed.documents[1].size, None); assert_eq!(parsed.documents[2].size, Some(4096)); + assert_eq!(parsed.documents[3].size, Some(0)); } } diff --git a/crates/transfer/src/upload.rs b/crates/transfer/src/upload.rs index bed84e4..875c7d0 100644 --- a/crates/transfer/src/upload.rs +++ b/crates/transfer/src/upload.rs @@ -102,17 +102,10 @@ pub async fn send( let ready_str = String::from_utf8(ready_raw).map_err(|e| cfms_core::Error::Protocol(e.to_string()))?; - if ready_str == "stop" { - return Err(cfms_core::Error::Protocol( - "upload rejected by server".into(), - )); - } - - let chunk_size: usize = ready_str - .strip_prefix("ready ") - .and_then(|s| s.split_whitespace().next()) - .and_then(|n| n.parse().ok()) - .unwrap_or(8192); + let Some(chunk_size) = parse_ready_signal(&ready_str, file_size)? else { + on_progress(0, file_size); + return Ok(()); + }; // --- Step 4: stream the file --- let mut file = tokio::fs::File::open(source).await?; @@ -142,3 +135,44 @@ pub async fn send( Ok(()) } + +fn parse_ready_signal(ready_str: &str, file_size: u64) -> Result> { + if ready_str == "stop" { + return if file_size == 0 { + Ok(None) + } else { + Err(cfms_core::Error::Protocol( + "server sent stop for a non-empty upload".into(), + )) + }; + } + + Ok(Some( + ready_str + .strip_prefix("ready ") + .and_then(|s| s.split_whitespace().next()) + .and_then(|n| n.parse().ok()) + .unwrap_or(8192), + )) +} + +#[cfg(test)] +mod tests { + use super::parse_ready_signal; + + #[test] + fn stop_completes_zero_byte_upload() { + assert_eq!(parse_ready_signal("stop", 0).unwrap(), None); + } + + #[test] + fn stop_rejects_non_empty_upload() { + let err = parse_ready_signal("stop", 1).unwrap_err().to_string(); + assert!(err.contains("server sent stop for a non-empty upload")); + } + + #[test] + fn ready_signal_uses_server_chunk_size() { + assert_eq!(parse_ready_signal("ready 16384", 42).unwrap(), Some(16384)); + } +} diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 116fbb5..4d814da 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -87,7 +87,7 @@ export interface ServerDocumentMetadata { export interface ServerDocumentInfo { document_id?: string; title?: string; - size?: number; + size?: number | null; created_time?: number | null; last_modified?: number | null; parent_id?: string | null; @@ -144,7 +144,7 @@ export interface SearchDocumentEntry { name?: string; title?: string; parent_id?: string | null; - size?: number; + size?: number | null; last_modified?: number | null; } diff --git a/src/lib/files/formatting.ts b/src/lib/files/formatting.ts index 834af2b..81b21fb 100644 --- a/src/lib/files/formatting.ts +++ b/src/lib/files/formatting.ts @@ -1,6 +1,6 @@ export function formatBytes(bytes: number | null | undefined): string { if (bytes === null || bytes === undefined) return '—'; - if (bytes === 0) return '0 B'; + if (bytes === 0) return '0'; const k = 1024; const sizes = ['B', 'KiB', 'MiB', 'GiB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); From 608758f52eb1568e049df8d21fb96f01a290313c Mon Sep 17 00:00:00 2001 From: Creeper19472 Date: Wed, 17 Jun 2026 15:08:43 +0800 Subject: [PATCH 5/5] chore: update version to 0.27.3 and enhance changelog with new features and fixes --- Cargo.lock | 12 ++++++------ Cargo.toml | 2 +- package.json | 2 +- src-tauri/gen/ios/CFMS Client/Info.plist | 4 ++-- src-tauri/tauri.conf.json | 2 +- src/lib/changelog/CHANGELOG.md | 15 +++++++++++++++ 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 78aadc1..6c04ed8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -661,7 +661,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "cfms-client-tauri" -version = "0.27.2" +version = "0.27.3" dependencies = [ "async-trait", "cfms-core", @@ -699,7 +699,7 @@ dependencies = [ [[package]] name = "cfms-core" -version = "0.27.2" +version = "0.27.3" dependencies = [ "hex", "serde", @@ -710,7 +710,7 @@ dependencies = [ [[package]] name = "cfms-crypto" -version = "0.27.2" +version = "0.27.3" dependencies = [ "aes-gcm", "base64ct", @@ -727,7 +727,7 @@ dependencies = [ [[package]] name = "cfms-service" -version = "0.27.2" +version = "0.27.3" dependencies = [ "cfms-core", "cfms-crypto", @@ -747,7 +747,7 @@ dependencies = [ [[package]] name = "cfms-transfer" -version = "0.27.2" +version = "0.27.3" dependencies = [ "base64ct", "cfms-core", @@ -764,7 +764,7 @@ dependencies = [ [[package]] name = "cfms-transport" -version = "0.27.2" +version = "0.27.3" dependencies = [ "base64ct", "cfms-core", diff --git a/Cargo.toml b/Cargo.toml index d0f8081..8bcf732 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.27.2" +version = "0.27.3" edition = "2024" license = "MIT" repository = "https://github.com/cfms-dev/cfms_client_tauri" diff --git a/package.json b/package.json index f5fca02..f03c117 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cfms-client-tauri", - "version": "0.27.2", + "version": "0.27.3", "description": "", "type": "module", "scripts": { diff --git a/src-tauri/gen/ios/CFMS Client/Info.plist b/src-tauri/gen/ios/CFMS Client/Info.plist index 4d8fe5d..2e8862f 100644 --- a/src-tauri/gen/ios/CFMS Client/Info.plist +++ b/src-tauri/gen/ios/CFMS Client/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.27.2 + 0.27.3 CFBundleVersion - 27002 + 27003 NSFaceIDUsageDescription Use Face ID to unlock CFMS Client when biometric app lock is enabled. LSRequiresIPhoneOS diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c713f6e..37cd8c1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CFMS Client", - "version": "0.27.2", + "version": "0.27.3", "identifier": "org.crpteam.cfms-client-tauri", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/lib/changelog/CHANGELOG.md b/src/lib/changelog/CHANGELOG.md index 3418e64..3e70ff3 100644 --- a/src/lib/changelog/CHANGELOG.md +++ b/src/lib/changelog/CHANGELOG.md @@ -4,6 +4,21 @@ This file is the product changelog shown inside the app. Keep entries newest fir --- +## v0.27.3 +**Released on:** 2026-06-17 + +**Title:** Implement file sorting with web worker support for improved p... + +### Fixed +- Update document size handling to support null values and improve formatting +- Rewrap DEK and upload it to the server in advance +- Add border to parent navigation button for better visibility + +### Added +- Implement file sorting with web worker support for improved performance + +--- + ## v0.27.2 **Released on:** 2026-06-16