diff --git a/AGENTS.md b/AGENTS.md index dd5e70a7..cc3eb29b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,19 +40,19 @@ make tauri-dev # Tauri development - **MessageHandler.js** - Email state management (collection, pinning, sorting, selection) - **FileHandler.js** - Drag-drop handling, delegates parsing to injected parsers - **utils.js** - MSG/EML parsing logic using `@kenjiuno/msgreader` and custom MIME parser -- **tauri-bridge.js** - IPC layer for native features (file ops, dialogs, system viewer) +- **tauri-bridge.js** - IPC layer for native features (file ops, Save As dialog, updates) ### UI Layer (src/js/ui/) - **UIManager.js** - Delegates to specialized sub-managers - **MessageListRenderer.js** - Sidebar list with virtual scrolling - **MessageContentRenderer.js** - Email body with HTML sanitization and inline images -- **AttachmentModalManager.js** - Attachment preview/download with system viewer support +- **AttachmentModalManager.js** - Attachment preview modal and download (web + Tauri Save As) - **VirtualList.js** - Performance optimization for large email lists ### Desktop App (src-tauri/) -- **lib.rs** - Tauri commands: file reading, system viewer, Save As dialog +- **lib.rs** - Tauri commands: file reading, Save As dialog - Single-instance enforcement, file associations (.msg, .eml), auto-update ## Key Patterns diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d8854079..9a3dbf2b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,61 +13,6 @@ fn read_file_as_bytes(path: String) -> Result, String> { std::fs::read(&path).map_err(|e| format!("Failed to read file {}: {}", path, e)) } -/// Save a base64-encoded file to temp directory and open with system viewer -#[tauri::command] -fn open_file_with_system(base64_content: String, file_name: String) -> Result<(), String> { - use base64::{Engine as _, engine::general_purpose::STANDARD}; - - // Decode base64 content - let bytes = STANDARD.decode(&base64_content) - .map_err(|e| format!("Failed to decode base64: {}", e))?; - - // Create temp file path - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(&file_name); - - // Write to temp file - let mut file = std::fs::File::create(&temp_path) - .map_err(|e| format!("Failed to create temp file: {}", e))?; - file.write_all(&bytes) - .map_err(|e| format!("Failed to write temp file: {}", e))?; - - // Open with system default application - #[cfg(target_os = "macos")] - { - std::process::Command::new("open") - .arg(&temp_path) - .spawn() - .map_err(|e| format!("Failed to open file: {}", e))?; - } - - #[cfg(target_os = "windows")] - { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x08000000; - - // Use cmd.exe's start command - more reliable than PowerShell for opening files - // The empty "" is required as start interprets the first quoted arg as window title - // CREATE_NO_WINDOW prevents the console window from flashing - std::process::Command::new("cmd") - .args(["/c", "start", ""]) - .arg(&temp_path) - .creation_flags(CREATE_NO_WINDOW) - .spawn() - .map_err(|e| format!("Failed to open file: {}", e))?; - } - - #[cfg(target_os = "linux")] - { - std::process::Command::new("xdg-open") - .arg(&temp_path) - .spawn() - .map_err(|e| format!("Failed to open file: {}", e))?; - } - - Ok(()) -} - /// Save a file with a "Save As" dialog #[tauri::command] async fn save_file_with_dialog( @@ -182,7 +127,7 @@ pub fn run() { Ok(()) }) - .invoke_handler(tauri::generate_handler![read_file_as_bytes, get_pending_files, open_file_with_system, save_file_with_dialog]); + .invoke_handler(tauri::generate_handler![read_file_as_bytes, get_pending_files, save_file_with_dialog]); builder .build(tauri::generate_context!()) diff --git a/src/js/tauri-bridge.js b/src/js/tauri-bridge.js index 3748b090..a6dbf517 100644 --- a/src/js/tauri-bridge.js +++ b/src/js/tauri-bridge.js @@ -89,30 +89,6 @@ export function getFileName(filePath) { return parts[parts.length - 1]; } -/** - * Open a file with the system's default application (Tauri only) - * Saves the file to a temp location and opens it - * @param {string} base64Data - Base64 data URL (data:mime/type;base64,...) - * @param {string} fileName - Original filename - * @returns {Promise} - */ -export async function openWithSystemViewer(base64Data, fileName) { - if (!isTauri()) { - throw new Error('openWithSystemViewer is only available in Tauri'); - } - - const { invoke } = await import('@tauri-apps/api/core'); - - // Extract the base64 content (remove data:mime/type;base64, prefix) - const base64Content = base64Data.split(',')[1]; - - // Call Rust command to save and open the file - await invoke('open_file_with_system', { - base64Content, - fileName, - }); -} - /** * Save a file with a "Save As" dialog (Tauri only) * @param {string} base64Data - Base64 data URL (data:mime/type;base64,...) diff --git a/src/js/ui/AttachmentModalManager.js b/src/js/ui/AttachmentModalManager.js index 0ce5ed8c..425775d3 100644 --- a/src/js/ui/AttachmentModalManager.js +++ b/src/js/ui/AttachmentModalManager.js @@ -1,4 +1,4 @@ -import { isTauri, openWithSystemViewer, saveFileWithDialog } from '../tauri-bridge.js'; +import { isTauri, saveFileWithDialog } from '../tauri-bridge.js'; import { extractEml } from '../utils.js'; /** @@ -41,6 +41,7 @@ export class AttachmentModalManager { this.imageViewerFallbackWidth = 960; this.imageViewerFallbackHeight = 720; this.inlineImageMetadataBySource = new Map(); + this._pdfPreviewObjectUrl = null; // Navigation stack for nested content (e.g., attachments within nested emails) this.navigationStack = []; @@ -288,13 +289,34 @@ export class AttachmentModalManager { } /** - * Checks if a MIME type is PDF + * True when filename ends with .pdf (case-insensitive) + * @param {string} fileName + * @returns {boolean} + */ + _hasPdfExtension(fileName) { + return /\.pdf$/i.test(fileName || ''); + } + + /** + * MIME types often used when the real type is unknown but file is binary + * @param {string} mimeType + * @returns {boolean} + */ + _isGenericBinaryMime(mimeType) { + const m = (mimeType || '').toLowerCase(); + return !m || m === 'application/octet-stream' || m === 'binary/octet-stream'; + } + + /** + * Checks if an attachment is PDF by MIME and/or .pdf extension with generic MIME * @param {string} mimeType - MIME type to check - * @returns {boolean} True if the MIME type is PDF + * @param {string} [fileName=''] - Original filename + * @returns {boolean} True if the attachment should be previewed as PDF */ - isPdf(mimeType) { - if (!mimeType) return false; - return mimeType.toLowerCase() === 'application/pdf'; + isPdf(mimeType, fileName = '') { + const m = (mimeType || '').toLowerCase(); + if (m === 'application/pdf') return true; + return this._hasPdfExtension(fileName) && this._isGenericBinaryMime(mimeType); } /** @@ -326,38 +348,15 @@ export class AttachmentModalManager { /** * Checks if an attachment can be previewed in the modal * @param {string} mimeType - MIME type to check + * @param {string} [fileName=''] - Original filename (used for PDF-by-extension) * @returns {boolean} True if the attachment is previewable */ - isPreviewable(mimeType) { - return this.isPreviewableImage(mimeType) || this.isPdf(mimeType) || this.isText(mimeType) || this.isPreviewableEml(mimeType); - } - - /** - * Checks if an attachment should be opened with system viewer (Tauri PDF) - * @param {Object} attachment - Attachment object - * @returns {boolean} True if should use system viewer - */ - _shouldOpenWithSystemViewer(attachment) { - return isTauri() && this.isPdf(attachment.attachMimeTag); - } - - /** - * Opens a PDF with the system viewer (Tauri only) - * @param {Object} attachment - Attachment object - */ - _openPdfWithSystemViewer(attachment) { - openWithSystemViewer(attachment.contentBase64, attachment.fileName) - .catch(err => { - console.error('Failed to open PDF with system viewer:', err); - if (this.showToast) { - this.showToast('Failed to open PDF', 'error'); - } - }); + isPreviewable(mimeType, fileName = '') { + return this.isPreviewableImage(mimeType) || this.isPdf(mimeType, fileName) || this.isText(mimeType) || this.isPreviewableEml(mimeType); } /** * Opens the attachment preview modal for a specific attachment - * In Tauri, PDFs are opened with the system viewer instead of the modal * @param {Object} attachment - Attachment object to preview */ open(attachment) { @@ -366,16 +365,10 @@ export class AttachmentModalManager { // Clear navigation stack when opening a new attachment this.clearNavigationStack(); - // In Tauri, open PDFs with system viewer (WebKit has issues with data: URLs) - if (this._shouldOpenWithSystemViewer(attachment)) { - this._openPdfWithSystemViewer(attachment); - return; - } - // Build list of previewable attachments if not already set if (this.currentAttachments) { this.previewableAttachments = this.currentAttachments.filter(att => - this.isPreviewable(att.attachMimeTag) + this.isPreviewable(att.attachMimeTag, att.fileName) ); } @@ -405,6 +398,7 @@ export class AttachmentModalManager { */ renderAttachmentPreview(attachment) { this.resetImagePreviewState(); + this._revokePdfPreviewObjectUrl(); // Set filename with breadcrumb if navigating from nested content this.updateFilenameWithBreadcrumb(attachment.fileName); @@ -421,12 +415,27 @@ export class AttachmentModalManager { // Render appropriate preview if (this.isPreviewableImage(attachment.attachMimeTag)) { this.renderImagePreview(attachment); - } else if (this.isPdf(attachment.attachMimeTag)) { - // Use object tag for better PDF compatibility with data: URLs + } else if (this.isPdf(attachment.attachMimeTag, attachment.fileName)) { const pdfObject = document.createElement('object'); - pdfObject.data = attachment.contentBase64; pdfObject.type = 'application/pdf'; - pdfObject.innerHTML = `

PDF cannot be displayed. Download here

`; + try { + const base64Data = attachment.contentBase64?.split(',')[1]; + if (!base64Data) { + throw new Error('Invalid base64 data format'); + } + const binary = atob(base64Data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + const blob = new Blob([bytes], { type: 'application/pdf' }); + this._pdfPreviewObjectUrl = URL.createObjectURL(blob); + pdfObject.data = this._pdfPreviewObjectUrl; + } catch (err) { + console.error('Error building PDF preview:', err); + pdfObject.removeAttribute('data'); + } + pdfObject.innerHTML = `

PDF cannot be displayed. Download here

`; this.attachmentModalContent.appendChild(pdfObject); } else if (this.isText(attachment.attachMimeTag)) { // Decode base64 to text @@ -625,6 +634,16 @@ export class AttachmentModalManager { this.attachmentModalSourceLink.hidden = false; } + /** + * Releases the blob URL for the in-modal PDF preview + */ + _revokePdfPreviewObjectUrl() { + if (this._pdfPreviewObjectUrl) { + URL.revokeObjectURL(this._pdfPreviewObjectUrl); + this._pdfPreviewObjectUrl = null; + } + } + /** * Resets image-specific preview state */ @@ -872,9 +891,9 @@ export class AttachmentModalManager { attachmentsList.className = 'nested-email-attachments-list'; emailData.attachments.forEach(att => { - const isPreviewable = this.isPreviewable(att.attachMimeTag); + const isPreviewable = this.isPreviewable(att.attachMimeTag, att.fileName); const isImage = this.isPreviewableImage(att.attachMimeTag); - const isPdf = this.isPdf(att.attachMimeTag); + const isPdf = this.isPdf(att.attachMimeTag, att.fileName); const isText = this.isText(att.attachMimeTag); const isEml = this.isPreviewableEml(att.attachMimeTag); @@ -928,9 +947,6 @@ export class AttachmentModalManager { if (downloadBtn) { e.stopPropagation(); this.downloadAttachment(att); - } else if (this._shouldOpenWithSystemViewer(att)) { - // PDF in Tauri: open with system viewer instead of modal preview - this._openPdfWithSystemViewer(att); } else { this.pushToStack(attachment); this.renderAttachmentPreview(att); @@ -1037,6 +1053,7 @@ export class AttachmentModalManager { close() { if (!this.attachmentModal) return; + this._revokePdfPreviewObjectUrl(); this.attachmentModal.classList.remove('active'); this.attachmentModalContent.innerHTML = ''; this.attachmentModalContent.classList.remove('attachment-modal-content--image'); diff --git a/src/js/ui/MessageContentRenderer.js b/src/js/ui/MessageContentRenderer.js index c4b5d9b1..8668b0c7 100644 --- a/src/js/ui/MessageContentRenderer.js +++ b/src/js/ui/MessageContentRenderer.js @@ -469,7 +469,7 @@ export class MessageContentRenderer { */ renderAttachmentItems(items) { return items.map(({ attachment, index }) => { - const isPreviewable = this.attachmentModal?.isPreviewable(attachment.attachMimeTag); + const isPreviewable = this.attachmentModal?.isPreviewable(attachment.attachMimeTag, attachment.fileName); if (isPreviewable) { return ` @@ -529,7 +529,7 @@ export class MessageContentRenderer { */ getAttachmentItemIcon(attachment) { const isImage = this.attachmentModal?.isPreviewableImage(attachment.attachMimeTag); - const isPdf = this.attachmentModal?.isPdf(attachment.attachMimeTag); + const isPdf = this.attachmentModal?.isPdf(attachment.attachMimeTag, attachment.fileName); const isText = this.attachmentModal?.isText(attachment.attachMimeTag); const isEml = this.attachmentModal?.isPreviewableEml(attachment.attachMimeTag); diff --git a/tests/UIManager.test.js b/tests/UIManager.test.js index 98b54b31..82cd9620 100644 --- a/tests/UIManager.test.js +++ b/tests/UIManager.test.js @@ -6,8 +6,7 @@ // Mock the tauri-bridge module jest.mock('../src/js/tauri-bridge.js', () => ({ isTauri: jest.fn(() => false), - saveFileWithDialog: jest.fn(() => Promise.resolve(true)), - openWithSystemViewer: jest.fn(() => Promise.resolve()) + saveFileWithDialog: jest.fn(() => Promise.resolve(true)) })); // Mock DOMPurify @@ -20,7 +19,7 @@ import { ToastManager } from '../src/js/ui/ToastManager.js'; import { AttachmentModalManager } from '../src/js/ui/AttachmentModalManager.js'; import { MessageListRenderer } from '../src/js/ui/MessageListRenderer.js'; import { MessageContentRenderer } from '../src/js/ui/MessageContentRenderer.js'; -import { isTauri, openWithSystemViewer, saveFileWithDialog } from '../src/js/tauri-bridge.js'; +import { isTauri, saveFileWithDialog } from '../src/js/tauri-bridge.js'; /** * Creates a mock message object for testing @@ -80,7 +79,6 @@ describe('UIManager (Facade)', () => { beforeEach(() => { setupDOM(); isTauri.mockReturnValue(false); - openWithSystemViewer.mockClear(); saveFileWithDialog.mockClear(); mockMessageHandler = { @@ -445,6 +443,9 @@ describe('AttachmentModalManager', () => { expect(modal.isPdf('APPLICATION/PDF')).toBe(true); expect(modal.isPdf('image/png')).toBe(false); expect(modal.isPdf(null)).toBe(false); + expect(modal.isPdf('application/octet-stream', 'report.pdf')).toBe(true); + expect(modal.isPdf('binary/octet-stream', 'Doc.PDF')).toBe(true); + expect(modal.isPdf('text/plain', 'notes.pdf')).toBe(false); }); test('isText', () => { @@ -463,6 +464,7 @@ describe('AttachmentModalManager', () => { expect(modal.isPreviewable('application/pdf')).toBe(true); expect(modal.isPreviewable('text/plain')).toBe(true); expect(modal.isPreviewable('application/octet-stream')).toBe(false); + expect(modal.isPreviewable('application/octet-stream', 'document.pdf')).toBe(true); }); }); @@ -641,11 +643,13 @@ describe('AttachmentModalManager', () => { expect(modal.attachmentModalZoomOut.disabled).toBe(true); }); - test('creates object for PDFs', () => { + test('creates object for PDFs with blob URL', () => { const att = { fileName: 'test.pdf', attachMimeTag: 'application/pdf', contentBase64: 'data:application/pdf;base64,abc' }; modal.setAttachments([att]); modal.renderAttachmentPreview(att); - expect(modal.attachmentModalContent.querySelector('object')).toBeTruthy(); + const pdfObject = modal.attachmentModalContent.querySelector('object'); + expect(pdfObject).toBeTruthy(); + expect(pdfObject.data).toMatch(/^blob:/); }); test('creates pre for text', () => { @@ -752,12 +756,15 @@ describe('AttachmentModalManager', () => { }); describe('Tauri PDF handling', () => { - test('opens PDF with system viewer in Tauri', () => { + test('opens PDF in attachment modal in Tauri', () => { isTauri.mockReturnValue(true); const pdfAtt = { fileName: 'test.pdf', attachMimeTag: 'application/pdf', contentBase64: 'data:application/pdf;base64,abc' }; + modal.setAttachments([pdfAtt]); modal.open(pdfAtt); - expect(openWithSystemViewer).toHaveBeenCalledWith('data:application/pdf;base64,abc', 'test.pdf'); - expect(modal.attachmentModal.classList.contains('active')).toBe(false); + expect(modal.attachmentModal.classList.contains('active')).toBe(true); + const pdfObject = modal.attachmentModalContent.querySelector('object'); + expect(pdfObject).toBeTruthy(); + expect(pdfObject.data).toMatch(/^blob:/); }); }); }); diff --git a/tests/integration/file-flow.test.js b/tests/integration/file-flow.test.js index 99742cbf..b7eeaa6c 100644 --- a/tests/integration/file-flow.test.js +++ b/tests/integration/file-flow.test.js @@ -12,7 +12,6 @@ // Mock external dependencies jest.mock('../../src/js/tauri-bridge.js', () => ({ isTauri: jest.fn(() => false), - openWithSystemViewer: jest.fn(() => Promise.resolve()), readFileFromPath: jest.fn(), getFileName: jest.fn(), getPendingFiles: jest.fn(() => Promise.resolve([])), diff --git a/tests/integration/multi-file-flow.test.js b/tests/integration/multi-file-flow.test.js index 24ed10a3..458e8c95 100644 --- a/tests/integration/multi-file-flow.test.js +++ b/tests/integration/multi-file-flow.test.js @@ -12,7 +12,6 @@ // Mock external dependencies jest.mock('../../src/js/tauri-bridge.js', () => ({ isTauri: jest.fn(() => false), - openWithSystemViewer: jest.fn(() => Promise.resolve()), readFileFromPath: jest.fn(), getFileName: jest.fn(), getPendingFiles: jest.fn(() => Promise.resolve([])), diff --git a/tests/integration/navigation-flow.test.js b/tests/integration/navigation-flow.test.js index 0e26f053..b6dae5cd 100644 --- a/tests/integration/navigation-flow.test.js +++ b/tests/integration/navigation-flow.test.js @@ -12,7 +12,6 @@ // Mock external dependencies jest.mock('../../src/js/tauri-bridge.js', () => ({ isTauri: jest.fn(() => false), - openWithSystemViewer: jest.fn(() => Promise.resolve()), readFileFromPath: jest.fn(), getFileName: jest.fn(), getPendingFiles: jest.fn(() => Promise.resolve([])), diff --git a/tests/integration/pin-delete-flow.test.js b/tests/integration/pin-delete-flow.test.js index a224b7a3..52732a22 100644 --- a/tests/integration/pin-delete-flow.test.js +++ b/tests/integration/pin-delete-flow.test.js @@ -18,7 +18,6 @@ // Mock external dependencies jest.mock('../../src/js/tauri-bridge.js', () => ({ isTauri: jest.fn(() => false), - openWithSystemViewer: jest.fn(() => Promise.resolve()), readFileFromPath: jest.fn(), getFileName: jest.fn(), getPendingFiles: jest.fn(() => Promise.resolve([])), diff --git a/tests/setup.js b/tests/setup.js index e16efe46..1bc2a0fd 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -46,6 +46,10 @@ beforeEach(() => { localStorageMock.store = {}; matchMediaMock.matches = false; jest.clearAllMocks(); + + let blobUrlSerial = 0; + global.URL.createObjectURL = jest.fn(() => `blob:mock-${++blobUrlSerial}`); + global.URL.revokeObjectURL = jest.fn(); }); // Mock window.md5 for tests that need it