Skip to content
Merged
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
23 changes: 23 additions & 0 deletions doc/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
6 changes: 6 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>msgReader</title>
<!-- macOS disables CSS page margins and sets them on the native print job. -->
<style id="printPageMargins">
@media print {
@page { margin: 15mm; }
}
</style>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%233b82f6' class='size-6'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75'/%3E%3C/svg%3E">
<script type="module" src="/src/js/main.js"></script>
</head>
Expand Down
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/gen/schemas/capabilities.json
Original file line number Diff line number Diff line change
@@ -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"]}}
{"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"]}}
4 changes: 3 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<PathBuf>>);

Expand Down Expand Up @@ -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!())
Expand Down
57 changes: 57 additions & 0 deletions src-tauri/src/printing.rs
Original file line number Diff line number Diff line change
@@ -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::<AnyObject>();
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<AnyObject> =
msg_send![class!(NSPrintInfo), sharedPrintInfo];
let info: Retained<AnyObject> = 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<AnyObject> =
msg_send![view, printOperationWithPrintInfo: &*info];
let native_window: Retained<AnyObject> = msg_send![view, window];
let _: () = msg_send![&*operation, setCanSpawnSeparateThread: true];
let _: () = msg_send![&*operation,
runOperationModalForWindow: &*native_window,
delegate: std::ptr::null::<AnyObject>(),
didRunSelector: None::<objc2::runtime::Sel>,
contextInfo: std::ptr::null_mut::<std::ffi::c_void>()
];
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())
}
1 change: 1 addition & 0 deletions src/js/KeyboardManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()],
Expand Down
7 changes: 7 additions & 0 deletions src/js/KeyboardShortcuts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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' }
]
Expand Down
38 changes: 30 additions & 8 deletions src/js/tauri-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<void>}
*/
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
Expand Down Expand Up @@ -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
});
}

Expand All @@ -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
});
}

Expand All @@ -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])
};
}

Expand Down Expand Up @@ -207,7 +229,7 @@ export async function checkForUpdates() {
title: 'Update available',
kind: 'info',
okLabel: 'Update',
cancelLabel: 'Later',
cancelLabel: 'Later'
}
);

Expand Down
15 changes: 9 additions & 6 deletions src/js/ui/MessageContentRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ export class MessageContentRenderer {
${canDownloadOriginal ? `<button data-action="export-message" data-index="${messageIndex}" data-format="original" class="message-export-item">Download original ${msgInfo._fileType.toUpperCase()}</button>` : ''}
</div>
</div>
<button data-action="print-message" data-index="${messageIndex}" class="action-button rounded-full" title="print message">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.72 13.829c-.24.03-.48.062-.72.096m.72-.096a42.415 42.415 0 0 1 10.56 0m-10.56 0L6.34 18m10.94-4.171c.24.03.48.062.72.096m-.72-.096L17.66 18m0 0 .229 2.523a1.125 1.125 0 0 1-1.12 1.227H7.231c-.662 0-1.18-.568-1.12-1.227L6.34 18m11.318 0h1.091A2.25 2.25 0 0 0 21 15.75V9.456c0-1.081-.768-2.015-1.837-2.175a48.055 48.055 0 0 0-1.913-.247M6.34 18H5.25A2.25 2.25 0 0 1 3 15.75V9.456c0-1.081.768-2.015 1.837-2.175a48.041 48.041 0 0 1 1.913-.247m10.5 0a48.536 48.536 0 0 0-10.5 0m10.5 0V3.375c0-.621-.504-1.125-1.125-1.125h-8.25c-.621 0-1.125.504-1.125 1.125v3.659M18 10.5h.008v.008H18V10.5Zm-3 0h.008v.008H15V10.5Z" />
</svg>
</button>
<button data-action="pin" data-index="${messageIndex}" class="action-button rounded-full ${isPinned ? 'pinned' : ''}" title="bookmark message">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z" />
Expand Down Expand Up @@ -300,13 +305,11 @@ export class MessageContentRenderer {

// WCAG AA requires 4.5:1 for normal text, we use 3:1 as minimum
if (contrast < 3) {
// Lighten the color for dark backgrounds
// Lighten the color for dark backgrounds. Applied via a screen-only
// CSS rule so printouts keep the sender's original colors.
const adjustedColor = adjustColorForContrast(color, bgColor);
const newStyle = style.replace(
/(?:^|;)(\s*color\s*:\s*)([^;]+)/i,
`$1${adjustedColor}`
);
el.setAttribute('style', newStyle);
el.style.setProperty('--contrast-adjusted-color', adjustedColor);
el.dataset.contrastAdjusted = 'true';
}
});
}
Expand Down
44 changes: 43 additions & 1 deletion src/js/ui/UIManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { MessageContentRenderer } from './MessageContentRenderer.js';
import { AttachmentModalManager } from './AttachmentModalManager.js';
import { ToastManager } from './ToastManager.js';
import { SearchManager } from '../SearchManager.js';
import { isTauri, saveFileWithDialog } from '../tauri-bridge.js';
import { isTauri, printWebview, saveFileWithDialog } from '../tauri-bridge.js';
import {
getExportFileName,
getOriginalMessageMimeType,
Expand Down Expand Up @@ -62,6 +62,7 @@ class UIManager {
this.devPanel = null;
this.initEventDelegation();
this.initSearchListeners();
this.initPrintTitle();
this.updateBulkActions();
}

Expand Down Expand Up @@ -224,6 +225,8 @@ class UIManager {
else if (action === 'toggle-export-menu') {
e.stopPropagation();
this.toggleExportMenu(btn);
} else if (action === 'print-message') {
this.printMessage();
} else if (action === 'export-message') {
const message = this.messageHandler.getMessages()[index];
if (message) {
Expand Down Expand Up @@ -858,6 +861,45 @@ class UIManager {
URL.revokeObjectURL(objectUrl);
}

/**
* Uses the subject as document title while printing, so the print header
* and the suggested PDF file name match the message
*/
initPrintTitle() {
let titleBeforePrint = null;

window.addEventListener('beforeprint', () => {
const subject = this.messageHandler.getCurrentMessage()?.subject;
if (!subject) return;
titleBeforePrint = document.title;
document.title = subject;
});

window.addEventListener('afterprint', () => {
if (titleBeforePrint === null) return;
document.title = titleBeforePrint;
titleBeforePrint = null;
});
}

/**
* Opens the print dialog for the currently displayed message.
* Print styles in styles.css reduce the page to the message itself.
*/
printMessage() {
if (!this.messageHandler.getCurrentMessage()) {
this.showInfo('Select a message to print');
return;
}

Promise.resolve()
.then(() => printWebview())
.catch((error) => {
console.error('Failed to print message:', error);
this.showError('Failed to open print dialog');
});
}

/**
* Exports a message in the requested format
* @param {Object} message - Message object
Expand Down
Loading
Loading