mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-28 02:31:17 +01:00
Fix printing on Mac desktop (#5920)
# Description of Changes Fix #5164 As I mentioned on the bug https://github.com/Stirling-Tools/Stirling-PDF/issues/5164#issuecomment-4045170827, it's impossible to print on Mac currently because `iframe.contentWindow?.print()` silently does nothing in Tauri on Mac, but [it seems unlikely that this will be fixed](https://github.com/tauri-apps/tauri/issues/13451#issuecomment-4048075861). Instead, I've linked directly to the Mac `PDFKit` framework in Rust to use its printing functionality instead of Safari's. I believe that `PDFKit` is what `Preview.app` is using and the print UI that it generates seems to perform identically, so this should solve the issue on Mac. Hopefully one day the TS iframe print API will be fixed and we'll be able to get rid of this code, or [there'll be an official Tauri plugin for printing which we can use instead](https://github.com/tauri-apps/plugins-workspace/issues/293). This implementation should be entirely Mac-specific. Windows & Linux will continue to use their TS printing (which comes from EmbedPDF) unless we have a good reason to change them to use a native solution as well.
This commit is contained in:
@@ -7,5 +7,3 @@ VITE_DESKTOP_BACKEND_URL=
|
||||
# Desktop auth integration
|
||||
VITE_SAAS_SERVER_URL=https://auth.stirling.com
|
||||
VITE_SAAS_BACKEND_API_URL=https://api2.stirling.com
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb
|
||||
VITE_SUPABASE_URL=https://rficokptxxxxtyzcvgmx.supabase.co
|
||||
|
||||
18
frontend/src-tauri/Cargo.lock
generated
18
frontend/src-tauri/Cargo.lock
generated
@@ -2637,6 +2637,20 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-pdf-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c14ed801ae810c6ba487cedd7616bb48f9a8e37940042f57e862538e2c7db117"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -4190,6 +4204,10 @@ dependencies = [
|
||||
"core-services",
|
||||
"keyring",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"objc2-pdf-kit",
|
||||
"rand 0.9.2",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
|
||||
@@ -49,6 +49,10 @@ rand = "0.9"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = "0.10"
|
||||
core-services = "1.0"
|
||||
objc2 = "0.6.4"
|
||||
objc2-app-kit = { version = "0.3.2", features = ["NSPrintInfo", "NSPrintOperation"] }
|
||||
objc2-foundation = { version = "0.3.2", features = ["NSObject", "NSString", "NSURL"] }
|
||||
objc2-pdf-kit = { version = "0.3.2", features = ["PDFDocument", "objc2-app-kit"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.58", features = [
|
||||
|
||||
@@ -3,6 +3,8 @@ pub mod files;
|
||||
pub mod connection;
|
||||
pub mod auth;
|
||||
pub mod default_app;
|
||||
pub mod platform;
|
||||
pub mod print;
|
||||
|
||||
pub use backend::{cleanup_backend, get_backend_port, start_backend};
|
||||
pub use files::{add_opened_file, clear_opened_files, get_opened_files, pop_opened_files};
|
||||
@@ -26,3 +28,5 @@ pub use auth::{
|
||||
start_oauth_login,
|
||||
};
|
||||
pub use default_app::{is_default_pdf_handler, set_as_default_pdf_handler};
|
||||
pub use platform::get_desktop_os;
|
||||
pub use print::print_pdf_file_native;
|
||||
|
||||
20
frontend/src-tauri/src/commands/platform.rs
Normal file
20
frontend/src-tauri/src/commands/platform.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DesktopOS {
|
||||
MacOS,
|
||||
Windows,
|
||||
Linux,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_desktop_os() -> DesktopOS {
|
||||
match std::env::consts::OS {
|
||||
"macos" => DesktopOS::MacOS,
|
||||
"windows" => DesktopOS::Windows,
|
||||
"linux" => DesktopOS::Linux,
|
||||
_ => DesktopOS::Unknown,
|
||||
}
|
||||
}
|
||||
68
frontend/src-tauri/src/commands/print.rs
Normal file
68
frontend/src-tauri/src/commands/print.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos {
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use objc2::rc::autoreleasepool;
|
||||
use objc2::AnyThread;
|
||||
use objc2_app_kit::NSPrintInfo;
|
||||
use objc2_foundation::{MainThreadMarker, NSString, NSURL};
|
||||
use objc2_pdf_kit::{PDFDocument, PDFPrintScalingMode};
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn print_pdf_file_native(app: AppHandle, file_path: String, title: Option<String>) -> Result<(), String> {
|
||||
if !Path::new(&file_path).exists() {
|
||||
return Err(format!("Print file does not exist: {}", file_path));
|
||||
}
|
||||
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
app.run_on_main_thread(move || {
|
||||
let result = autoreleasepool(|_| {
|
||||
let mtm = MainThreadMarker::new()
|
||||
.ok_or_else(|| "macOS print must run on the main thread".to_string())?;
|
||||
|
||||
let path_string = NSString::from_str(&file_path);
|
||||
let file_url = NSURL::fileURLWithPath(&path_string);
|
||||
let document = unsafe { PDFDocument::initWithURL(PDFDocument::alloc(), &file_url) }
|
||||
.ok_or_else(|| format!("Failed to load PDF for printing: {}", file_path))?;
|
||||
|
||||
let print_info = NSPrintInfo::sharedPrintInfo();
|
||||
let print_operation = unsafe {
|
||||
document
|
||||
.printOperationForPrintInfo_scalingMode_autoRotate(
|
||||
Some(&print_info),
|
||||
PDFPrintScalingMode::PageScaleDownToFit,
|
||||
true,
|
||||
mtm,
|
||||
)
|
||||
}
|
||||
.ok_or_else(|| "PDFKit did not create a print operation".to_string())?;
|
||||
|
||||
if let Some(job_title) = title.as_deref() {
|
||||
print_operation.setJobTitle(Some(&NSString::from_str(job_title)));
|
||||
}
|
||||
|
||||
print_operation.setShowsPrintPanel(true);
|
||||
print_operation.setShowsProgressPanel(true);
|
||||
let _ = print_operation.runOperation();
|
||||
Ok(())
|
||||
});
|
||||
|
||||
let _ = sender.send(result);
|
||||
}).map_err(|error| error.to_string())?;
|
||||
|
||||
receiver
|
||||
.recv()
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use macos::print_pdf_file_native;
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[tauri::command]
|
||||
pub fn print_pdf_file_native(_file_path: String, _title: Option<String>) -> Result<(), String> {
|
||||
Err("Native PDF printing is only implemented on macOS".to_string())
|
||||
}
|
||||
@@ -27,6 +27,8 @@ use commands::{
|
||||
save_user_info,
|
||||
set_connection_mode,
|
||||
set_as_default_pdf_handler,
|
||||
get_desktop_os,
|
||||
print_pdf_file_native,
|
||||
start_backend,
|
||||
start_oauth_login,
|
||||
};
|
||||
@@ -163,6 +165,8 @@ pub fn run() {
|
||||
get_user_info,
|
||||
clear_user_info,
|
||||
start_oauth_login,
|
||||
get_desktop_os,
|
||||
print_pdf_file_native,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
|
||||
@@ -685,7 +685,11 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
<ExportAPIBridge />
|
||||
<BookmarkAPIBridge />
|
||||
<AttachmentAPIBridge />
|
||||
<PrintAPIBridge />
|
||||
<PrintAPIBridge
|
||||
file={file}
|
||||
url={pdfUrl}
|
||||
fileName={fileName}
|
||||
/>
|
||||
<DocumentPermissionsAPIBridge />
|
||||
<DocumentReadyWrapper
|
||||
fallback={
|
||||
|
||||
@@ -3,10 +3,16 @@ import { usePrintCapability } from '@embedpdf/plugin-print/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
|
||||
|
||||
export interface PrintAPIBridgeProps {
|
||||
file?: File | Blob;
|
||||
url?: string | null;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects the PDF print plugin to the shared ViewerContext.
|
||||
*/
|
||||
export function PrintAPIBridge() {
|
||||
export function PrintAPIBridge(_props: PrintAPIBridgeProps) {
|
||||
const { provides: print } = usePrintCapability();
|
||||
const { registerBridge } = useViewer();
|
||||
const documentReady = useDocumentReady();
|
||||
|
||||
44
frontend/src/desktop/components/viewer/PrintAPIBridge.tsx
Normal file
44
frontend/src/desktop/components/viewer/PrintAPIBridge.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useEffect } from 'react';
|
||||
import { usePrintCapability } from '@embedpdf/plugin-print/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
|
||||
import { printPdfNatively } from '@app/services/nativePrintService';
|
||||
import { DesktopOs, getDesktopOs } from '@app/services/platformService';
|
||||
import { PrintAPIBridgeProps } from "@core/components/viewer/PrintAPIBridge";
|
||||
|
||||
export function PrintAPIBridge({ file, url, fileName }: PrintAPIBridgeProps) {
|
||||
const { provides: print } = usePrintCapability();
|
||||
const { registerBridge } = useViewer();
|
||||
const documentReady = useDocumentReady();
|
||||
|
||||
useEffect(() => {
|
||||
if (documentReady) {
|
||||
registerBridge('print', {
|
||||
state: {},
|
||||
api: {
|
||||
print: () => {
|
||||
void (async () => {
|
||||
// macOS desktop uses a native print path because Tauri/WKWebView does not
|
||||
// reliably support iframe-based PDF printing yet:
|
||||
// https://github.com/tauri-apps/tauri/issues/13451#issuecomment-4045138142
|
||||
if ((await getDesktopOs()) === DesktopOs.Mac) {
|
||||
await printPdfNatively(file, url, fileName);
|
||||
return;
|
||||
}
|
||||
|
||||
print?.print?.();
|
||||
})().catch((error) => {
|
||||
console.error('[Desktop Print] Print failed', error);
|
||||
});
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
registerBridge('print', null);
|
||||
};
|
||||
}, [documentReady, file, fileName, print, registerBridge, url]);
|
||||
|
||||
return null;
|
||||
}
|
||||
56
frontend/src/desktop/services/nativePrintService.ts
Normal file
56
frontend/src/desktop/services/nativePrintService.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
function sanitizeFileName(fileName: string) {
|
||||
const cleaned = fileName.replace(/[^A-Za-z0-9._-]+/g, "_");
|
||||
if (!cleaned.toLowerCase().endsWith(".pdf")) {
|
||||
return `${cleaned || "document"}.pdf`;
|
||||
}
|
||||
return cleaned || "document.pdf";
|
||||
}
|
||||
|
||||
async function resolvePdfSource(file?: File | Blob, url?: string | null) {
|
||||
if (file) {
|
||||
return file;
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load PDF for native print (${response.status})`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export async function printPdfNatively(file?: File | Blob, url?: string | null, fileName = "document.pdf") {
|
||||
const source = await resolvePdfSource(file, url);
|
||||
if (!source) {
|
||||
throw new Error("No PDF source available for native print");
|
||||
}
|
||||
|
||||
const { tempDir, join } = await import("@tauri-apps/api/path");
|
||||
const { remove, writeFile } = await import("@tauri-apps/plugin-fs");
|
||||
|
||||
const tempPath = await join(
|
||||
await tempDir(),
|
||||
`stirling-print-${crypto.randomUUID()}-${sanitizeFileName(fileName)}`,
|
||||
);
|
||||
|
||||
await writeFile(tempPath, new Uint8Array(await source.arrayBuffer()));
|
||||
|
||||
try {
|
||||
await invoke("print_pdf_file_native", {
|
||||
filePath: tempPath,
|
||||
title: fileName,
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
await remove(tempPath);
|
||||
} catch (error) {
|
||||
console.warn("[Desktop Print] Failed to clean up temp print file", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
frontend/src/desktop/services/platformService.ts
Normal file
18
frontend/src/desktop/services/platformService.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export enum DesktopOs {
|
||||
Mac = "macos",
|
||||
Windows = "windows",
|
||||
Linux = "linux",
|
||||
Unknown = "unknown",
|
||||
}
|
||||
|
||||
let desktopOsPromise: Promise<DesktopOs> | null = null;
|
||||
|
||||
export async function getDesktopOs() {
|
||||
if (!desktopOsPromise) {
|
||||
desktopOsPromise = invoke<DesktopOs>("get_desktop_os");
|
||||
}
|
||||
|
||||
return desktopOsPromise;
|
||||
}
|
||||
Reference in New Issue
Block a user