Add prompt to make Stirling your default PDF app (#4890)

# Description of Changes
- Adds a reusable banner component/system to the core app
- Adds banner at the top of the desktop app if Stirling isn't your
default PDF editor, with a button to make it your default
- Adds a permanent button in the settings to do it manually (in case
you've dismissed the banner)
- Simplifies the file loading logic to fix a bug where the input file
could be duplicated occasionally. Now, the TS just receives files from
one buffer, regardless of how they've been passed to the app in Rust.

## Caveats
I've only been able to get the setting of default apps working properly
on Mac. The Windows build isn't signed (yet) so we can't use the proper
API for it, so currently it just sends you to the Settings UI. I've also
not been able to test it on Linux at all.
This commit is contained in:
James Brunton
2025-11-17 16:05:33 +00:00
committed by GitHub
parent 28eb9baa02
commit a415c457e9
22 changed files with 759 additions and 139 deletions
@@ -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 }) {
}}
>
<DesktopConfigSync />
<DesktopBannerInitializer />
{children}
</ProprietaryAppProviders>
);
@@ -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(<DefaultAppBanner />);
}, [setBanner]);
return null;
}
@@ -0,0 +1,27 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { InfoBanner } from '@app/components/shared/InfoBanner';
import { useDefaultApp } from '@app/hooks/useDefaultApp';
export const DefaultAppBanner: React.FC = () => {
const { t } = useTranslation();
const { isDefault, isLoading, handleSetDefault } = useDefaultApp();
const [dismissed, setDismissed] = useState(false);
const handleDismissPrompt = () => {
setDismissed(true);
};
return (
<InfoBanner
icon="picture-as-pdf-rounded"
message={t('defaultApp.prompt.message', 'Make Stirling PDF your default application for opening PDF files.')}
buttonText={t('defaultApp.setDefault', 'Set Default')}
buttonIcon="check-circle-rounded"
onButtonClick={handleSetDefault}
onDismiss={handleDismissPrompt}
loading={isLoading}
show={!dismissed && isDefault === false}
/>
);
};
@@ -0,0 +1,40 @@
import React from 'react';
import { Paper, Text, Button, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useDefaultApp } from '@app/hooks/useDefaultApp';
export const DefaultAppSettings: React.FC = () => {
const { t } = useTranslation();
const { isDefault, isLoading, handleSetDefault } = useDefaultApp();
return (
<Paper withBorder p="md" radius="md">
<Group justify="space-between" align="center">
<div>
<Text fw={500} size="sm">
{t('settings.general.defaultPdfEditor', 'Default PDF editor')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{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...')}
</Text>
</div>
<Button
variant={isDefault ? 'light' : 'filled'}
color="blue"
size="sm"
onClick={handleSetDefault}
loading={isLoading}
disabled={isDefault === true}
>
{isDefault
? t('settings.general.defaultPdfEditorSet', 'Already Default')
: t('settings.general.setAsDefault', 'Set as Default')}
</Button>
</Group>
</Paper>
);
};
@@ -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 (
<Stack gap="lg">
<DefaultAppSettings />
<CoreGeneralSection />
</Stack>
);
};
export default GeneralSection;
@@ -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]);
}
@@ -0,0 +1,61 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { defaultAppService } from '@app/services/defaultAppService';
import { alert } from '@app/components/toast';
export const useDefaultApp = () => {
const { t } = useTranslation();
const [isDefault, setIsDefault] = useState<boolean | null>(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 {
isDefault,
isLoading,
checkDefaultStatus,
handleSetDefault,
};
};
+16 -15
View File
@@ -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<string[]>([]);
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();
};
}, []);
@@ -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;
},
};