diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 9cfe5380b..689166047 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -41,6 +41,25 @@ "cancel": "Cancel", "confirm": "Extract" }, + "defaultApp": { + "title": "Set as Default PDF App", + "message": "Would you like to set Stirling PDF as your default PDF editor?", + "description": "You can change this later in your system settings.", + "notNow": "Not Now", + "setDefault": "Set Default", + "success": { + "title": "Default App Set", + "message": "Stirling PDF is now your default PDF editor" + }, + "settingsOpened": { + "title": "Settings Opened", + "message": "Please select Stirling PDF in your system settings" + }, + "error": { + "title": "Error", + "message": "Failed to set default PDF handler" + } + }, "language": { "direction": "ltr" }, diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index d2abe9651..c40b35a8e 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -643,6 +643,15 @@ dependencies = [ "libc", ] +[[package]] +name = "core-services" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0aa845ab21b847ee46954be761815f18f16469b29ef3ba250241b1b8bab659a" +dependencies = [ + "core-foundation 0.10.1", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -3941,6 +3950,8 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" name = "stirling-pdf" version = "0.1.0" dependencies = [ + "core-foundation 0.10.1", + "core-services", "log", "reqwest 0.11.27", "serde", diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index e14bbaaee..b75106195 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -31,3 +31,7 @@ tauri-plugin-fs = "2.4.4" tauri-plugin-single-instance = "2.0.1" tokio = { version = "1.0", features = ["time"] } reqwest = { version = "0.11", features = ["json"] } + +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation = "0.10" +core-services = "1.0" diff --git a/frontend/src-tauri/src/commands/default_app.rs b/frontend/src-tauri/src/commands/default_app.rs new file mode 100644 index 000000000..bade6dba9 --- /dev/null +++ b/frontend/src-tauri/src/commands/default_app.rs @@ -0,0 +1,212 @@ +use crate::utils::add_log; + +#[cfg(any(target_os = "windows", target_os = "linux"))] +use std::process::Command; + +/// Check if Stirling PDF is the default PDF handler +#[tauri::command] +pub fn is_default_pdf_handler() -> Result { + add_log("🔍 Checking if app is default PDF handler".to_string()); + + #[cfg(target_os = "windows")] + { + check_default_windows() + } + + #[cfg(target_os = "macos")] + { + check_default_macos() + } + + #[cfg(target_os = "linux")] + { + check_default_linux() + } +} + +/// Attempt to set/prompt for Stirling PDF as default PDF handler +#[tauri::command] +pub fn set_as_default_pdf_handler() -> Result { + add_log("⚙️ Attempting to set as default PDF handler".to_string()); + + #[cfg(target_os = "windows")] + { + set_default_windows() + } + + #[cfg(target_os = "macos")] + { + set_default_macos() + } + + #[cfg(target_os = "linux")] + { + set_default_linux() + } +} + +// ============================================================================ +// Windows Implementation +// ============================================================================ + +#[cfg(target_os = "windows")] +fn check_default_windows() -> Result { + // Query the default handler for .pdf extension + let output = Command::new("cmd") + .args(["/C", "assoc .pdf"]) + .output() + .map_err(|e| format!("Failed to check default app: {}", e))?; + + let assoc = String::from_utf8_lossy(&output.stdout); + add_log(format!("Windows PDF association: {}", assoc.trim())); + + // Get the ProgID for .pdf files + if let Some(prog_id) = assoc.trim().strip_prefix(".pdf=") { + // Query what application handles this ProgID + let output = Command::new("cmd") + .args(["/C", &format!("ftype {}", prog_id)]) + .output() + .map_err(|e| format!("Failed to query file type: {}", e))?; + + let ftype = String::from_utf8_lossy(&output.stdout); + add_log(format!("Windows file type: {}", ftype.trim())); + + // Check if it contains "Stirling" or our app name + let is_default = ftype.to_lowercase().contains("stirling"); + Ok(is_default) + } else { + Ok(false) + } +} + +#[cfg(target_os = "windows")] +fn set_default_windows() -> Result { + // On Windows 10+, we need to open the Default Apps settings + // as programmatic setting requires a signed installer + Command::new("cmd") + .args(["/C", "start", "ms-settings:defaultapps"]) + .spawn() + .map_err(|e| format!("Failed to open default apps settings: {}", e))?; + + add_log("Opened Windows Default Apps settings".to_string()); + Ok("opened_settings".to_string()) +} + +// ============================================================================ +// macOS Implementation (using LaunchServices framework) +// ============================================================================ + +#[cfg(target_os = "macos")] +fn check_default_macos() -> Result { + use core_foundation::base::TCFType; + use core_foundation::string::{CFString, CFStringRef}; + use std::os::raw::c_int; + + // Define the LSCopyDefaultRoleHandlerForContentType function + #[link(name = "CoreServices", kind = "framework")] + extern "C" { + fn LSCopyDefaultRoleHandlerForContentType( + content_type: CFStringRef, + role: c_int, + ) -> CFStringRef; + } + + const K_LS_ROLES_ALL: c_int = 0xFFFFFFFF_u32 as c_int; + + unsafe { + // Query the default handler for "com.adobe.pdf" (PDF UTI - standard macOS identifier) + let pdf_uti = CFString::new("com.adobe.pdf"); + let handler_ref = LSCopyDefaultRoleHandlerForContentType(pdf_uti.as_concrete_TypeRef(), K_LS_ROLES_ALL); + + if handler_ref.is_null() { + add_log("No default PDF handler found".to_string()); + return Ok(false); + } + + let handler = CFString::wrap_under_create_rule(handler_ref); + let handler_str = handler.to_string(); + add_log(format!("macOS PDF handler: {}", handler_str)); + + // Check if it's our bundle identifier + let is_default = handler_str == "stirling.pdf.dev"; + Ok(is_default) + } +} + +#[cfg(target_os = "macos")] +fn set_default_macos() -> Result { + use core_foundation::base::TCFType; + use core_foundation::string::{CFString, CFStringRef}; + use std::os::raw::c_int; + + // Define the LSSetDefaultRoleHandlerForContentType function + #[link(name = "CoreServices", kind = "framework")] + extern "C" { + fn LSSetDefaultRoleHandlerForContentType( + content_type: CFStringRef, + role: c_int, + handler_bundle_id: CFStringRef, + ) -> c_int; // OSStatus + } + + const K_LS_ROLES_ALL: c_int = 0xFFFFFFFF_u32 as c_int; + + unsafe { + // Set our app as the default handler for PDF files + let pdf_uti = CFString::new("com.adobe.pdf"); + let our_bundle_id = CFString::new("stirling.pdf.dev"); + + let status = LSSetDefaultRoleHandlerForContentType( + pdf_uti.as_concrete_TypeRef(), + K_LS_ROLES_ALL, + our_bundle_id.as_concrete_TypeRef(), + ); + + if status == 0 { + add_log("Successfully triggered default app dialog".to_string()); + Ok("set_successfully".to_string()) + } else { + let error_msg = format!("LaunchServices returned status: {}", status); + add_log(error_msg.clone()); + Err(error_msg) + } + } +} + +// ============================================================================ +// Linux Implementation +// ============================================================================ + +#[cfg(target_os = "linux")] +fn check_default_linux() -> Result { + // Use xdg-mime to check the default application for PDF files + let output = Command::new("xdg-mime") + .args(["query", "default", "application/pdf"]) + .output() + .map_err(|e| format!("Failed to check default app: {}", e))?; + + let handler = String::from_utf8_lossy(&output.stdout); + add_log(format!("Linux PDF handler: {}", handler.trim())); + + // Check if it's our .desktop file + let is_default = handler.trim() == "stirling-pdf.desktop"; + Ok(is_default) +} + +#[cfg(target_os = "linux")] +fn set_default_linux() -> Result { + // Use xdg-mime to set the default application for PDF files + let result = Command::new("xdg-mime") + .args(["default", "stirling-pdf.desktop", "application/pdf"]) + .output() + .map_err(|e| format!("Failed to set default app: {}", e))?; + + if result.status.success() { + add_log("Set as default PDF handler on Linux".to_string()); + Ok("set_successfully".to_string()) + } else { + let error = String::from_utf8_lossy(&result.stderr); + add_log(format!("Failed to set default: {}", error)); + Err(format!("Failed to set as default: {}", error)) + } +} diff --git a/frontend/src-tauri/src/commands/mod.rs b/frontend/src-tauri/src/commands/mod.rs index f21bf8042..ba9995ba6 100644 --- a/frontend/src-tauri/src/commands/mod.rs +++ b/frontend/src-tauri/src/commands/mod.rs @@ -1,7 +1,9 @@ pub mod backend; pub mod health; pub mod files; +pub mod default_app; pub use backend::{start_backend, cleanup_backend}; pub use health::check_backend_health; pub use files::{get_opened_files, clear_opened_files, add_opened_file}; +pub use default_app::{is_default_pdf_handler, set_as_default_pdf_handler}; diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 9a07845e1..c6b264e56 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -3,7 +3,16 @@ use tauri::{RunEvent, WindowEvent, Emitter, Manager}; mod utils; mod commands; -use commands::{start_backend, check_backend_health, get_opened_files, clear_opened_files, cleanup_backend, add_opened_file}; +use commands::{ + start_backend, + check_backend_health, + get_opened_files, + clear_opened_files, + cleanup_backend, + add_opened_file, + is_default_pdf_handler, + set_as_default_pdf_handler, +}; use utils::{add_log, get_tauri_logs}; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -39,7 +48,15 @@ pub fn run() { add_log("🔍 DEBUG: Setup completed".to_string()); Ok(()) }) - .invoke_handler(tauri::generate_handler![start_backend, check_backend_health, get_opened_files, clear_opened_files, get_tauri_logs]) + .invoke_handler(tauri::generate_handler![ + start_backend, + check_backend_health, + get_opened_files, + clear_opened_files, + get_tauri_logs, + is_default_pdf_handler, + set_as_default_pdf_handler, + ]) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app_handle, event| { diff --git a/frontend/src/desktop/components/AppProviders.tsx b/frontend/src/desktop/components/AppProviders.tsx index c04ede4f9..225b5f6f5 100644 --- a/frontend/src/desktop/components/AppProviders.tsx +++ b/frontend/src/desktop/components/AppProviders.tsx @@ -2,13 +2,18 @@ import { ReactNode } from "react"; import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders"; import { DesktopConfigSync } from '@app/components/DesktopConfigSync'; import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig'; +import { DefaultAppPrompt } from '@app/components/DefaultAppPrompt'; +import { useDefaultAppPrompt } from '@app/hooks/useDefaultAppPrompt'; /** * Desktop application providers * Wraps proprietary providers and adds desktop-specific configuration * - Enables retry logic for app config (needed for Tauri mode when backend is starting) + * - Shows default PDF handler prompt on first launch */ export function AppProviders({ children }: { children: ReactNode }) { + const { promptOpened, handleSetDefault, handleDismiss } = useDefaultAppPrompt(); + return ( + {children} ); diff --git a/frontend/src/desktop/components/DefaultAppPrompt.tsx b/frontend/src/desktop/components/DefaultAppPrompt.tsx new file mode 100644 index 000000000..3d3cc4c14 --- /dev/null +++ b/frontend/src/desktop/components/DefaultAppPrompt.tsx @@ -0,0 +1,78 @@ +import { Modal, Text, Button, Stack, Flex } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import CancelIcon from '@mui/icons-material/Cancel'; +import { CSSProperties } from 'react'; + +interface DefaultAppPromptProps { + opened: boolean; + onSetDefault: () => void; + onDismiss: () => void; +} + +const ICON_STYLE: CSSProperties = { + fontSize: 48, + display: 'block', + margin: '0 auto 12px', + color: 'var(--mantine-color-blue-6)', +}; + +export const DefaultAppPrompt = ({ opened, onSetDefault, onDismiss }: DefaultAppPromptProps) => { + const { t } = useTranslation(); + + return ( + + + + + {t( + 'defaultApp.message', + 'Would you like to set Stirling PDF as your default PDF editor?' + )} + + + {t( + 'defaultApp.description', + 'You can change this later in your system settings.' + )} + + + + + + + + + ); +}; diff --git a/frontend/src/desktop/hooks/useDefaultAppPrompt.ts b/frontend/src/desktop/hooks/useDefaultAppPrompt.ts new file mode 100644 index 000000000..fe5933f6f --- /dev/null +++ b/frontend/src/desktop/hooks/useDefaultAppPrompt.ts @@ -0,0 +1,82 @@ +import { useState, useEffect } from 'react'; +import { defaultAppService } from '@app/services/defaultAppService'; +import { alert } from '@app/components/toast'; +import { useTranslation } from 'react-i18next'; + +export function useDefaultAppPrompt() { + const { t } = useTranslation(); + const [promptOpened, setPromptOpened] = useState(false); + const [isSettingDefault, setIsSettingDefault] = useState(false); + + // Check on mount if we should show the prompt + useEffect(() => { + const checkShouldPrompt = async () => { + try { + const shouldShow = await defaultAppService.shouldShowPrompt(); + if (shouldShow) { + // Small delay so it doesn't show immediately on app launch + setTimeout(() => setPromptOpened(true), 2000); + } + } catch (error) { + console.error('[DefaultAppPrompt] Failed to check prompt status:', error); + } + }; + + checkShouldPrompt(); + }, []); + + const handleSetDefault = async () => { + setIsSettingDefault(true); + try { + const result = await defaultAppService.setAsDefaultPdfHandler(); + + if (result === 'set_successfully') { + alert({ + alertType: 'success', + title: t('defaultApp.success.title', 'Default App Set'), + body: t( + 'defaultApp.success.message', + 'Stirling PDF is now your default PDF editor' + ), + }); + } else if (result === 'opened_settings') { + alert({ + alertType: 'neutral', + title: t('defaultApp.settingsOpened.title', 'Settings Opened'), + body: t( + 'defaultApp.settingsOpened.message', + 'Please select Stirling PDF in your system settings' + ), + }); + } + + // Mark as dismissed regardless of outcome + defaultAppService.setPromptDismissed(true); + setPromptOpened(false); + } catch (error) { + console.error('[DefaultAppPrompt] Failed to set default handler:', error); + alert({ + alertType: 'error', + title: t('defaultApp.error.title', 'Error'), + body: t( + 'defaultApp.error.message', + 'Failed to set default PDF handler' + ), + }); + } finally { + setIsSettingDefault(false); + } + }; + + const handleDismiss = () => { + defaultAppService.setPromptDismissed(true); + setPromptOpened(false); + }; + + return { + promptOpened, + isSettingDefault, + handleSetDefault, + handleDismiss, + }; +} diff --git a/frontend/src/desktop/services/defaultAppService.ts b/frontend/src/desktop/services/defaultAppService.ts new file mode 100644 index 000000000..fa24be906 --- /dev/null +++ b/frontend/src/desktop/services/defaultAppService.ts @@ -0,0 +1,70 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** + * Service for managing default PDF handler settings + * Note: Uses localStorage for machine-specific preferences (not synced to server) + */ +export const defaultAppService = { + /** + * Check if Stirling PDF is the default PDF handler + */ + async isDefaultPdfHandler(): Promise { + try { + const result = await invoke('is_default_pdf_handler'); + return result; + } catch (error) { + console.error('[DefaultApp] Failed to check default handler:', error); + return false; + } + }, + + /** + * Set or prompt to set Stirling PDF as default PDF handler + * Returns a status string indicating what happened + */ + async setAsDefaultPdfHandler(): Promise<'set_successfully' | 'opened_settings' | 'error'> { + try { + const result = await invoke('set_as_default_pdf_handler'); + return result as 'set_successfully' | 'opened_settings'; + } catch (error) { + console.error('[DefaultApp] Failed to set default handler:', error); + return 'error'; + } + }, + + /** + * Check if user has dismissed the default app prompt (machine-specific) + */ + hasUserDismissedPrompt(): boolean { + try { + const dismissed = localStorage.getItem('stirlingpdf_default_app_prompt_dismissed'); + return dismissed === 'true'; + } catch { + return false; + } + }, + + /** + * Mark that user has dismissed the default app prompt (machine-specific) + */ + setPromptDismissed(dismissed: boolean): void { + try { + localStorage.setItem('stirlingpdf_default_app_prompt_dismissed', dismissed ? 'true' : 'false'); + } catch (error) { + console.error('[DefaultApp] Failed to save prompt preference:', error); + } + }, + + /** + * Check if we should show the default app prompt + * Returns true if: user hasn't dismissed it AND app is not default handler + */ + async shouldShowPrompt(): Promise { + if (this.hasUserDismissedPrompt()) { + return false; + } + + const isDefault = await this.isDefaultPdfHandler(); + return !isDefault; + }, +};