From e76a22e520b5a9f107b9970c9818d2d711dcffe8 Mon Sep 17 00:00:00 2001 From: Wesley <25321235+wesley-ka@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:30:53 +0200 Subject: [PATCH] fix(desktop): persist default-app banner dismiss and fix Linux detection (#7112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes Fixes the desktop default-PDF banner that kept returning every launch after users dismissed it or already set Stirling as default ([#6743](https://github.com/Stirling-Tools/Stirling-PDF/issues/6743), also tracked in [#5772](https://github.com/Stirling-Tools/Stirling-PDF/issues/5772) / [#6270](https://github.com/Stirling-Tools/Stirling-PDF/issues/6270)). ### What changed - **Persist dismiss:** X closes the banner for the current session only; **Don't remind me again** (muted secondary action) permanently opts out via the existing `localStorage` helpers that were never wired. - **Settings:** General → Default PDF editor includes a **Remind me to set as default** toggle (on by default), shown only when Stirling is not already the default, so users can undo a permanent dismiss. - **Linux detection:** Treat `Stirling-PDF.desktop` / case-insensitive `*stirling*.desktop` as default, and resolve the real desktop file when setting the association (fixes “already default but banner still shows”). - **InfoBanner:** optional secondary button support for the muted “Don't remind me again” action. ### macOS quirk (Gatekeeper false positive) One-click **Set Default** on macOS still uses `LSSetDefaultRoleHandlerForContentType` — Apple has no public replacement for document UTIs, so this remains the only single-button path. After setting Stirling as default, if a user later switches away with Finder **Open With → Always Open With** on a *quarantined* (typically downloaded) PDF, macOS can show: > Apple could not verify “…pdf” is free of malware… That is a known Gatekeeper/`LSRiskCategoryHasRedirectedBinding` behaviour ([Apple Developer Forums thread](https://developer.apple.com/forums/thread/795994)), not malware and not something we can suppress from the app. **Safe way to switch away:** select a PDF → File → Get Info → Open With → choose the app → **Change All** (avoid Open With → Always on downloaded PDFs). Closes #6743 --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [x] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) - New strings added in **en-US only** (`defaultApp.prompt.dontRemind`, `settings.general.defaultPdfEditorRemind` / `RemindDescription`); other locales handled separately. ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) - Banner: session dismiss (X) + muted **Don't remind me again**; settings toggle only when not already default. ### Testing (if applicable) - [x] I have run `task check` to verify linters, typechecks, and tests pass - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. #### Test runs - `task pre-commit:fix` (including toml-sort / locale hygiene) - `task backend:test` — passed (incl. JaCoCo coverage targets) - `task frontend:check` — lint, typecheck, format, tests - `task frontend:test` — **166 files / 1353 tests passed** - `task engine:check` — typecheck, lint, format; **335 pytest tests passed** - Re-ran `unusedTranslations` / `missingTranslations` after new en-US keys — passed - Manual: permanent dismiss persists across relaunch; settings remind toggle restores banner; Linux desktop-file name mismatch addressed in Rust Co-authored-by: Wesley Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../public/locales/en-US/translation.toml | 3 + .../src-tauri/src/commands/default_app.rs | 64 +++++++++-- .../src/core/components/shared/InfoBanner.tsx | 25 ++++- .../components/shared/DefaultAppBanner.tsx | 23 ++-- .../configSections/DefaultAppSettings.tsx | 105 ++++++++++++------ .../editor/src/desktop/hooks/useDefaultApp.ts | 24 ++++ .../src/desktop/services/defaultAppService.ts | 32 ++++-- 7 files changed, 220 insertions(+), 56 deletions(-) diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 3cfc28cd09..3b94a878ac 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3525,6 +3525,7 @@ message = "Failed to set default PDF handler" title = "Error" [defaultApp.prompt] +dontRemind = "Don't remind me again" message = "Make Stirling PDF your default application for opening PDF files." [defaultApp.settingsOpened] @@ -9641,6 +9642,8 @@ defaultPdfEditor = "Default PDF editor" defaultPdfEditorActive = "Stirling PDF is your default PDF editor" defaultPdfEditorChecking = "Checking..." defaultPdfEditorInactive = "Another application is set as default" +defaultPdfEditorRemind = "Remind me to set as default" +defaultPdfEditorRemindDescription = "Show a banner when Stirling PDF is not your default PDF application." defaultPdfEditorSet = "Already Default" defaultStartupView = "Default view on launch" defaultStartupViewDescription = "Choose which view is active when the app starts" diff --git a/frontend/editor/src-tauri/src/commands/default_app.rs b/frontend/editor/src-tauri/src/commands/default_app.rs index 41c59fdd09..1ca4e0c004 100644 --- a/frontend/editor/src-tauri/src/commands/default_app.rs +++ b/frontend/editor/src-tauri/src/commands/default_app.rs @@ -171,7 +171,11 @@ fn set_default_macos() -> Result { use core_foundation::string::{CFString, CFStringRef}; use std::os::raw::c_int; - // Define the LSSetDefaultRoleHandlerForContentType function + // LSSetDefaultRoleHandlerForContentType is deprecated and has no public + // replacement for document UTIs (unlike default browser/mail). It is still + // the only one-click option. Prefer Finder Get Info → Change All when + // switching *away* from Stirling — Open With → Always Open With can trip a + // Gatekeeper false positive (LSRiskCategoryHasRedirectedBinding). #[link(name = "CoreServices", kind = "framework")] extern "C" { fn LSSetDefaultRoleHandlerForContentType( @@ -184,7 +188,6 @@ fn set_default_macos() -> Result { 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"); @@ -195,7 +198,7 @@ fn set_default_macos() -> Result { ); if status == 0 { - add_log("Successfully triggered default app dialog".to_string()); + add_log("Successfully set as default PDF handler on macOS".to_string()); Ok("set_successfully".to_string()) } else { let error_msg = format!("LaunchServices returned status: {}", status); @@ -209,6 +212,48 @@ fn set_default_macos() -> Result { // Linux Implementation // ============================================================================ +/// Installed desktop entry names vary by packaging (template vs binary name). +#[cfg(target_os = "linux")] +const LINUX_DESKTOP_CANDIDATES: &[&str] = + &["Stirling-PDF.desktop", "stirling-pdf.desktop"]; + +#[cfg(target_os = "linux")] +fn is_stirling_desktop_handler(handler: &str) -> bool { + let name = handler.trim().to_lowercase(); + name.contains("stirling") && name.ends_with(".desktop") +} + +#[cfg(target_os = "linux")] +fn linux_desktop_file_exists(name: &str) -> bool { + use std::path::PathBuf; + + let mut dirs = Vec::new(); + if let Ok(home) = std::env::var("HOME") { + dirs.push(PathBuf::from(home).join(".local/share/applications")); + } + if let Ok(xdg_data_dirs) = std::env::var("XDG_DATA_DIRS") { + for dir in xdg_data_dirs.split(':').filter(|d| !d.is_empty()) { + dirs.push(PathBuf::from(dir).join("applications")); + } + } else { + dirs.push(PathBuf::from("/usr/local/share/applications")); + dirs.push(PathBuf::from("/usr/share/applications")); + } + + dirs.iter().any(|dir| dir.join(name).is_file()) +} + +#[cfg(target_os = "linux")] +fn resolve_linux_desktop_file() -> String { + for name in LINUX_DESKTOP_CANDIDATES { + if linux_desktop_file_exists(name) { + return (*name).to_string(); + } + } + // Matches tauri.conf.json desktopTemplate + "stirling-pdf.desktop".to_string() +} + #[cfg(target_os = "linux")] fn check_default_linux() -> Result { use std::process::Command; @@ -222,18 +267,23 @@ fn check_default_linux() -> Result { 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) + // Accept stirling-pdf.desktop and Stirling-PDF.desktop (and similar) + Ok(is_stirling_desktop_handler(&handler)) } #[cfg(target_os = "linux")] fn set_default_linux() -> Result { use std::process::Command; + let desktop_file = resolve_linux_desktop_file(); + add_log(format!( + "Setting Linux default PDF handler to {}", + desktop_file + )); + // Use xdg-mime to set the default application for PDF files let result = Command::new("xdg-mime") - .args(["default", "stirling-pdf.desktop", "application/pdf"]) + .args(["default", &desktop_file, "application/pdf"]) .output() .map_err(|e| format!("Failed to set default app: {}", e))?; diff --git a/frontend/editor/src/core/components/shared/InfoBanner.tsx b/frontend/editor/src/core/components/shared/InfoBanner.tsx index 6572022299..a081f60bbe 100644 --- a/frontend/editor/src/core/components/shared/InfoBanner.tsx +++ b/frontend/editor/src/core/components/shared/InfoBanner.tsx @@ -78,6 +78,9 @@ interface InfoBannerProps { buttonText?: string; buttonIcon?: string; onButtonClick?: () => void; + /** Optional muted secondary action (e.g. "Don't remind me again"). */ + secondaryButtonText?: string; + onSecondaryButtonClick?: () => void; onDismiss?: () => void; dismissible?: boolean; loading?: boolean; @@ -106,6 +109,8 @@ export const InfoBanner: React.FC = ({ buttonText, buttonIcon = "check-circle-rounded", onButtonClick, + secondaryButtonText, + onSecondaryButtonClick, onDismiss, dismissible = true, loading = false, @@ -128,6 +133,7 @@ export const InfoBanner: React.FC = ({ } const toneStyle = toneStyles[tone] ?? toneStyles.info; + const resolvedTextColor = textColor ?? toneStyle.text; const handleDismiss = () => { onDismiss?.(); }; @@ -184,7 +190,7 @@ export const InfoBanner: React.FC = ({ {title} @@ -192,7 +198,7 @@ export const InfoBanner: React.FC = ({ {message} @@ -221,6 +227,17 @@ export const InfoBanner: React.FC = ({ {buttonText} )} + {secondaryButtonText && onSecondaryButtonClick && ( + + )} {dismissible && ( = ({ size="sm" onClick={handleDismiss} aria-label={t("infoBanner.dismiss", "Dismiss")} - style={closeIconColor ? { color: closeIconColor } : undefined} + style={{ + color: closeIconColor ?? "var(--c-text-muted)", + }} > { const { t } = useTranslation(); - const { isDefault, isLoading, handleSetDefault } = useDefaultApp(); - const [dismissed, setDismissed] = useState(false); - - const handleDismissPrompt = () => { - setDismissed(true); - }; + const { + isDefault, + isLoading, + promptDismissed, + handleSetDefault, + dontRemindAgain, + } = useDefaultApp(); + const [sessionDismissed, setSessionDismissed] = useState(false); return ( { buttonText={t("defaultApp.setDefault", "Set Default")} buttonIcon="check-circle-rounded" onButtonClick={handleSetDefault} - onDismiss={handleDismissPrompt} + secondaryButtonText={t( + "defaultApp.prompt.dontRemind", + "Don't remind me again", + )} + onSecondaryButtonClick={dontRemindAgain} + onDismiss={() => setSessionDismissed(true)} loading={isLoading} - show={!dismissed && isDefault === false} + show={!sessionDismissed && !promptDismissed && isDefault === false} /> ); }; diff --git a/frontend/editor/src/desktop/components/shared/config/configSections/DefaultAppSettings.tsx b/frontend/editor/src/desktop/components/shared/config/configSections/DefaultAppSettings.tsx index 247fda74d3..01d908f0b2 100644 --- a/frontend/editor/src/desktop/components/shared/config/configSections/DefaultAppSettings.tsx +++ b/frontend/editor/src/desktop/components/shared/config/configSections/DefaultAppSettings.tsx @@ -1,46 +1,89 @@ import React from "react"; -import { Paper, Text, Group } from "@mantine/core"; +import { Paper, Text, Group, Switch, Stack } from "@mantine/core"; import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import { useDefaultApp } from "@app/hooks/useDefaultApp"; export const DefaultAppSettings: React.FC = () => { const { t } = useTranslation(); - const { isDefault, isLoading, handleSetDefault } = useDefaultApp(); + const { + isDefault, + isLoading, + promptDismissed, + handleSetDefault, + setRemindWhenNotDefault, + } = useDefaultApp(); + + // Remind is on by default (promptDismissed is false until the user opts out). + const remindEnabled = !promptDismissed; 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.defaultPdfEditor", "Default PDF editor")} + + + {isDefault === true ? t( - "settings.general.defaultPdfEditorInactive", - "Another application is set as default", + "settings.general.defaultPdfEditorActive", + "Stirling PDF is your default PDF editor", ) - : t("settings.general.defaultPdfEditorChecking", "Checking...")} - -
- -
+ : isDefault === false + ? t( + "settings.general.defaultPdfEditorInactive", + "Another application is set as default", + ) + : t( + "settings.general.defaultPdfEditorChecking", + "Checking...", + )} +
+
+ +
+ + {isDefault === false && ( + +
+ + {t( + "settings.general.defaultPdfEditorRemind", + "Remind me to set as default", + )} + + + {t( + "settings.general.defaultPdfEditorRemindDescription", + "Show a banner when Stirling PDF is not your default PDF application.", + )} + +
+ + setRemindWhenNotDefault(event.currentTarget.checked) + } + aria-label={t( + "settings.general.defaultPdfEditorRemind", + "Remind me to set as default", + )} + /> +
+ )} +
); }; diff --git a/frontend/editor/src/desktop/hooks/useDefaultApp.ts b/frontend/editor/src/desktop/hooks/useDefaultApp.ts index 9f647ca888..1719ee0924 100644 --- a/frontend/editor/src/desktop/hooks/useDefaultApp.ts +++ b/frontend/editor/src/desktop/hooks/useDefaultApp.ts @@ -7,11 +7,20 @@ export const useDefaultApp = () => { const { t } = useTranslation(); const [isDefault, setIsDefault] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [promptDismissed, setPromptDismissed] = useState(() => + defaultAppService.hasUserDismissedPrompt(), + ); useEffect(() => { checkDefaultStatus(); }, []); + useEffect(() => { + return defaultAppService.subscribePromptDismissed(() => { + setPromptDismissed(defaultAppService.hasUserDismissedPrompt()); + }); + }, []); + const checkDefaultStatus = async () => { try { const status = await defaultAppService.isDefaultPdfHandler(); @@ -21,6 +30,18 @@ export const useDefaultApp = () => { } }; + /** Permanently stop showing the banner (same as turning off the settings toggle). */ + const dontRemindAgain = () => { + defaultAppService.setPromptDismissed(true); + setPromptDismissed(true); + }; + + /** Settings toggle: remind is on by default until the user opts out. */ + const setRemindWhenNotDefault = (remind: boolean) => { + defaultAppService.setPromptDismissed(!remind); + setPromptDismissed(!remind); + }; + const handleSetDefault = async () => { setIsLoading(true); try { @@ -64,7 +85,10 @@ export const useDefaultApp = () => { return { isDefault, isLoading, + promptDismissed, checkDefaultStatus, + dontRemindAgain, + setRemindWhenNotDefault, handleSetDefault, }; }; diff --git a/frontend/editor/src/desktop/services/defaultAppService.ts b/frontend/editor/src/desktop/services/defaultAppService.ts index c7f554a960..0d5d17e45d 100644 --- a/frontend/editor/src/desktop/services/defaultAppService.ts +++ b/frontend/editor/src/desktop/services/defaultAppService.ts @@ -1,5 +1,16 @@ import { invoke } from "@tauri-apps/api/core"; +const PROMPT_DISMISSED_KEY = "stirlingpdf_default_app_prompt_dismissed"; + +type PromptDismissedListener = () => void; +const promptDismissedListeners = new Set(); + +function notifyPromptDismissedListeners(): void { + for (const listener of promptDismissedListeners) { + listener(); + } +} + /** * Service for managing default PDF handler settings * Note: Uses localStorage for machine-specific preferences (not synced to server) @@ -39,9 +50,7 @@ export const defaultAppService = { */ hasUserDismissedPrompt(): boolean { try { - const dismissed = localStorage.getItem( - "stirlingpdf_default_app_prompt_dismissed", - ); + const dismissed = localStorage.getItem(PROMPT_DISMISSED_KEY); return dismissed === "true"; } catch { return false; @@ -53,15 +62,24 @@ export const defaultAppService = { */ setPromptDismissed(dismissed: boolean): void { try { - localStorage.setItem( - "stirlingpdf_default_app_prompt_dismissed", - dismissed ? "true" : "false", - ); + localStorage.setItem(PROMPT_DISMISSED_KEY, dismissed ? "true" : "false"); + notifyPromptDismissedListeners(); } catch (error) { console.error("[DefaultApp] Failed to save prompt preference:", error); } }, + /** + * Subscribe to prompt-dismissed preference changes (same-session sync). + * Returns an unsubscribe function. + */ + subscribePromptDismissed(listener: PromptDismissedListener): () => void { + promptDismissedListeners.add(listener); + return () => { + promptDismissedListeners.delete(listener); + }; + }, + /** * Check if we should show the default app prompt * Returns true if: user hasn't dismissed it AND app is not default handler