diff --git a/doc/contributing.md b/doc/contributing.md index 32bffe6f..59776461 100644 --- a/doc/contributing.md +++ b/doc/contributing.md @@ -141,6 +141,29 @@ make mocks-bulk COUNT=100 # Generate bulk emails for performance testing --- +## macOS print regression + +The native WebKit print panel can calculate the page count before applying CSS +`@page` margins. This can truncate long messages even though the dialog offers +multiple pages. macOS printing therefore sets 15 mm margins on `NSPrintInfo` +before pagination and disables CSS page margins for that webview. Other platforms +continue to use `window.print()` and the print stylesheet. + +To verify the native path: + +1. Run `npm run tauri:dev` and open `tests/fixtures/print-long.eml`. +2. Use the message header's print button, then select PDF > Open in Preview. +3. Check that the final page contains section 60 and `ENDE DER NACHRICHT`. +4. Repeat with Cmd+P, in both light and dark app themes. +5. Check that the printed pages have a light background, margins, message + metadata and body, with no app sidebar or action buttons. + +Jest covers print routing and error handling. This regression also needs the +native dialog check: directly saving a PDF from an offscreen `WKWebView` does not +reproduce the print panel's pagination behavior. + +--- + ## Project Structure ``` diff --git a/index.html b/index.html index 2c6ade87..437c8d11 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,12 @@ msgReader + + diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cc3c1aee..176eb3f8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2011,6 +2011,7 @@ name = "msg-reader" version = "1.0.0" dependencies = [ "base64 0.22.1", + "objc2 0.6.4", "serde", "serde_json", "tauri", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6e21736d..11e6da13 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,6 +23,9 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" base64 = "0.22" +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" + [profile.release] panic = "abort" codegen-units = 1 diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c141aade..9563dd6a 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,6 +7,7 @@ "core:default", "core:webview:allow-webview-position", "core:webview:allow-set-webview-focus", + "core:webview:allow-print", "core:window:default", "fs:default", "fs:allow-read-text-file", diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 0b0660e4..204a2e33 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default permissions for msgReader","local":true,"windows":["main"],"permissions":["core:default","core:webview:allow-webview-position","core:webview:allow-set-webview-focus","core:window:default","fs:default","fs:allow-read-text-file","fs:allow-read-file","updater:default","dialog:default","process:default"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default permissions for msgReader","local":true,"windows":["main"],"permissions":["core:default","core:webview:allow-webview-position","core:webview:allow-set-webview-focus","core:webview:allow-print","core:window:default","fs:default","fs:allow-read-text-file","fs:allow-read-file","updater:default","dialog:default","process:default"]}} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d8854079..26457f8b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,8 @@ use tauri::{AppHandle, Emitter, Manager}; use std::io::Write; use tauri_plugin_dialog::DialogExt; +mod printing; + /// Store pending file paths for when app is launched via file association pub struct PendingFiles(pub Mutex>); @@ -182,7 +184,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, open_file_with_system, save_file_with_dialog, printing::print_message]); builder .build(tauri::generate_context!()) diff --git a/src-tauri/src/printing.rs b/src-tauri/src/printing.rs new file mode 100644 index 00000000..0b8b1336 --- /dev/null +++ b/src-tauri/src/printing.rs @@ -0,0 +1,57 @@ +use tauri::WebviewWindow; + +/// Opens the native print panel with margins applied before WebKit paginates. +#[tauri::command] +pub async fn print_message(window: WebviewWindow) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + use objc2::{class, msg_send, rc::Retained, runtime::AnyObject, sel}; + use std::sync::mpsc; + + let (sender, receiver) = mpsc::channel(); + window + .with_webview(move |webview| { + // Tauri runs this closure on the main thread and keeps the + // WKWebView alive. Retained Objective-C objects own the print + // settings and operation throughout setup of the native sheet. + let result = unsafe { + let view = &*webview.inner().cast::(); + let supported: bool = + msg_send![view, respondsToSelector: sel!(printOperationWithPrintInfo:)]; + if !supported { + Err("Native printing requires macOS 11 or later".to_string()) + } else { + let shared: Retained = + msg_send![class!(NSPrintInfo), sharedPrintInfo]; + let info: Retained = msg_send![&*shared, copy]; + let margin = 15.0 * 72.0 / 25.4; + let _: () = msg_send![&*info, setTopMargin: margin]; + let _: () = msg_send![&*info, setRightMargin: margin]; + let _: () = msg_send![&*info, setBottomMargin: margin]; + let _: () = msg_send![&*info, setLeftMargin: margin]; + let operation: Retained = + msg_send![view, printOperationWithPrintInfo: &*info]; + let native_window: Retained = msg_send![view, window]; + let _: () = msg_send![&*operation, setCanSpawnSeparateThread: true]; + let _: () = msg_send![&*operation, + runOperationModalForWindow: &*native_window, + delegate: std::ptr::null::(), + didRunSelector: None::, + contextInfo: std::ptr::null_mut::() + ]; + Ok(()) + } + }; + let _ = sender.send(result); + }) + .map_err(|error| error.to_string())?; + + tauri::async_runtime::spawn_blocking(move || receiver.recv()) + .await + .map_err(|error| error.to_string())? + .map_err(|error| error.to_string())? + } + + #[cfg(not(target_os = "macos"))] + window.print().map_err(|error| error.to_string()) +} diff --git a/src/js/KeyboardManager.js b/src/js/KeyboardManager.js index 681b29e8..283c9266 100644 --- a/src/js/KeyboardManager.js +++ b/src/js/KeyboardManager.js @@ -68,6 +68,7 @@ class KeyboardManager { ['togglePin', () => this.toggleCurrentPin()], ['deleteMessage', () => this.deleteCurrentMessage()], ['openFilePicker', () => this.openFilePicker()], + ['printMessage', () => this.app.uiManager.printMessage()], ['focusSearch', () => this.focusSearch()], ['clearSearch', () => this.clearSearch()], ['closeModal', () => this.closeCurrentModal()], diff --git a/src/js/KeyboardShortcuts.js b/src/js/KeyboardShortcuts.js index a5f5134b..8e464a37 100644 --- a/src/js/KeyboardShortcuts.js +++ b/src/js/KeyboardShortcuts.js @@ -84,6 +84,12 @@ export const SHORTCUTS = { contexts: [KEYBOARD_CONTEXTS.MAIN], description: 'Open file picker' }, + { + keys: ['Ctrl+p', 'Meta+p'], + action: 'printMessage', + contexts: [KEYBOARD_CONTEXTS.MAIN], + description: 'Print message' + }, { keys: ['/'], action: 'focusSearch', @@ -156,6 +162,7 @@ export const HELP_MODAL_SECTIONS = [ { keys: ['s'], description: 'Pin/unpin message' }, { keys: ['Delete'], description: 'Delete message' }, { keys: ['Ctrl/\u2318+O'], description: 'Open file picker' }, + { keys: ['Ctrl/\u2318+P'], description: 'Print message' }, { keys: ['/'], description: 'Focus search' }, { keys: ['t'], description: 'Toggle theme' } ] diff --git a/src/js/tauri-bridge.js b/src/js/tauri-bridge.js index bd77cce1..ac8478c2 100644 --- a/src/js/tauri-bridge.js +++ b/src/js/tauri-bridge.js @@ -9,9 +9,7 @@ * @returns {boolean} True if running in Tauri */ export function isTauri() { - return ( - typeof window !== 'undefined' && window.__TAURI_INTERNALS__ !== undefined - ); + return typeof window !== 'undefined' && window.__TAURI_INTERNALS__ !== undefined; } // Lazy-loaded Tauri APIs to avoid errors in browser context @@ -27,13 +25,37 @@ async function getTauriApis() { const [{ invoke }, { listen }] = await Promise.all([ import('@tauri-apps/api/core'), - import('@tauri-apps/api/event'), + import('@tauri-apps/api/event') ]); tauriApis = { invoke, listen }; return tauriApis; } +/** + * Prints the current webview with native page margins on macOS. + * WebKit's print panel can paginate before applying CSS @page margins, + * so those margins must be set on NSPrintInfo instead. + * @returns {Promise} + */ +export async function printWebview() { + if (!isTauri() || !navigator.platform.startsWith('Mac')) { + return window.print(); + } + + const apis = await getTauriApis(); + const pageMargins = document.getElementById('printPageMargins')?.sheet; + const marginsWereDisabled = pageMargins?.disabled; + if (pageMargins) pageMargins.disabled = true; + + try { + await apis.invoke('print_message'); + } catch (error) { + if (pageMargins) pageMargins.disabled = marginsWereDisabled; + throw error; + } +} + /** * Read a file from the filesystem using Tauri * @param {string} filePath - Absolute path to the file @@ -109,7 +131,7 @@ export async function openWithSystemViewer(base64Data, fileName) { // Call Rust command to save and open the file await invoke('open_file_with_system', { base64Content, - fileName, + fileName }); } @@ -132,7 +154,7 @@ export async function saveFileWithDialog(base64Data, fileName) { // Call Rust command to show save dialog and save file return await invoke('save_file_with_dialog', { base64Content, - fileName, + fileName }); } @@ -151,7 +173,7 @@ export function parseReleaseVersion(version) { return { major: Number(match[1]), minor: Number(match[2]), - patch: Number(match[3]), + patch: Number(match[3]) }; } @@ -207,7 +229,7 @@ export async function checkForUpdates() { title: 'Update available', kind: 'info', okLabel: 'Update', - cancelLabel: 'Later', + cancelLabel: 'Later' } ); diff --git a/src/js/ui/MessageContentRenderer.js b/src/js/ui/MessageContentRenderer.js index 6e7a5b8a..55265b8c 100644 --- a/src/js/ui/MessageContentRenderer.js +++ b/src/js/ui/MessageContentRenderer.js @@ -63,6 +63,11 @@ export class MessageContentRenderer { ${canDownloadOriginal ? `` : ''} +