Tighten iOS Safari detection and report install hint storage failures
CI / ci (push) Successful in 1m33s
CI / ci (pull_request) Successful in 1m39s

Detect Safari by its exact user agent tail instead of a "Safari/" token
plus an exclusion list, so third-party iOS browsers such as MQQBrowser
no longer get the hint. When storage cannot be read or written, keep the
hint visible and show an error banner with Retry instead of treating the
dismissal as saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 08:25:06 -04:00
co-authored by Claude Opus 5.5
parent ceccbda61d
commit 695003c5f7
2 changed files with 95 additions and 36 deletions
+43 -7
View File
@@ -9,6 +9,14 @@ const IPAD_SAFARI =
const MAC_SAFARI = IPAD_SAFARI const MAC_SAFARI = IPAD_SAFARI
const IPHONE_CHROME = const IPHONE_CHROME =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/138.0.7204.119 Mobile/15E148 Safari/604.1' 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/138.0.7204.119 Mobile/15E148 Safari/604.1'
const IPHONE_FIREFOX =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/141.0 Mobile/15E148 Safari/605.1.15'
const IPHONE_EDGE =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 EdgiOS/138.3351.83 Mobile/15E148 Safari/605.1.15'
const IPHONE_OPERA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1 OPT/5.4.0'
const IPHONE_MQQBROWSER =
'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 MQQBrowser/11.3.5 Mobile/15E148 Safari/604.1'
const ANDROID_CHROME = const ANDROID_CHROME =
'Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36' 'Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36'
const DESKTOP_CHROME = const DESKTOP_CHROME =
@@ -76,6 +84,10 @@ describe('InstallHint', () => {
['desktop Chrome', { userAgent: DESKTOP_CHROME }], ['desktop Chrome', { userAgent: DESKTOP_CHROME }],
['desktop Safari on a Mac', { userAgent: MAC_SAFARI }], ['desktop Safari on a Mac', { userAgent: MAC_SAFARI }],
['Chrome on iPhone', { userAgent: IPHONE_CHROME, maxTouchPoints: 5 }], ['Chrome on iPhone', { userAgent: IPHONE_CHROME, maxTouchPoints: 5 }],
['Firefox on iPhone', { userAgent: IPHONE_FIREFOX, maxTouchPoints: 5 }],
['Edge on iPhone', { userAgent: IPHONE_EDGE, maxTouchPoints: 5 }],
['Opera on iPhone', { userAgent: IPHONE_OPERA, maxTouchPoints: 5 }],
['MQQBrowser on iPhone', { userAgent: IPHONE_MQQBROWSER, maxTouchPoints: 5 }],
])('is hidden on %s', (_name, device: Device) => { ])('is hidden on %s', (_name, device: Device) => {
mockDevice(device) mockDevice(device)
render(<InstallHint />) render(<InstallHint />)
@@ -94,17 +106,41 @@ describe('InstallHint', () => {
expect(screen.queryByRole('note')).toBeNull() expect(screen.queryByRole('note')).toBeNull()
}) })
it('shows and can be dismissed when storage is unavailable', () => { it('stays up and reports the error when the dismissal cannot be saved', () => {
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false }) mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
const blocked = () => { const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('quota exceeded', 'QuotaExceededError')
})
render(<InstallHint />)
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
expect(screen.getByRole('note')).toHaveTextContent(copy)
expect(screen.getByRole('alert')).toHaveTextContent(
'Could not remember on this device that this hint was dismissed: QuotaExceededError: quota exceeded',
)
setItem.mockRestore()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(screen.queryByRole('note')).toBeNull()
expect(screen.queryByRole('alert')).toBeNull()
expect(localStorage.getItem(DISMISSED_KEY)).toBe('1')
})
it('shows and reports the error when storage cannot be read', () => {
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('storage blocked', 'SecurityError') throw new DOMException('storage blocked', 'SecurityError')
} })
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(blocked)
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(blocked)
render(<InstallHint />) render(<InstallHint />)
expect(screen.getByRole('note')).toHaveTextContent(copy) expect(screen.getByRole('note')).toHaveTextContent(copy)
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) expect(screen.getByRole('alert')).toHaveTextContent(
expect(screen.queryByRole('note')).toBeNull() 'Could not check whether this hint was dismissed on this device: SecurityError: storage blocked',
)
getItem.mockRestore()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(screen.getByRole('note')).toHaveTextContent(copy)
expect(screen.queryByRole('alert')).toBeNull()
}) })
}) })
+52 -29
View File
@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { Share, X } from 'lucide-react' import { Share, X } from 'lucide-react'
import { Card } from '../ui/Card.tsx' import { Card } from '../ui/Card.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx' import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx' import { IconButton } from '../ui/IconButton.tsx'
import styles from './InstallHint.module.css' import styles from './InstallHint.module.css'
@@ -9,9 +10,11 @@ import styles from './InstallHint.module.css'
// household dismissed the hint. // household dismissed the hint.
export const DISMISSED_KEY = 'mealquest.installHint.dismissed' export const DISMISSED_KEY = 'mealquest.installHint.dismissed'
// iOS browsers other than Safari name themselves in the user agent; Safari // Safari's user agent ends "(KHTML, like Gecko) Version/<v> Mobile/<build>
// does not, and in-app web views leave out the Safari/ token. // Safari/<v>" (iPadOS leaves out Mobile/). Every other iOS browser and in-app
const OTHER_IOS_BROWSERS = /CriOS|FxiOS|EdgiOS|OPiOS|OPT\/|GSA\/|YaBrowser|DuckDuckGo|Brave/ // web view adds its own token (CriOS, FxiOS, EdgiOS, OPiOS, MQQBrowser, GSA,
// ...) or drops Version/ or Safari/, so only this exact tail is Safari.
const SAFARI_TAIL = /\(KHTML, like Gecko\) Version\/[\d.]+ (?:Mobile\/\w+ )?Safari\/[\d.]+$/
// isIosSafari reports whether the page is open in Safari on iPhone, iPod or // isIosSafari reports whether the page is open in Safari on iPhone, iPod or
// iPad. iPadOS 13+ asks for the desktop site and reports itself as a Mac, so // iPad. iPadOS 13+ asks for the desktop site and reports itself as a Mac, so
@@ -19,7 +22,7 @@ const OTHER_IOS_BROWSERS = /CriOS|FxiOS|EdgiOS|OPiOS|OPT\/|GSA\/|YaBrowser|DuckD
export function isIosSafari(nav: Pick<Navigator, 'userAgent' | 'maxTouchPoints'>): boolean { export function isIosSafari(nav: Pick<Navigator, 'userAgent' | 'maxTouchPoints'>): boolean {
const ua = nav.userAgent const ua = nav.userAgent
const ios = /iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && nav.maxTouchPoints > 1) const ios = /iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && nav.maxTouchPoints > 1)
return ios && /Safari\//.test(ua) && !OTHER_IOS_BROWSERS.test(ua) return ios && SAFARI_TAIL.test(ua)
} }
// isStandalone reports whether the app is running from the Home Screen. // isStandalone reports whether the app is running from the Home Screen.
@@ -30,45 +33,65 @@ export function isStandalone(): boolean {
return legacy === true || window.matchMedia('(display-mode: standalone)').matches return legacy === true || window.matchMedia('(display-mode: standalone)').matches
} }
// HintState is whether the household dismissed the hint on this device, and
// the storage failure, if any, that stopped us reading or saving that.
type HintState = {
dismissed: boolean
failure: { action: 'read' | 'write'; message: string } | null
}
// Storage can be unavailable (blocked by the browser or privacy settings), in // Storage can be unavailable (blocked by the browser or privacy settings), in
// which case reading it throws. The hint then counts as not dismissed. // which case using it throws. The failure is reported, never treated as a
function readDismissed(): boolean { // remembered dismissal.
function readDismissed(): HintState {
try { try {
return window.localStorage.getItem(DISMISSED_KEY) === '1' return { dismissed: window.localStorage.getItem(DISMISSED_KEY) === '1', failure: null }
} catch { } catch (err) {
return false return { dismissed: false, failure: { action: 'read', message: String(err) } }
} }
} }
// Without storage the dismissal lasts until the page is reloaded. function writeDismissed(): HintState {
function writeDismissed() {
try { try {
window.localStorage.setItem(DISMISSED_KEY, '1') window.localStorage.setItem(DISMISSED_KEY, '1')
} catch { return { dismissed: true, failure: null }
// Nothing to remember it in; the component state still hides the hint. } catch (err) {
return { dismissed: false, failure: { action: 'write', message: String(err) } }
} }
} }
const failureText = {
read: 'Could not check whether this hint was dismissed on this device',
write: 'Could not remember on this device that this hint was dismissed',
}
// InstallHint tells iOS and iPadOS Safari users how to install the app, which // InstallHint tells iOS and iPadOS Safari users how to install the app, which
// iOS requires before it delivers Web Push. It shows only in a Safari browser // iOS requires before it delivers Web Push. It shows only in a Safari browser
// tab, never once installed, and stays dismissed on this device. Used by // tab, never once installed, and stays dismissed on this device. If storage
// onboarding step 3 and the Notifications screen. // fails, the hint stays up with an error saying so. Used by onboarding step 3
// and the Notifications screen.
export function InstallHint() { export function InstallHint() {
const [visible, setVisible] = useState(() => isIosSafari(navigator) && !isStandalone() && !readDismissed()) const [eligible] = useState(() => isIosSafari(navigator) && !isStandalone())
if (!visible) return null const [state, setState] = useState<HintState>(() =>
eligible ? readDismissed() : { dismissed: false, failure: null },
function dismiss() { )
writeDismissed() if (!eligible || state.dismissed) return null
setVisible(false)
}
const { failure } = state
return ( return (
<Card tone="muted" role="note" className={styles.hint}> <>
<Icon icon={Share} className={styles.icon} /> <Card tone="muted" role="note" className={styles.hint}>
<p className={styles.message}>Tap Share, then Add to Home Screen to get notifications</p> <Icon icon={Share} className={styles.icon} />
<IconButton label="Dismiss" onClick={dismiss}> <p className={styles.message}>Tap Share, then Add to Home Screen to get notifications</p>
<Icon icon={X} /> <IconButton label="Dismiss" onClick={() => setState(writeDismissed())}>
</IconButton> <Icon icon={X} />
</Card> </IconButton>
</Card>
{failure && (
<ErrorBanner onRetry={() => setState(failure.action === 'read' ? readDismissed() : writeDismissed())}>
{failureText[failure.action]}: {failure.message}
</ErrorBanner>
)}
</>
) )
} }