mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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:
co-authored by
Wesley
Anthony Stirling
parent
e565251991
commit
e76a22e520
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+74
-31
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user