From 2652bdda2eb3059b081d968d2c7d50bb5e49b861 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 22 Feb 2026 15:25:31 +0700 Subject: [PATCH] desktop: make tauri attachments filesystem-only --- .../desktop-attachment-store.test.ts | 115 +++++++++ .../attachments/desktop-attachment-store.ts | 207 +++++++++++++++ .../attachments/desktop-file-commands.ts | 49 ++++ .../attachments/desktop-preview-url.test.ts | 66 +++++ .../attachments/desktop-preview-url.ts | 61 +++++ .../desktop/tauri/invoke-desktop-command.ts | 7 +- packages/desktop/src-tauri/Cargo.lock | 1 + packages/desktop/src-tauri/Cargo.toml | 2 +- packages/desktop/src-tauri/src/lib.rs | 235 +++++++++++++++++- 9 files changed, 739 insertions(+), 4 deletions(-) create mode 100644 packages/app/src/desktop/attachments/desktop-attachment-store.test.ts create mode 100644 packages/app/src/desktop/attachments/desktop-attachment-store.ts create mode 100644 packages/app/src/desktop/attachments/desktop-file-commands.ts create mode 100644 packages/app/src/desktop/attachments/desktop-preview-url.test.ts create mode 100644 packages/app/src/desktop/attachments/desktop-preview-url.ts diff --git a/packages/app/src/desktop/attachments/desktop-attachment-store.test.ts b/packages/app/src/desktop/attachments/desktop-attachment-store.test.ts new file mode 100644 index 000000000..1eaa8b3c7 --- /dev/null +++ b/packages/app/src/desktop/attachments/desktop-attachment-store.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createDesktopAttachmentStore } from "./desktop-attachment-store"; + +const { + copyDesktopAttachmentFileMock, + writeDesktopAttachmentBase64Mock, + deleteDesktopAttachmentFileMock, + garbageCollectDesktopAttachmentFilesMock, + readDesktopFileBase64Mock, + resolveDesktopPreviewUrlMock, + releaseDesktopPreviewUrlMock, +} = vi.hoisted(() => ({ + copyDesktopAttachmentFileMock: vi.fn(async () => ({ + path: "/managed/att_1.png", + byteSize: 4, + })), + writeDesktopAttachmentBase64Mock: vi.fn(async () => ({ + path: "/managed/att_2.png", + byteSize: 4, + })), + deleteDesktopAttachmentFileMock: vi.fn(async () => true), + garbageCollectDesktopAttachmentFilesMock: vi.fn(async () => 0), + readDesktopFileBase64Mock: vi.fn(async () => "AAECAw=="), + resolveDesktopPreviewUrlMock: vi.fn(async () => "blob:test"), + releaseDesktopPreviewUrlMock: vi.fn(async () => {}), +})); + +vi.mock("./desktop-file-commands", () => ({ + copyDesktopAttachmentFile: copyDesktopAttachmentFileMock, + writeDesktopAttachmentBase64: writeDesktopAttachmentBase64Mock, + deleteDesktopAttachmentFile: deleteDesktopAttachmentFileMock, + garbageCollectDesktopAttachmentFiles: garbageCollectDesktopAttachmentFilesMock, +})); + +vi.mock("./desktop-preview-url", () => ({ + readDesktopFileBase64: readDesktopFileBase64Mock, + resolveDesktopPreviewUrl: resolveDesktopPreviewUrlMock, + releaseDesktopPreviewUrl: releaseDesktopPreviewUrlMock, +})); + +describe("desktop attachment store", () => { + beforeEach(() => { + copyDesktopAttachmentFileMock.mockClear(); + writeDesktopAttachmentBase64Mock.mockClear(); + deleteDesktopAttachmentFileMock.mockClear(); + garbageCollectDesktopAttachmentFilesMock.mockClear(); + readDesktopFileBase64Mock.mockClear(); + resolveDesktopPreviewUrlMock.mockClear(); + releaseDesktopPreviewUrlMock.mockClear(); + }); + + it("saves dropped file paths as desktop-file metadata", async () => { + const store = createDesktopAttachmentStore(); + const attachment = await store.save({ + id: "att_1", + mimeType: "image/png", + source: { + kind: "file_uri", + uri: "file:///Users/test/Desktop/image.png", + }, + }); + + expect(copyDesktopAttachmentFileMock).toHaveBeenCalledWith({ + attachmentId: "att_1", + sourcePath: "/Users/test/Desktop/image.png", + extension: ".png", + }); + expect(attachment.storageType).toBe("desktop-file"); + expect(attachment.storageKey).toBe("/managed/att_1.png"); + }); + + it("saves blob/data-url sources via desktop filesystem writes", async () => { + const store = createDesktopAttachmentStore(); + await store.save({ + id: "att_2", + source: { + kind: "data_url", + dataUrl: "data:image/png;base64,AAECAw==", + }, + }); + + expect(writeDesktopAttachmentBase64Mock).toHaveBeenCalledWith({ + attachmentId: "att_2", + base64: "AAECAw==", + extension: ".png", + }); + }); + + it("delegates encode/preview/delete/gc to desktop command path", async () => { + const store = createDesktopAttachmentStore(); + const attachment = { + id: "att_3", + mimeType: "image/jpeg", + storageType: "desktop-file" as const, + storageKey: "/managed/att_3.jpg", + createdAt: Date.now(), + }; + + await store.encodeBase64({ attachment }); + await store.resolvePreviewUrl({ attachment }); + await store.releasePreviewUrl?.({ attachment, url: "blob:test" }); + await store.delete({ attachment }); + await store.garbageCollect({ referencedIds: new Set(["att_3"]) }); + + expect(readDesktopFileBase64Mock).toHaveBeenCalledWith("/managed/att_3.jpg"); + expect(resolveDesktopPreviewUrlMock).toHaveBeenCalled(); + expect(releaseDesktopPreviewUrlMock).toHaveBeenCalledWith({ url: "blob:test" }); + expect(deleteDesktopAttachmentFileMock).toHaveBeenCalledWith({ + path: "/managed/att_3.jpg", + }); + expect(garbageCollectDesktopAttachmentFilesMock).toHaveBeenCalledWith({ + referencedIds: ["att_3"], + }); + }); +}); diff --git a/packages/app/src/desktop/attachments/desktop-attachment-store.ts b/packages/app/src/desktop/attachments/desktop-attachment-store.ts new file mode 100644 index 000000000..87c047687 --- /dev/null +++ b/packages/app/src/desktop/attachments/desktop-attachment-store.ts @@ -0,0 +1,207 @@ +import type { AttachmentMetadata, AttachmentStore } from "@/attachments/types"; +import { + blobToBase64, + fileUriToPath, + generateAttachmentId, + getFileExtensionFromName, + normalizeMimeType, + parseDataUrl, +} from "@/attachments/utils"; +import { + copyDesktopAttachmentFile, + deleteDesktopAttachmentFile, + garbageCollectDesktopAttachmentFiles, + writeDesktopAttachmentBase64, +} from "@/desktop/attachments/desktop-file-commands"; +import { + readDesktopFileBase64, + releaseDesktopPreviewUrl, + resolveDesktopPreviewUrl, +} from "@/desktop/attachments/desktop-preview-url"; + +const IMAGE_EXTENSION_BY_MIME_TYPE: Record = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + "image/avif": ".avif", + "image/heic": ".heic", + "image/heif": ".heif", + "image/tiff": ".tiff", + "image/bmp": ".bmp", + "image/svg+xml": ".svg", +}; + +function inferFileNameFromPath(path: string): string | null { + const normalizedPath = path.replace(/\\/g, "/"); + const parts = normalizedPath.split("/"); + const lastPart = parts[parts.length - 1]; + return lastPart && lastPart.length > 0 ? lastPart : null; +} + +function extensionForAttachment(input: { + fileName?: string | null; + sourcePath?: string | null; + mimeType: string; +}): string { + const fromName = getFileExtensionFromName(input.fileName); + if (fromName) { + return fromName; + } + + const fromSourcePath = getFileExtensionFromName(input.sourcePath); + if (fromSourcePath) { + return fromSourcePath; + } + + return IMAGE_EXTENSION_BY_MIME_TYPE[input.mimeType] ?? ".img"; +} + +function toDesktopMetadata(input: { + id: string; + mimeType: string; + storagePath: string; + byteSize: number; + fileName?: string | null; +}): AttachmentMetadata { + return { + id: input.id, + mimeType: input.mimeType, + storageType: "desktop-file", + storageKey: input.storagePath, + fileName: input.fileName ?? null, + byteSize: input.byteSize, + createdAt: Date.now(), + }; +} + +async function saveDesktopAttachmentFromFileUri(input: { + id: string; + uri: string; + mimeType?: string; + fileName?: string | null; +}): Promise { + const sourcePath = fileUriToPath(input.uri); + const fileName = input.fileName ?? inferFileNameFromPath(sourcePath); + const mimeType = normalizeMimeType(input.mimeType); + const extension = extensionForAttachment({ + fileName, + sourcePath, + mimeType, + }); + + const result = await copyDesktopAttachmentFile({ + attachmentId: input.id, + sourcePath, + extension, + }); + + return toDesktopMetadata({ + id: input.id, + mimeType, + storagePath: result.path, + byteSize: result.byteSize, + fileName, + }); +} + +async function saveDesktopAttachmentFromBase64(input: { + id: string; + base64: string; + mimeType: string; + fileName?: string | null; +}): Promise { + const extension = extensionForAttachment({ + fileName: input.fileName, + mimeType: input.mimeType, + }); + + const result = await writeDesktopAttachmentBase64({ + attachmentId: input.id, + base64: input.base64, + extension, + }); + + return toDesktopMetadata({ + id: input.id, + mimeType: input.mimeType, + storagePath: result.path, + byteSize: result.byteSize, + fileName: input.fileName, + }); +} + +function assertDesktopAttachment(attachment: AttachmentMetadata): void { + if (attachment.storageType !== "desktop-file") { + throw new Error( + `Unsupported desktop attachment storage type '${attachment.storageType}'.` + ); + } +} + +export function createDesktopAttachmentStore(): AttachmentStore { + return { + storageType: "desktop-file", + + async save(input): Promise { + const id = input.id ?? generateAttachmentId(); + const fileName = input.fileName ?? null; + + if (input.source.kind === "file_uri") { + return await saveDesktopAttachmentFromFileUri({ + id, + uri: input.source.uri, + mimeType: input.mimeType, + fileName, + }); + } + + if (input.source.kind === "data_url") { + const parsed = parseDataUrl(input.source.dataUrl); + const mimeType = normalizeMimeType(input.mimeType ?? parsed.mimeType); + return await saveDesktopAttachmentFromBase64({ + id, + base64: parsed.base64, + mimeType, + fileName, + }); + } + + const mimeType = normalizeMimeType(input.mimeType ?? input.source.blob.type); + const base64 = await blobToBase64(input.source.blob); + return await saveDesktopAttachmentFromBase64({ + id, + base64, + mimeType, + fileName, + }); + }, + + async encodeBase64({ attachment }): Promise { + assertDesktopAttachment(attachment); + return await readDesktopFileBase64(attachment.storageKey); + }, + + async resolvePreviewUrl({ attachment }): Promise { + assertDesktopAttachment(attachment); + return await resolveDesktopPreviewUrl(attachment); + }, + + async releasePreviewUrl({ attachment, url }): Promise { + assertDesktopAttachment(attachment); + await releaseDesktopPreviewUrl({ url }); + }, + + async delete({ attachment }): Promise { + assertDesktopAttachment(attachment); + await deleteDesktopAttachmentFile({ path: attachment.storageKey }); + }, + + async garbageCollect({ referencedIds }): Promise { + await garbageCollectDesktopAttachmentFiles({ + referencedIds: Array.from(referencedIds), + }); + }, + }; +} diff --git a/packages/app/src/desktop/attachments/desktop-file-commands.ts b/packages/app/src/desktop/attachments/desktop-file-commands.ts new file mode 100644 index 000000000..f8e28e20f --- /dev/null +++ b/packages/app/src/desktop/attachments/desktop-file-commands.ts @@ -0,0 +1,49 @@ +import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command"; + +interface AttachmentFileResult { + path: string; + byteSize: number; +} + +export async function writeDesktopAttachmentBase64(input: { + attachmentId: string; + base64: string; + extension?: string | null; +}): Promise { + return await invokeDesktopCommand( + "write_attachment_base64", + { + attachmentId: input.attachmentId, + base64: input.base64, + extension: input.extension ?? null, + } + ); +} + +export async function copyDesktopAttachmentFile(input: { + attachmentId: string; + sourcePath: string; + extension?: string | null; +}): Promise { + return await invokeDesktopCommand("copy_attachment_file", { + attachmentId: input.attachmentId, + sourcePath: input.sourcePath, + extension: input.extension ?? null, + }); +} + +export async function deleteDesktopAttachmentFile(input: { + path: string; +}): Promise { + return await invokeDesktopCommand("delete_attachment_file", { + path: input.path, + }); +} + +export async function garbageCollectDesktopAttachmentFiles(input: { + referencedIds: readonly string[]; +}): Promise { + return await invokeDesktopCommand("garbage_collect_attachment_files", { + referencedIds: [...input.referencedIds], + }); +} diff --git a/packages/app/src/desktop/attachments/desktop-preview-url.test.ts b/packages/app/src/desktop/attachments/desktop-preview-url.test.ts new file mode 100644 index 000000000..daaf8b429 --- /dev/null +++ b/packages/app/src/desktop/attachments/desktop-preview-url.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { invokeDesktopCommandMock } = vi.hoisted(() => ({ + invokeDesktopCommandMock: vi.fn(async () => "AAECAw=="), +})); + +vi.mock("@/desktop/tauri/invoke-desktop-command", () => ({ + invokeDesktopCommand: invokeDesktopCommandMock, +})); + +import { + __desktopPreviewUrlTestUtils, + releaseDesktopPreviewUrl, + resolveDesktopPreviewUrl, +} from "./desktop-preview-url"; + +describe("desktop preview URLs", () => { + const createObjectURL = vi.fn(() => "blob:desktop-preview-1"); + const revokeObjectURL = vi.fn(); + + beforeEach(() => { + vi.stubGlobal("URL", { + createObjectURL, + revokeObjectURL, + }); + }); + + afterEach(() => { + createObjectURL.mockClear(); + revokeObjectURL.mockClear(); + invokeDesktopCommandMock.mockClear(); + __desktopPreviewUrlTestUtils.clearActiveObjectUrls(); + vi.unstubAllGlobals(); + }); + + it("resolves renderer-safe blob URLs for desktop attachments", async () => { + const url = await resolveDesktopPreviewUrl({ + id: "att-1", + mimeType: "image/png", + storageType: "desktop-file", + storageKey: "/tmp/att-1.png", + createdAt: Date.now(), + }); + + expect(invokeDesktopCommandMock).toHaveBeenCalledWith("read_file_base64", { + path: "/tmp/att-1.png", + }); + expect(url).toBe("blob:desktop-preview-1"); + expect(url.startsWith("asset://")).toBe(false); + expect(createObjectURL).toHaveBeenCalledTimes(1); + }); + + it("releases blob URLs via URL.revokeObjectURL", async () => { + const url = await resolveDesktopPreviewUrl({ + id: "att-2", + mimeType: "image/jpeg", + storageType: "desktop-file", + storageKey: "/tmp/att-2.jpg", + createdAt: Date.now(), + }); + + await releaseDesktopPreviewUrl({ url }); + + expect(revokeObjectURL).toHaveBeenCalledWith("blob:desktop-preview-1"); + }); +}); diff --git a/packages/app/src/desktop/attachments/desktop-preview-url.ts b/packages/app/src/desktop/attachments/desktop-preview-url.ts new file mode 100644 index 000000000..14987149a --- /dev/null +++ b/packages/app/src/desktop/attachments/desktop-preview-url.ts @@ -0,0 +1,61 @@ +import type { AttachmentMetadata } from "@/attachments/types"; +import { fileUriToPath } from "@/attachments/utils"; +import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command"; + +function base64ToUint8Array(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +const activeObjectUrls = new Set(); + +export async function readDesktopFileBase64(pathOrUri: string): Promise { + return await invokeDesktopCommand("read_file_base64", { + path: fileUriToPath(pathOrUri), + }); +} + +export async function resolveDesktopPreviewUrl( + attachment: AttachmentMetadata +): Promise { + const base64 = await readDesktopFileBase64(attachment.storageKey); + + if ( + typeof URL !== "undefined" && + typeof URL.createObjectURL === "function" + ) { + const bytes = base64ToUint8Array(base64); + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + const blob = new Blob([buffer], { type: attachment.mimeType }); + const objectUrl = URL.createObjectURL(blob); + activeObjectUrls.add(objectUrl); + return objectUrl; + } + + return `data:${attachment.mimeType};base64,${base64}`; +} + +export async function releaseDesktopPreviewUrl(input: { + url: string; +}): Promise { + if (!activeObjectUrls.has(input.url)) { + return; + } + activeObjectUrls.delete(input.url); + + if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") { + URL.revokeObjectURL(input.url); + } +} + +export const __desktopPreviewUrlTestUtils = { + base64ToUint8Array, + clearActiveObjectUrls: () => { + activeObjectUrls.clear(); + }, +}; diff --git a/packages/app/src/desktop/tauri/invoke-desktop-command.ts b/packages/app/src/desktop/tauri/invoke-desktop-command.ts index 8df181ab1..04e07d776 100644 --- a/packages/app/src/desktop/tauri/invoke-desktop-command.ts +++ b/packages/app/src/desktop/tauri/invoke-desktop-command.ts @@ -1,10 +1,13 @@ import { getTauri } from "@/utils/tauri"; -export async function invokeDesktopCommand(command: string): Promise { +export async function invokeDesktopCommand( + command: string, + args?: Record +): Promise { const invoke = getTauri()?.core?.invoke; if (typeof invoke !== "function") { throw new Error("Tauri invoke() is unavailable in this environment."); } - return (await invoke(command)) as T; + return (await invoke(command, args)) as T; } diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index bfed8a8d8..db055c5fe 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2631,6 +2631,7 @@ dependencies = [ name = "paseo" version = "0.1.15" dependencies = [ + "base64 0.22.1", "log", "serde", "serde_json", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index a62f21c94..03d9f4314 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -22,6 +22,7 @@ path = "src/main.rs" tauri-build = { version = "2.5.3", features = [] } [dependencies] +base64 = "0.22" serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" @@ -33,4 +34,3 @@ tauri-plugin-opener = "2" tauri-plugin-updater = "2" tauri-plugin-websocket = "2" - diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index c11d82b16..766c2480b 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -1,4 +1,8 @@ +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use serde::Serialize; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::atomic::{AtomicU64, Ordering}; use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu}; @@ -53,6 +57,13 @@ struct LocalDaemonVersionResult { error: Option, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AttachmentFileResult { + path: String, + byte_size: u64, +} + fn resolve_login_shell() -> String { std::env::var("SHELL") .ok() @@ -209,6 +220,223 @@ async fn install_app_update(app: AppHandle) -> Result Result { + let app_data_dir = app + .path() + .app_data_dir() + .map_err(|error| format!("Failed to resolve app data directory: {error}"))?; + let attachment_dir = app_data_dir.join("paseo-desktop-attachments"); + fs::create_dir_all(&attachment_dir) + .map_err(|error| format!("Failed to create attachment directory: {error}"))?; + Ok(attachment_dir) +} + +fn normalize_extension(extension: Option) -> String { + let raw = extension + .unwrap_or_default() + .trim() + .trim_matches('.') + .to_string(); + if raw.is_empty() { + String::new() + } else { + format!(".{raw}") + } +} + +fn validate_attachment_id(attachment_id: &str) -> Result<(), String> { + if attachment_id.is_empty() { + return Err("Attachment ID cannot be empty.".to_string()); + } + if !attachment_id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') + { + return Err("Attachment ID contains invalid characters.".to_string()); + } + Ok(()) +} + +fn clear_existing_attachment_files( + attachment_dir: &Path, + attachment_id: &str, +) -> Result<(), String> { + let id_prefix = format!("{attachment_id}."); + let entries = fs::read_dir(attachment_dir) + .map_err(|error| format!("Failed to scan attachment directory: {error}"))?; + + for entry in entries { + let entry = entry.map_err(|error| format!("Failed to read directory entry: {error}"))?; + let path = entry.path(); + if !path.is_file() { + continue; + } + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + if file_name == attachment_id || file_name.starts_with(&id_prefix) { + fs::remove_file(&path) + .map_err(|error| format!("Failed to remove prior attachment file: {error}"))?; + } + } + + Ok(()) +} + +fn build_attachment_path(attachment_dir: &Path, attachment_id: &str, extension: &str) -> PathBuf { + attachment_dir.join(format!("{attachment_id}{extension}")) +} + +fn canonicalize_managed_attachment_path( + attachment_dir: &Path, + path: &str, +) -> Result { + let candidate = PathBuf::from(path); + if !candidate.exists() { + return Err(format!("Attachment file not found at path: {path}")); + } + + let canonical_candidate = fs::canonicalize(&candidate) + .map_err(|error| format!("Failed to resolve attachment path: {error}"))?; + let canonical_dir = fs::canonicalize(attachment_dir) + .map_err(|error| format!("Failed to resolve attachment directory: {error}"))?; + + if !canonical_candidate.starts_with(&canonical_dir) { + return Err("Attachment path is outside managed attachment directory.".to_string()); + } + + Ok(canonical_candidate) +} + +#[tauri::command] +async fn write_attachment_base64( + app: AppHandle, + attachment_id: String, + base64: String, + extension: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + validate_attachment_id(&attachment_id)?; + let attachment_dir = resolve_attachment_dir(&app)?; + clear_existing_attachment_files(&attachment_dir, &attachment_id)?; + let normalized_extension = normalize_extension(extension); + let attachment_path = + build_attachment_path(&attachment_dir, &attachment_id, &normalized_extension); + let decoded_bytes = BASE64_STANDARD + .decode(base64.as_bytes()) + .map_err(|error| format!("Failed to decode attachment base64: {error}"))?; + fs::write(&attachment_path, &decoded_bytes) + .map_err(|error| format!("Failed to write attachment file: {error}"))?; + + Ok(AttachmentFileResult { + path: attachment_path.to_string_lossy().into_owned(), + byte_size: decoded_bytes.len() as u64, + }) + }) + .await + .map_err(|error| format!("Attachment write task failed: {error}"))? +} + +#[tauri::command] +async fn copy_attachment_file( + app: AppHandle, + attachment_id: String, + source_path: String, + extension: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + validate_attachment_id(&attachment_id)?; + let source = PathBuf::from(source_path); + if !source.exists() { + return Err("Source attachment file does not exist.".to_string()); + } + + let source_extension = source + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_string()); + let normalized_extension = normalize_extension(extension.or(source_extension)); + let attachment_dir = resolve_attachment_dir(&app)?; + clear_existing_attachment_files(&attachment_dir, &attachment_id)?; + let destination_path = + build_attachment_path(&attachment_dir, &attachment_id, &normalized_extension); + let copied_bytes = fs::copy(&source, &destination_path) + .map_err(|error| format!("Failed to copy attachment file: {error}"))?; + + Ok(AttachmentFileResult { + path: destination_path.to_string_lossy().into_owned(), + byte_size: copied_bytes, + }) + }) + .await + .map_err(|error| format!("Attachment copy task failed: {error}"))? +} + +#[tauri::command] +async fn read_file_base64(app: AppHandle, path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let attachment_dir = resolve_attachment_dir(&app)?; + let attachment_path = canonicalize_managed_attachment_path(&attachment_dir, &path)?; + let bytes = fs::read(&attachment_path) + .map_err(|error| format!("Failed to read attachment file: {error}"))?; + Ok(BASE64_STANDARD.encode(bytes)) + }) + .await + .map_err(|error| format!("Attachment read task failed: {error}"))? +} + +#[tauri::command] +async fn delete_attachment_file(app: AppHandle, path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let attachment_dir = resolve_attachment_dir(&app)?; + let attachment_path = match canonicalize_managed_attachment_path(&attachment_dir, &path) { + Ok(path) => path, + Err(_) => return Ok(false), + }; + fs::remove_file(&attachment_path) + .map_err(|error| format!("Failed to delete attachment file: {error}"))?; + Ok(true) + }) + .await + .map_err(|error| format!("Attachment delete task failed: {error}"))? +} + +#[tauri::command] +async fn garbage_collect_attachment_files( + app: AppHandle, + referenced_ids: Vec, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let attachment_dir = resolve_attachment_dir(&app)?; + let referenced = referenced_ids.into_iter().collect::>(); + let mut deleted_count = 0_u64; + + let entries = fs::read_dir(&attachment_dir) + .map_err(|error| format!("Failed to scan attachment directory: {error}"))?; + for entry in entries { + let entry = entry.map_err(|error| format!("Failed to read directory entry: {error}"))?; + let path = entry.path(); + if !path.is_file() { + continue; + } + + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + let id = file_name.split('.').next().unwrap_or_default(); + if id.is_empty() || referenced.contains(id) { + continue; + } + + fs::remove_file(&path) + .map_err(|error| format!("Failed to delete stale attachment file: {error}"))?; + deleted_count += 1; + } + + Ok(deleted_count) + }) + .await + .map_err(|error| format!("Attachment GC task failed: {error}"))? +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -221,7 +449,12 @@ pub fn run() { get_local_daemon_version, run_local_daemon_update, check_app_update, - install_app_update + install_app_update, + write_attachment_base64, + copy_attachment_file, + read_file_base64, + delete_attachment_file, + garbage_collect_attachment_files ]) .setup(|app| { if cfg!(debug_assertions) {