diff --git a/frontend/config/.env.desktop.example b/frontend/config/.env.desktop.example index 65dcea5a05..d7975153b1 100644 --- a/frontend/config/.env.desktop.example +++ b/frontend/config/.env.desktop.example @@ -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 diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index a0068e1a5b..f5d534b959 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -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", diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index a730e1fc6a..bceaf0c70f 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -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 = [ diff --git a/frontend/src-tauri/src/commands/mod.rs b/frontend/src-tauri/src/commands/mod.rs index 3e98408113..27720de256 100644 --- a/frontend/src-tauri/src/commands/mod.rs +++ b/frontend/src-tauri/src/commands/mod.rs @@ -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; diff --git a/frontend/src-tauri/src/commands/platform.rs b/frontend/src-tauri/src/commands/platform.rs new file mode 100644 index 0000000000..907e7dd418 --- /dev/null +++ b/frontend/src-tauri/src/commands/platform.rs @@ -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, + } +} diff --git a/frontend/src-tauri/src/commands/print.rs b/frontend/src-tauri/src/commands/print.rs new file mode 100644 index 0000000000..dad46c645c --- /dev/null +++ b/frontend/src-tauri/src/commands/print.rs @@ -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) -> 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) -> Result<(), String> { + Err("Native PDF printing is only implemented on macOS".to_string()) +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index bac8a25283..08986b2d37 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -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") diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx index f7d7fc65e0..247bdb8632 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -685,7 +685,11 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false, - + { + 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; +} diff --git a/frontend/src/desktop/services/nativePrintService.ts b/frontend/src/desktop/services/nativePrintService.ts new file mode 100644 index 0000000000..1256dba242 --- /dev/null +++ b/frontend/src/desktop/services/nativePrintService.ts @@ -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); + } + } +} diff --git a/frontend/src/desktop/services/platformService.ts b/frontend/src/desktop/services/platformService.ts new file mode 100644 index 0000000000..fec56aed22 --- /dev/null +++ b/frontend/src/desktop/services/platformService.ts @@ -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 | null = null; + +export async function getDesktopOs() { + if (!desktopOsPromise) { + desktopOsPromise = invoke("get_desktop_os"); + } + + return desktopOsPromise; +}