From cb2446ae83df738f73fde6d00fdb4dab8b1cfb44 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 13 Nov 2025 12:15:03 +0000 Subject: [PATCH 1/9] Add prompt to make Stirling your default PDF app --- .../public/locales/en-GB/translation.json | 19 ++ frontend/src-tauri/Cargo.lock | 11 + frontend/src-tauri/Cargo.toml | 4 + .../src-tauri/src/commands/default_app.rs | 212 ++++++++++++++++++ frontend/src-tauri/src/commands/mod.rs | 2 + frontend/src-tauri/src/lib.rs | 21 +- .../src/desktop/components/AppProviders.tsx | 10 + .../desktop/components/DefaultAppPrompt.tsx | 78 +++++++ .../src/desktop/hooks/useDefaultAppPrompt.ts | 82 +++++++ .../src/desktop/services/defaultAppService.ts | 70 ++++++ 10 files changed, 507 insertions(+), 2 deletions(-) create mode 100644 frontend/src-tauri/src/commands/default_app.rs create mode 100644 frontend/src/desktop/components/DefaultAppPrompt.tsx create mode 100644 frontend/src/desktop/hooks/useDefaultAppPrompt.ts create mode 100644 frontend/src/desktop/services/defaultAppService.ts 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; + }, +}; From 0649a1c89e40edab3b91ab3dcbd68226679de74a Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 13 Nov 2025 15:07:34 +0000 Subject: [PATCH 2/9] Redesign file opening to be simpler --- frontend/src-tauri/src/lib.rs | 14 +-- .../src/desktop/hooks/useAppInitialization.ts | 87 ++++++------------- frontend/src/desktop/hooks/useOpenedFile.ts | 31 +++---- 3 files changed, 52 insertions(+), 80 deletions(-) diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index c6b264e56..02343b290 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -32,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(); @@ -42,6 +39,9 @@ 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()); @@ -75,6 +75,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://") { @@ -82,11 +83,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/desktop/hooks/useAppInitialization.ts b/frontend/src/desktop/hooks/useAppInitialization.ts index a34547bcd..f237102bc 100644 --- a/frontend/src/desktop/hooks/useAppInitialization.ts +++ b/frontend/src/desktop/hooks/useAppInitialization.ts @@ -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(); }; }, []); From 8b9204ebba2088331f52d2eaf443137dc7d9934a Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 13 Nov 2025 15:25:55 +0000 Subject: [PATCH 3/9] Linting --- frontend/src/desktop/hooks/useAppInitialization.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/desktop/hooks/useAppInitialization.ts b/frontend/src/desktop/hooks/useAppInitialization.ts index f237102bc..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'; From ec89b77e953fe7155283e781511b873418460c6e Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 13 Nov 2025 16:40:56 +0000 Subject: [PATCH 4/9] Move to banner --- .../public/locales/en-GB/translation.json | 13 ++- frontend/src/desktop/App.tsx | 53 +++++++++ .../src/desktop/components/AppProviders.tsx | 10 -- .../desktop/components/DefaultAppPrompt.tsx | 78 ------------- .../components/shared/DefaultAppBanner.tsx | 110 ++++++++++++++++++ .../configSections/DefaultAppSettings.tsx | 86 ++++++++++++++ .../config/configSections/GeneralSection.tsx | 18 +++ .../src/desktop/hooks/useDefaultAppPrompt.ts | 82 ------------- 8 files changed, 279 insertions(+), 171 deletions(-) create mode 100644 frontend/src/desktop/App.tsx delete mode 100644 frontend/src/desktop/components/DefaultAppPrompt.tsx create mode 100644 frontend/src/desktop/components/shared/DefaultAppBanner.tsx create mode 100644 frontend/src/desktop/components/shared/config/configSections/DefaultAppSettings.tsx create mode 100644 frontend/src/desktop/components/shared/config/configSections/GeneralSection.tsx delete mode 100644 frontend/src/desktop/hooks/useDefaultAppPrompt.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 689166047..96335fa23 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -47,6 +47,11 @@ "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" @@ -351,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/desktop/App.tsx b/frontend/src/desktop/App.tsx new file mode 100644 index 000000000..89e092aa3 --- /dev/null +++ b/frontend/src/desktop/App.tsx @@ -0,0 +1,53 @@ +import { Suspense } from "react"; +import { Routes, Route } from "react-router-dom"; +import { AppProviders } from "@app/components/AppProviders"; +import { LoadingFallback } from "@app/components/shared/LoadingFallback"; +import Landing from "@app/routes/Landing"; +import Login from "@app/routes/Login"; +import Signup from "@app/routes/Signup"; +import AuthCallback from "@app/routes/AuthCallback"; +import InviteAccept from "@app/routes/InviteAccept"; +import OnboardingTour from "@app/components/onboarding/OnboardingTour"; +import { DefaultAppBanner } from "@app/components/shared/DefaultAppBanner"; + +// Import global styles +import "@app/styles/tailwind.css"; +import "@app/styles/cookieconsent.css"; +import "@app/styles/index.css"; +import "@app/styles/auth-theme.css"; + +// Import file ID debugging helpers (development only) +import "@app/utils/fileIdSafety"; + +export default function App() { + return ( + }> + +
+ +
+ + + {/* Auth routes - no nested providers needed */} + } /> + } /> + } /> + } /> + + {/* Main app routes - Landing handles auth logic */} + } /> + + +
+
+
+
+ ); +} diff --git a/frontend/src/desktop/components/AppProviders.tsx b/frontend/src/desktop/components/AppProviders.tsx index 225b5f6f5..c04ede4f9 100644 --- a/frontend/src/desktop/components/AppProviders.tsx +++ b/frontend/src/desktop/components/AppProviders.tsx @@ -2,18 +2,13 @@ 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 deleted file mode 100644 index 3d3cc4c14..000000000 --- a/frontend/src/desktop/components/DefaultAppPrompt.tsx +++ /dev/null @@ -1,78 +0,0 @@ -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/components/shared/DefaultAppBanner.tsx b/frontend/src/desktop/components/shared/DefaultAppBanner.tsx new file mode 100644 index 000000000..1e75faa01 --- /dev/null +++ b/frontend/src/desktop/components/shared/DefaultAppBanner.tsx @@ -0,0 +1,110 @@ +import React, { useState, useEffect } from 'react'; +import { Paper, Group, Text, Button, ActionIcon } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import { defaultAppService } from '@app/services/defaultAppService'; +import { alert } from '@app/components/toast'; + +const PROMPT_DISMISSED_KEY = 'stirlingpdf_default_app_prompt_dismissed'; + +export const DefaultAppBanner: React.FC = () => { + const { t } = useTranslation(); + const [promptDismissed, setPromptDismissed] = useState(() => { + return localStorage.getItem(PROMPT_DISMISSED_KEY) === 'true'; + }); + 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); + } + }; + + const handleDismissPrompt = () => { + setPromptDismissed(true); + localStorage.setItem(PROMPT_DISMISSED_KEY, 'true'); + }; + + if (promptDismissed || isDefault !== false) { + return null; + } + + return ( + + + + + {t('defaultApp.prompt.message', 'Make Stirling PDF your default application for opening PDF files.')} + + + + + + + + ); +}; 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..8dfec2ee9 --- /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/useDefaultAppPrompt.ts b/frontend/src/desktop/hooks/useDefaultAppPrompt.ts deleted file mode 100644 index fe5933f6f..000000000 --- a/frontend/src/desktop/hooks/useDefaultAppPrompt.ts +++ /dev/null @@ -1,82 +0,0 @@ -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, - }; -} From ae66e74687546d0c5993a51298a5d4291cec84be Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 14 Nov 2025 09:57:16 +0000 Subject: [PATCH 5/9] Make banner reusable --- frontend/src/core/App.tsx | 7 +- frontend/src/core/components/AppLayout.tsx | 31 ++++++++ frontend/src/core/components/AppProviders.tsx | 55 +++++++------- .../src/core/components/shared/InfoBanner.tsx | 72 +++++++++++++++++++ frontend/src/core/contexts/BannerContext.tsx | 26 +++++++ frontend/src/desktop/App.tsx | 53 -------------- .../src/desktop/components/AppProviders.tsx | 2 + .../components/DesktopBannerInitializer.tsx | 13 ++++ .../components/shared/DefaultAppBanner.tsx | 54 +++----------- frontend/src/proprietary/App.tsx | 23 +++--- 10 files changed, 202 insertions(+), 134 deletions(-) create mode 100644 frontend/src/core/components/AppLayout.tsx create mode 100644 frontend/src/core/components/shared/InfoBanner.tsx create mode 100644 frontend/src/core/contexts/BannerContext.tsx delete mode 100644 frontend/src/desktop/App.tsx create mode 100644 frontend/src/desktop/components/DesktopBannerInitializer.tsx 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 24f793188..7b09bcc8a 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/App.tsx b/frontend/src/desktop/App.tsx deleted file mode 100644 index 89e092aa3..000000000 --- a/frontend/src/desktop/App.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Suspense } from "react"; -import { Routes, Route } from "react-router-dom"; -import { AppProviders } from "@app/components/AppProviders"; -import { LoadingFallback } from "@app/components/shared/LoadingFallback"; -import Landing from "@app/routes/Landing"; -import Login from "@app/routes/Login"; -import Signup from "@app/routes/Signup"; -import AuthCallback from "@app/routes/AuthCallback"; -import InviteAccept from "@app/routes/InviteAccept"; -import OnboardingTour from "@app/components/onboarding/OnboardingTour"; -import { DefaultAppBanner } from "@app/components/shared/DefaultAppBanner"; - -// Import global styles -import "@app/styles/tailwind.css"; -import "@app/styles/cookieconsent.css"; -import "@app/styles/index.css"; -import "@app/styles/auth-theme.css"; - -// Import file ID debugging helpers (development only) -import "@app/utils/fileIdSafety"; - -export default function App() { - return ( - }> - -
- -
- - - {/* Auth routes - no nested providers needed */} - } /> - } /> - } /> - } /> - - {/* Main app routes - Landing handles auth logic */} - } /> - - -
-
-
-
- ); -} 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 index 1e75faa01..a0f617c86 100644 --- a/frontend/src/desktop/components/shared/DefaultAppBanner.tsx +++ b/frontend/src/desktop/components/shared/DefaultAppBanner.tsx @@ -1,7 +1,6 @@ import React, { useState, useEffect } from 'react'; -import { Paper, Group, Text, Button, ActionIcon } from '@mantine/core'; import { useTranslation } from 'react-i18next'; -import LocalIcon from '@app/components/shared/LocalIcon'; +import { InfoBanner } from '@app/components/shared/InfoBanner'; import { defaultAppService } from '@app/services/defaultAppService'; import { alert } from '@app/components/toast'; @@ -64,47 +63,16 @@ export const DefaultAppBanner: React.FC = () => { localStorage.setItem(PROMPT_DISMISSED_KEY, 'true'); }; - if (promptDismissed || isDefault !== false) { - return null; - } - return ( - - - - - {t('defaultApp.prompt.message', 'Make Stirling PDF your default application for opening PDF files.')} - - - - - - - + ); }; 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 */} + } /> + + + ); From 35485426926e28a9a28123abf2003e94d94cbb2d Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 14 Nov 2025 10:08:38 +0000 Subject: [PATCH 6/9] Make default app banner appearance more aggressive --- .../desktop/components/shared/DefaultAppBanner.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/frontend/src/desktop/components/shared/DefaultAppBanner.tsx b/frontend/src/desktop/components/shared/DefaultAppBanner.tsx index a0f617c86..35bd44f7c 100644 --- a/frontend/src/desktop/components/shared/DefaultAppBanner.tsx +++ b/frontend/src/desktop/components/shared/DefaultAppBanner.tsx @@ -4,15 +4,11 @@ import { InfoBanner } from '@app/components/shared/InfoBanner'; import { defaultAppService } from '@app/services/defaultAppService'; import { alert } from '@app/components/toast'; -const PROMPT_DISMISSED_KEY = 'stirlingpdf_default_app_prompt_dismissed'; - export const DefaultAppBanner: React.FC = () => { const { t } = useTranslation(); - const [promptDismissed, setPromptDismissed] = useState(() => { - return localStorage.getItem(PROMPT_DISMISSED_KEY) === 'true'; - }); const [isDefault, setIsDefault] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [dismissed, setDismissed] = useState(false); useEffect(() => { checkDefaultStatus(); @@ -59,8 +55,7 @@ export const DefaultAppBanner: React.FC = () => { }; const handleDismissPrompt = () => { - setPromptDismissed(true); - localStorage.setItem(PROMPT_DISMISSED_KEY, 'true'); + setDismissed(true); }; return ( @@ -72,7 +67,7 @@ export const DefaultAppBanner: React.FC = () => { onButtonClick={handleSetDefault} onDismiss={handleDismissPrompt} loading={isLoading} - show={!promptDismissed && isDefault === false} + show={!dismissed && isDefault === false} /> ); }; From 2f71e6778120beeb737f98e4d9e13726c11bfae2 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 14 Nov 2025 13:49:58 +0000 Subject: [PATCH 7/9] Don't spawn cmd windows when checking default app in Windows --- frontend/src-tauri/src/commands/default_app.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src-tauri/src/commands/default_app.rs b/frontend/src-tauri/src/commands/default_app.rs index bade6dba9..81e422240 100644 --- a/frontend/src-tauri/src/commands/default_app.rs +++ b/frontend/src-tauri/src/commands/default_app.rs @@ -51,9 +51,13 @@ pub fn set_as_default_pdf_handler() -> Result { #[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))?; @@ -65,6 +69,7 @@ fn check_default_windows() -> Result { // 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))?; From 85934e4625dc7ae673518c87f60c3bc5c8e8d945 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 14 Nov 2025 14:24:03 +0000 Subject: [PATCH 8/9] Only read commandline args on startup --- frontend/src-tauri/src/commands/files.rs | 22 +++++----------------- frontend/src-tauri/src/lib.rs | 10 ++++++++++ 2 files changed, 15 insertions(+), 17 deletions(-) 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/lib.rs b/frontend/src-tauri/src/lib.rs index 02343b290..969479666 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -45,6 +45,16 @@ pub fn run() { })) .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(()) }) From e367af47efe375aca515cda05b0896ad418c30f1 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 14 Nov 2025 17:31:54 +0000 Subject: [PATCH 9/9] Move setting to top --- .../components/shared/config/configSections/GeneralSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/desktop/components/shared/config/configSections/GeneralSection.tsx b/frontend/src/desktop/components/shared/config/configSections/GeneralSection.tsx index 8dfec2ee9..dbe9a0038 100644 --- a/frontend/src/desktop/components/shared/config/configSections/GeneralSection.tsx +++ b/frontend/src/desktop/components/shared/config/configSections/GeneralSection.tsx @@ -9,8 +9,8 @@ import { DefaultAppSettings } from '@app/components/shared/config/configSections const GeneralSection: React.FC = () => { return ( - + ); };