diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index a0f7a2666..9909f4c5d 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -41,6 +41,30 @@ "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", + "dismiss": "Dismiss", + "prompt": { + "title": "Set as Default PDF Editor", + "message": "Make Stirling PDF your default application for opening PDF files." + }, + "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" }, @@ -332,7 +356,13 @@ "mode": { "fullscreen": "Fullscreen", "sidebar": "Sidebar" - } + }, + "defaultPdfEditor": "Default PDF editor", + "defaultPdfEditorActive": "Stirling PDF is your default PDF editor", + "defaultPdfEditorInactive": "Another application is set as default", + "defaultPdfEditorChecking": "Checking...", + "defaultPdfEditorSet": "Already Default", + "setAsDefault": "Set as Default" }, "hotkeys": { "title": "Keyboard Shortcuts", 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..81e422240 --- /dev/null +++ b/frontend/src-tauri/src/commands/default_app.rs @@ -0,0 +1,217 @@ +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 { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + + // Query the default handler for .pdf extension + let output = Command::new("cmd") + .args(["/C", "assoc .pdf"]) + .creation_flags(CREATE_NO_WINDOW) + .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)]) + .creation_flags(CREATE_NO_WINDOW) + .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/files.rs b/frontend/src-tauri/src/commands/files.rs index 2d22ac53e..7faadf0db 100644 --- a/frontend/src-tauri/src/commands/files.rs +++ b/frontend/src-tauri/src/commands/files.rs @@ -14,23 +14,11 @@ pub fn add_opened_file(file_path: String) { // Command to get opened file paths (if app was launched with files) #[tauri::command] pub async fn get_opened_files() -> Result, String> { - let mut all_files: Vec = Vec::new(); - - // Get files from command line arguments (Windows/Linux 'Open With Stirling' behaviour) - let args: Vec = std::env::args().collect(); - let pdf_files: Vec = args.iter() - .skip(1) - .filter(|arg| std::path::Path::new(arg).exists()) - .cloned() - .collect(); - - all_files.extend(pdf_files); - - // Add any files sent via events or other instances (macOS 'Open With Stirling' behaviour, also Windows/Linux extra files) - { - let opened_files = OPENED_FILES.lock().unwrap(); - all_files.extend(opened_files.clone()); - } + // Get all files from the OPENED_FILES store + // Command line args are processed in setup() callback and added to this store + // Additional files from second instances or events are also added here + let opened_files = OPENED_FILES.lock().unwrap(); + let all_files = opened_files.clone(); add_log(format!("📂 Returning {} opened file(s)", all_files.len())); Ok(all_files) 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..969479666 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)] @@ -23,9 +32,6 @@ pub fn run() { // Store file for later retrieval (in case frontend isn't ready yet) add_opened_file(arg.clone()); - // Also emit event for immediate handling if frontend is ready - let _ = app.emit("file-opened", arg.clone()); - // Bring the existing window to front if let Some(window) = app.get_webview_window("main") { let _ = window.set_focus(); @@ -33,13 +39,34 @@ pub fn run() { } } } + + // Emit a generic notification that files were added (frontend will re-read storage) + let _ = app.emit("files-changed", ()); })) .setup(|_app| { add_log("🚀 Tauri app setup started".to_string()); + + // Process command line arguments on first launch + let args: Vec = std::env::args().collect(); + for arg in args.iter().skip(1) { + if std::path::Path::new(arg).exists() { + add_log(format!("📂 Initial file from command line: {}", arg)); + add_opened_file(arg.clone()); + } + } + 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| { @@ -58,6 +85,7 @@ pub fn run() { #[cfg(target_os = "macos")] RunEvent::Opened { urls } => { add_log(format!("📂 Tauri file opened event: {:?}", urls)); + let mut added_files = false; for url in urls { let url_str = url.as_str(); if url_str.starts_with("file://") { @@ -65,11 +93,14 @@ pub fn run() { if file_path.ends_with(".pdf") { add_log(format!("📂 Processing opened PDF: {}", file_path)); add_opened_file(file_path.to_string()); - // Use unified event name for consistency across platforms - let _ = app_handle.emit("file-opened", file_path.to_string()); + added_files = true; } } } + // Emit a generic notification that files were added (frontend will re-read storage) + if added_files { + let _ = app_handle.emit("files-changed", ()); + } } _ => { // Only log unhandled events in debug mode to reduce noise diff --git a/frontend/src/core/App.tsx b/frontend/src/core/App.tsx index e87843325..c21ad03b3 100644 --- a/frontend/src/core/App.tsx +++ b/frontend/src/core/App.tsx @@ -1,5 +1,6 @@ import { Suspense } from "react"; import { AppProviders } from "@app/components/AppProviders"; +import { AppLayout } from "@app/components/AppLayout"; import { LoadingFallback } from "@app/components/shared/LoadingFallback"; import HomePage from "@app/pages/HomePage"; import OnboardingTour from "@app/components/onboarding/OnboardingTour"; @@ -16,8 +17,10 @@ export default function App() { return ( }> - - + + + + ); diff --git a/frontend/src/core/components/AppLayout.tsx b/frontend/src/core/components/AppLayout.tsx new file mode 100644 index 000000000..39de5dc65 --- /dev/null +++ b/frontend/src/core/components/AppLayout.tsx @@ -0,0 +1,31 @@ +import { ReactNode } from 'react'; +import { useBanner } from '@app/contexts/BannerContext'; + +interface AppLayoutProps { + children: ReactNode; +} + +/** + * App layout wrapper that handles banner rendering and viewport sizing + * Automatically adjusts child components to fit remaining space after banner + */ +export function AppLayout({ children }: AppLayoutProps) { + const { banner } = useBanner(); + + return ( + <> + +
+ {banner} +
+ {children} +
+
+ + ); +} diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx index 34d0e9e06..96c44bfa2 100644 --- a/frontend/src/core/components/AppProviders.tsx +++ b/frontend/src/core/components/AppProviders.tsx @@ -16,6 +16,7 @@ import { OnboardingProvider } from "@app/contexts/OnboardingContext"; import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext"; import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext"; import { PageEditorProvider } from "@app/contexts/PageEditorContext"; +import { BannerProvider } from "@app/contexts/BannerContext"; import ErrorBoundary from "@app/components/shared/ErrorBoundary"; import { useScarfTracking } from "@app/hooks/useScarfTracking"; import { useAppInitialization } from "@app/hooks/useAppInitialization"; @@ -50,22 +51,23 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide - - - - - - - - - - - - - + + + + + + + + + + + + + + @@ -76,16 +78,17 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide - - - - - - - - - - + + + + + + + + + + + diff --git a/frontend/src/core/components/shared/InfoBanner.tsx b/frontend/src/core/components/shared/InfoBanner.tsx new file mode 100644 index 000000000..f29406c2e --- /dev/null +++ b/frontend/src/core/components/shared/InfoBanner.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { Paper, Group, Text, Button, ActionIcon } from '@mantine/core'; +import LocalIcon from '@app/components/shared/LocalIcon'; + +interface InfoBannerProps { + icon: string; + message: string; + buttonText: string; + buttonIcon?: string; + onButtonClick: () => void; + onDismiss: () => void; + loading?: boolean; + show?: boolean; +} + +/** + * Generic info banner component for displaying dismissible messages at the top of the app + */ +export const InfoBanner: React.FC = ({ + icon, + message, + buttonText, + buttonIcon = 'check-circle-rounded', + onButtonClick, + onDismiss, + loading = false, + show = true, +}) => { + if (!show) { + return null; + } + + return ( + + + + + {message} + + + + + + + + ); +}; diff --git a/frontend/src/core/contexts/BannerContext.tsx b/frontend/src/core/contexts/BannerContext.tsx new file mode 100644 index 000000000..810d50bd6 --- /dev/null +++ b/frontend/src/core/contexts/BannerContext.tsx @@ -0,0 +1,26 @@ +import { createContext, useContext, useState, ReactNode } from 'react'; + +interface BannerContextType { + banner: ReactNode; + setBanner: (banner: ReactNode) => void; +} + +const BannerContext = createContext(undefined); + +export function BannerProvider({ children }: { children: ReactNode }) { + const [banner, setBanner] = useState(null); + + return ( + + {children} + + ); +} + +export function useBanner() { + const context = useContext(BannerContext); + if (!context) { + throw new Error('useBanner must be used within BannerProvider'); + } + return context; +} diff --git a/frontend/src/desktop/components/AppProviders.tsx b/frontend/src/desktop/components/AppProviders.tsx index c04ede4f9..f5363b5e7 100644 --- a/frontend/src/desktop/components/AppProviders.tsx +++ b/frontend/src/desktop/components/AppProviders.tsx @@ -1,6 +1,7 @@ import { ReactNode } from "react"; import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders"; import { DesktopConfigSync } from '@app/components/DesktopConfigSync'; +import { DesktopBannerInitializer } from '@app/components/DesktopBannerInitializer'; import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig'; /** @@ -22,6 +23,7 @@ export function AppProviders({ children }: { children: ReactNode }) { }} > + {children} ); diff --git a/frontend/src/desktop/components/DesktopBannerInitializer.tsx b/frontend/src/desktop/components/DesktopBannerInitializer.tsx new file mode 100644 index 000000000..9a74f0511 --- /dev/null +++ b/frontend/src/desktop/components/DesktopBannerInitializer.tsx @@ -0,0 +1,13 @@ +import { useEffect } from 'react'; +import { useBanner } from '@app/contexts/BannerContext'; +import { DefaultAppBanner } from '@app/components/shared/DefaultAppBanner'; + +export function DesktopBannerInitializer() { + const { setBanner } = useBanner(); + + useEffect(() => { + setBanner(); + }, [setBanner]); + + return null; +} diff --git a/frontend/src/desktop/components/shared/DefaultAppBanner.tsx b/frontend/src/desktop/components/shared/DefaultAppBanner.tsx new file mode 100644 index 000000000..35bd44f7c --- /dev/null +++ b/frontend/src/desktop/components/shared/DefaultAppBanner.tsx @@ -0,0 +1,73 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { InfoBanner } from '@app/components/shared/InfoBanner'; +import { defaultAppService } from '@app/services/defaultAppService'; +import { alert } from '@app/components/toast'; + +export const DefaultAppBanner: React.FC = () => { + const { t } = useTranslation(); + const [isDefault, setIsDefault] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + checkDefaultStatus(); + }, []); + + const checkDefaultStatus = async () => { + try { + const status = await defaultAppService.isDefaultPdfHandler(); + setIsDefault(status); + } catch (error) { + console.error('Failed to check default status:', error); + } + }; + + const handleSetDefault = async () => { + setIsLoading(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'), + }); + setIsDefault(true); + } 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'), + }); + } + } catch (error) { + console.error('Failed to set default:', error); + alert({ + alertType: 'error', + title: t('defaultApp.error.title', 'Error'), + body: t('defaultApp.error.message', 'Failed to set default PDF handler'), + }); + } finally { + setIsLoading(false); + } + }; + + const handleDismissPrompt = () => { + setDismissed(true); + }; + + return ( + + ); +}; diff --git a/frontend/src/desktop/components/shared/config/configSections/DefaultAppSettings.tsx b/frontend/src/desktop/components/shared/config/configSections/DefaultAppSettings.tsx new file mode 100644 index 000000000..621f672d1 --- /dev/null +++ b/frontend/src/desktop/components/shared/config/configSections/DefaultAppSettings.tsx @@ -0,0 +1,86 @@ +import React, { useState, useEffect } from 'react'; +import { Paper, Text, Button, Group } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { defaultAppService } from '@app/services/defaultAppService'; +import { alert } from '@app/components/toast'; + +export const DefaultAppSettings: React.FC = () => { + const { t } = useTranslation(); + const [isDefault, setIsDefault] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + checkDefaultStatus(); + }, []); + + const checkDefaultStatus = async () => { + try { + const status = await defaultAppService.isDefaultPdfHandler(); + setIsDefault(status); + } catch (error) { + console.error('Failed to check default status:', error); + } + }; + + const handleSetDefault = async () => { + setIsLoading(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'), + }); + setIsDefault(true); + } 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'), + }); + } + } catch (error) { + console.error('Failed to set default:', error); + alert({ + alertType: 'error', + title: t('defaultApp.error.title', 'Error'), + body: t('defaultApp.error.message', 'Failed to set default PDF handler'), + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + +
+ + {t('settings.general.defaultPdfEditor', 'Default PDF editor')} + + + {isDefault === true + ? t('settings.general.defaultPdfEditorActive', 'Stirling PDF is your default PDF editor') + : isDefault === false + ? t('settings.general.defaultPdfEditorInactive', 'Another application is set as default') + : t('settings.general.defaultPdfEditorChecking', 'Checking...')} + +
+ +
+
+ ); +}; diff --git a/frontend/src/desktop/components/shared/config/configSections/GeneralSection.tsx b/frontend/src/desktop/components/shared/config/configSections/GeneralSection.tsx new file mode 100644 index 000000000..dbe9a0038 --- /dev/null +++ b/frontend/src/desktop/components/shared/config/configSections/GeneralSection.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Stack } from '@mantine/core'; +import CoreGeneralSection from '@core/components/shared/config/configSections/GeneralSection'; +import { DefaultAppSettings } from '@app/components/shared/config/configSections/DefaultAppSettings'; + +/** + * Desktop extension of GeneralSection that adds default PDF editor settings + */ +const GeneralSection: React.FC = () => { + return ( + + + + + ); +}; + +export default GeneralSection; diff --git a/frontend/src/desktop/hooks/useAppInitialization.ts b/frontend/src/desktop/hooks/useAppInitialization.ts index a34547bcd..1ef8ef694 100644 --- a/frontend/src/desktop/hooks/useAppInitialization.ts +++ b/frontend/src/desktop/hooks/useAppInitialization.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect } from 'react'; import { useBackendInitializer } from '@app/hooks/useBackendInitializer'; import { useOpenedFile } from '@app/hooks/useOpenedFile'; import { fileOpenService } from '@app/services/fileOpenService'; @@ -20,75 +20,42 @@ export function useAppInitialization(): void { // Handle files opened with app (Tauri mode) const { openedFilePaths, loading: openedFileLoading } = useOpenedFile(); - // Track if we've already loaded the initial files to prevent duplicate loads - const initialFilesLoadedRef = useRef(false); - // Load opened files and add directly to FileContext useEffect(() => { - if (openedFilePaths.length > 0 && !openedFileLoading && !initialFilesLoadedRef.current) { - initialFilesLoadedRef.current = true; - - const loadOpenedFiles = async () => { - try { - const filesArray: File[] = []; - - // Load all files in parallel - await Promise.all( - openedFilePaths.map(async (filePath) => { - try { - const fileData = await fileOpenService.readFileAsArrayBuffer(filePath); - if (fileData) { - const file = new File([fileData.arrayBuffer], fileData.fileName, { - type: 'application/pdf' - }); - filesArray.push(file); - console.log('[Desktop] Loaded file:', fileData.fileName); - } - } catch (error) { - console.error('[Desktop] Failed to load file:', filePath, error); - } - }) - ); - - if (filesArray.length > 0) { - // Add all files to FileContext at once - await addFiles(filesArray); - console.log(`[Desktop] ${filesArray.length} opened file(s) added to FileContext`); - } - } catch (error) { - console.error('[Desktop] Failed to load opened files:', error); - } - }; - - loadOpenedFiles(); + if (openedFilePaths.length === 0 || openedFileLoading) { + return; } - }, [openedFilePaths, openedFileLoading, addFiles]); - // Listen for runtime file-opened events (from second instances on Windows/Linux) - useEffect(() => { - const handleRuntimeFileOpen = async (filePath: string) => { + const loadOpenedFiles = async () => { try { - console.log('[Desktop] Runtime file-opened event received:', filePath); - const fileData = await fileOpenService.readFileAsArrayBuffer(filePath); - if (fileData) { - // Create a File object from the ArrayBuffer - const file = new File([fileData.arrayBuffer], fileData.fileName, { - type: 'application/pdf' - }); + const filesArray: File[] = []; - // Add directly to FileContext - await addFiles([file]); - console.log('[Desktop] Runtime opened file added to FileContext:', fileData.fileName); + await Promise.all( + openedFilePaths.map(async (filePath) => { + try { + const fileData = await fileOpenService.readFileAsArrayBuffer(filePath); + if (fileData) { + const file = new File([fileData.arrayBuffer], fileData.fileName, { + type: 'application/pdf' + }); + filesArray.push(file); + console.log('[Desktop] Loaded file:', fileData.fileName); + } + } catch (error) { + console.error('[Desktop] Failed to load file:', filePath, error); + } + }) + ); + + if (filesArray.length > 0) { + await addFiles(filesArray); + console.log(`[Desktop] ${filesArray.length} opened file(s) added to FileContext`); } } catch (error) { - console.error('[Desktop] Failed to load runtime opened file:', error); + console.error('[Desktop] Failed to load opened files:', error); } }; - // Set up event listener and get cleanup function - const unlisten = fileOpenService.onFileOpened(handleRuntimeFileOpen); - - // Clean up listener on unmount - return unlisten; - }, [addFiles]); + loadOpenedFiles(); + }, [openedFilePaths, openedFileLoading, addFiles]); } diff --git a/frontend/src/desktop/hooks/useOpenedFile.ts b/frontend/src/desktop/hooks/useOpenedFile.ts index 48565010f..6347ef736 100644 --- a/frontend/src/desktop/hooks/useOpenedFile.ts +++ b/frontend/src/desktop/hooks/useOpenedFile.ts @@ -1,45 +1,46 @@ import { useState, useEffect } from 'react'; import { fileOpenService } from '@app/services/fileOpenService'; +import { listen } from '@tauri-apps/api/event'; export function useOpenedFile() { const [openedFilePaths, setOpenedFilePaths] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { - const checkForOpenedFile = async () => { - console.log('🔍 Checking for opened file(s)...'); + // Function to read and process files from storage + const readFilesFromStorage = async () => { + console.log('🔍 Reading files from storage...'); try { const filePaths = await fileOpenService.getOpenedFiles(); console.log('🔍 fileOpenService.getOpenedFiles() returned:', filePaths); if (filePaths.length > 0) { - console.log(`✅ App opened with ${filePaths.length} file(s):`, filePaths); + console.log(`✅ Found ${filePaths.length} file(s) in storage:`, filePaths); setOpenedFilePaths(filePaths); - - // Clear the files from service state after consuming them await fileOpenService.clearOpenedFiles(); - } else { - console.log('â„šī¸ No files were opened with the app'); } - } catch (error) { - console.error('❌ Failed to check for opened files:', error); + console.error('❌ Failed to read files from storage:', error); } finally { setLoading(false); } }; - checkForOpenedFile(); + // Read files on mount + readFilesFromStorage(); - // Listen for runtime file open events (abstracted through service) - const unlistenRuntimeEvents = fileOpenService.onFileOpened((filePath: string) => { - console.log('📂 Runtime file open event:', filePath); - setOpenedFilePaths(prev => [...prev, filePath]); + // Listen for files-changed events (when new files are added to storage) + let unlisten: (() => void) | undefined; + listen('files-changed', async () => { + console.log('📂 files-changed event received, re-reading storage...'); + await readFilesFromStorage(); + }).then(unlistenFn => { + unlisten = unlistenFn; }); // Cleanup function return () => { - unlistenRuntimeEvents(); + if (unlisten) unlisten(); }; }, []); 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; + }, +}; diff --git a/frontend/src/proprietary/App.tsx b/frontend/src/proprietary/App.tsx index eba2fa5c4..9edb9ab83 100644 --- a/frontend/src/proprietary/App.tsx +++ b/frontend/src/proprietary/App.tsx @@ -1,6 +1,7 @@ import { Suspense } from "react"; import { Routes, Route } from "react-router-dom"; import { AppProviders } from "@app/components/AppProviders"; +import { AppLayout } from "@app/components/AppLayout"; import { LoadingFallback } from "@app/components/shared/LoadingFallback"; import Landing from "@app/routes/Landing"; import Login from "@app/routes/Login"; @@ -22,17 +23,19 @@ export default function App() { return ( }> - - {/* Auth routes - no nested providers needed */} - } /> - } /> - } /> - } /> + + + {/* Auth routes - no nested providers needed */} + } /> + } /> + } /> + } /> - {/* Main app routes - Landing handles auth logic */} - } /> - - + {/* Main app routes - Landing handles auth logic */} + } /> + + + );