Add prompt to make Stirling your default PDF app

This commit is contained in:
James Brunton 2025-11-13 12:15:03 +00:00
parent 50760b5302
commit cb2446ae83
10 changed files with 507 additions and 2 deletions

View File

@ -41,6 +41,25 @@
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Extract" "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": { "language": {
"direction": "ltr" "direction": "ltr"
}, },

View File

@ -643,6 +643,15 @@ dependencies = [
"libc", "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]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@ -3941,6 +3950,8 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
name = "stirling-pdf" name = "stirling-pdf"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"core-foundation 0.10.1",
"core-services",
"log", "log",
"reqwest 0.11.27", "reqwest 0.11.27",
"serde", "serde",

View File

@ -31,3 +31,7 @@ tauri-plugin-fs = "2.4.4"
tauri-plugin-single-instance = "2.0.1" tauri-plugin-single-instance = "2.0.1"
tokio = { version = "1.0", features = ["time"] } tokio = { version = "1.0", features = ["time"] }
reqwest = { version = "0.11", features = ["json"] } reqwest = { version = "0.11", features = ["json"] }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
core-services = "1.0"

View File

@ -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<bool, String> {
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<String, String> {
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<bool, String> {
// 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<String, String> {
// 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<bool, String> {
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<String, String> {
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<bool, String> {
// 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<String, String> {
// 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))
}
}

View File

@ -1,7 +1,9 @@
pub mod backend; pub mod backend;
pub mod health; pub mod health;
pub mod files; pub mod files;
pub mod default_app;
pub use backend::{start_backend, cleanup_backend}; pub use backend::{start_backend, cleanup_backend};
pub use health::check_backend_health; pub use health::check_backend_health;
pub use files::{get_opened_files, clear_opened_files, add_opened_file}; 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};

View File

@ -3,7 +3,16 @@ use tauri::{RunEvent, WindowEvent, Emitter, Manager};
mod utils; mod utils;
mod commands; 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}; use utils::{add_log, get_tauri_logs};
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
@ -39,7 +48,15 @@ pub fn run() {
add_log("🔍 DEBUG: Setup completed".to_string()); add_log("🔍 DEBUG: Setup completed".to_string());
Ok(()) 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!()) .build(tauri::generate_context!())
.expect("error while building tauri application") .expect("error while building tauri application")
.run(|app_handle, event| { .run(|app_handle, event| {

View File

@ -2,13 +2,18 @@ import { ReactNode } from "react";
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders"; import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
import { DesktopConfigSync } from '@app/components/DesktopConfigSync'; import { DesktopConfigSync } from '@app/components/DesktopConfigSync';
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig'; import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
import { DefaultAppPrompt } from '@app/components/DefaultAppPrompt';
import { useDefaultAppPrompt } from '@app/hooks/useDefaultAppPrompt';
/** /**
* Desktop application providers * Desktop application providers
* Wraps proprietary providers and adds desktop-specific configuration * Wraps proprietary providers and adds desktop-specific configuration
* - Enables retry logic for app config (needed for Tauri mode when backend is starting) * - 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 }) { export function AppProviders({ children }: { children: ReactNode }) {
const { promptOpened, handleSetDefault, handleDismiss } = useDefaultAppPrompt();
return ( return (
<ProprietaryAppProviders <ProprietaryAppProviders
appConfigRetryOptions={{ appConfigRetryOptions={{
@ -22,6 +27,11 @@ export function AppProviders({ children }: { children: ReactNode }) {
}} }}
> >
<DesktopConfigSync /> <DesktopConfigSync />
<DefaultAppPrompt
opened={promptOpened}
onSetDefault={handleSetDefault}
onDismiss={handleDismiss}
/>
{children} {children}
</ProprietaryAppProviders> </ProprietaryAppProviders>
); );

View File

@ -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 (
<Modal
opened={opened}
onClose={onDismiss}
title={t('defaultApp.title', 'Set as Default PDF App')}
centered
size="auto"
closeOnClickOutside={true}
closeOnEscape={true}
>
<Stack ta="center" p="md" gap="sm">
<PictureAsPdfIcon style={ICON_STYLE} />
<Text size="lg" fw={500}>
{t(
'defaultApp.message',
'Would you like to set Stirling PDF as your default PDF editor?'
)}
</Text>
<Text size="sm" c="dimmed">
{t(
'defaultApp.description',
'You can change this later in your system settings.'
)}
</Text>
</Stack>
<Flex
mt="md"
gap="sm"
justify="center"
align="center"
direction={{ base: 'column', md: 'row' }}
>
<Button
variant="light"
color="var(--mantine-color-gray-8)"
onClick={onDismiss}
leftSection={<CancelIcon fontSize="small" />}
w="10rem"
>
{t('defaultApp.notNow', 'Not Now')}
</Button>
<Button
variant="filled"
color="var(--mantine-color-blue-9)"
onClick={onSetDefault}
leftSection={<CheckCircleOutlineIcon fontSize="small" />}
w="10rem"
>
{t('defaultApp.setDefault', 'Set Default')}
</Button>
</Flex>
</Modal>
);
};

View File

@ -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,
};
}

View File

@ -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<boolean> {
try {
const result = await invoke<boolean>('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<string>('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<boolean> {
if (this.hasUserDismissedPrompt()) {
return false;
}
const isDefault = await this.isDefaultPdfHandler();
return !isDefault;
},
};