fix(desktop): persist default-app banner dismiss and fix Linux detection (#7112)

# 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 <wesley@awka.dev>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
This commit is contained in:
Wesley
2026-08-01 16:30:53 +01:00
committed by GitHub
co-authored by Wesley Anthony Stirling
parent e565251991
commit e76a22e520
7 changed files with 220 additions and 56 deletions
@@ -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"
@@ -171,7 +171,11 @@ fn set_default_macos() -> Result<String, String> {
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<String, String> {
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<String, String> {
);
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<String, String> {
// 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<bool, String> {
use std::process::Command;
@@ -222,18 +267,23 @@ fn check_default_linux() -> Result<bool, String> {
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<String, String> {
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))?;
@@ -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<InfoBannerProps> = ({
buttonText,
buttonIcon = "check-circle-rounded",
onButtonClick,
secondaryButtonText,
onSecondaryButtonClick,
onDismiss,
dismissible = true,
loading = false,
@@ -128,6 +133,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
}
const toneStyle = toneStyles[tone] ?? toneStyles.info;
const resolvedTextColor = textColor ?? toneStyle.text;
const handleDismiss = () => {
onDismiss?.();
};
@@ -184,7 +190,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
<Text
fw={600}
size={textSize}
style={{ color: textColor ?? toneStyle.text }}
style={{ color: resolvedTextColor }}
>
{title}
</Text>
@@ -192,7 +198,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
<Text
fw={title ? 400 : 500}
size={textSize}
style={{ color: textColor ?? toneStyle.text }}
style={{ color: resolvedTextColor }}
lineClamp={compact ? 1 : 2}
>
{message}
@@ -221,6 +227,17 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
{buttonText}
</Button>
)}
{secondaryButtonText && onSecondaryButtonClick && (
<Button
variant="tertiary"
accent="neutral"
size="sm"
onClick={onSecondaryButtonClick}
style={{ color: "var(--c-text-muted)" }}
>
{secondaryButtonText}
</Button>
)}
{dismissible && (
<ActionIcon
variant="tertiary"
@@ -228,7 +245,9 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
size="sm"
onClick={handleDismiss}
aria-label={t("infoBanner.dismiss", "Dismiss")}
style={closeIconColor ? { color: closeIconColor } : undefined}
style={{
color: closeIconColor ?? "var(--c-text-muted)",
}}
>
<LocalIcon
icon="close-rounded"
@@ -5,12 +5,14 @@ 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);
};
const {
isDefault,
isLoading,
promptDismissed,
handleSetDefault,
dontRemindAgain,
} = useDefaultApp();
const [sessionDismissed, setSessionDismissed] = useState(false);
return (
<InfoBanner
@@ -22,9 +24,14 @@ export const DefaultAppBanner: React.FC = () => {
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}
/>
);
};
@@ -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 (
<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
<Stack gap="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.defaultPdfEditorInactive",
"Another application is set as default",
"settings.general.defaultPdfEditorActive",
"Stirling PDF is your default PDF editor",
)
: t("settings.general.defaultPdfEditorChecking", "Checking...")}
</Text>
</div>
<Button
variant={isDefault ? "secondary" : "primary"}
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>
: isDefault === false
? t(
"settings.general.defaultPdfEditorInactive",
"Another application is set as default",
)
: t(
"settings.general.defaultPdfEditorChecking",
"Checking...",
)}
</Text>
</div>
<Button
variant={isDefault ? "secondary" : "primary"}
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>
{isDefault === false && (
<Group justify="space-between" align="center" wrap="nowrap">
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"settings.general.defaultPdfEditorRemind",
"Remind me to set as default",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"settings.general.defaultPdfEditorRemindDescription",
"Show a banner when Stirling PDF is not your default PDF application.",
)}
</Text>
</div>
<Switch
checked={remindEnabled}
onChange={(event) =>
setRemindWhenNotDefault(event.currentTarget.checked)
}
aria-label={t(
"settings.general.defaultPdfEditorRemind",
"Remind me to set as default",
)}
/>
</Group>
)}
</Stack>
</Paper>
);
};
@@ -7,11 +7,20 @@ export const useDefaultApp = () => {
const { t } = useTranslation();
const [isDefault, setIsDefault] = useState<boolean | null>(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,
};
};
@@ -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<PromptDismissedListener>();
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