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 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'
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 =
'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 =
@@ -76,6 +84,10 @@ describe('InstallHint', () => {
['desktop Chrome', { userAgent: DESKTOP_CHROME }],
['desktop Safari on a Mac', { userAgent: MAC_SAFARI }],
['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) => {
mockDevice(device)
render(<InstallHint />)
@@ -94,17 +106,41 @@ describe('InstallHint', () => {
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 })
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')
}
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(blocked)
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(blocked)
})
render(<InstallHint />)
expect(screen.getByRole('note')).toHaveTextContent(copy)
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
expect(screen.queryByRole('note')).toBeNull()
expect(screen.getByRole('alert')).toHaveTextContent(
'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 { Share, X } from 'lucide-react'
import { Card } from '../ui/Card.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import styles from './InstallHint.module.css'
@@ -9,9 +10,11 @@ import styles from './InstallHint.module.css'
// household dismissed the hint.
export const DISMISSED_KEY = 'mealquest.installHint.dismissed'
// iOS browsers other than Safari name themselves in the user agent; Safari
// does not, and in-app web views leave out the Safari/ token.
const OTHER_IOS_BROWSERS = /CriOS|FxiOS|EdgiOS|OPiOS|OPT\/|GSA\/|YaBrowser|DuckDuckGo|Brave/
// Safari's user agent ends "(KHTML, like Gecko) Version/<v> Mobile/<build>
// Safari/<v>" (iPadOS leaves out Mobile/). Every other iOS browser and in-app
// 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
// 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 {
const ua = nav.userAgent
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.
@@ -30,45 +33,65 @@ export function isStandalone(): boolean {
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
// which case reading it throws. The hint then counts as not dismissed.
function readDismissed(): boolean {
// which case using it throws. The failure is reported, never treated as a
// remembered dismissal.
function readDismissed(): HintState {
try {
return window.localStorage.getItem(DISMISSED_KEY) === '1'
} catch {
return false
return { dismissed: window.localStorage.getItem(DISMISSED_KEY) === '1', failure: null }
} catch (err) {
return { dismissed: false, failure: { action: 'read', message: String(err) } }
}
}
// Without storage the dismissal lasts until the page is reloaded.
function writeDismissed() {
function writeDismissed(): HintState {
try {
window.localStorage.setItem(DISMISSED_KEY, '1')
} catch {
// Nothing to remember it in; the component state still hides the hint.
return { dismissed: true, failure: null }
} 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
// 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
// onboarding step 3 and the Notifications screen.
// tab, never once installed, and stays dismissed on this device. If storage
// fails, the hint stays up with an error saying so. Used by onboarding step 3
// and the Notifications screen.
export function InstallHint() {
const [visible, setVisible] = useState(() => isIosSafari(navigator) && !isStandalone() && !readDismissed())
if (!visible) return null
function dismiss() {
writeDismissed()
setVisible(false)
}
const [eligible] = useState(() => isIosSafari(navigator) && !isStandalone())
const [state, setState] = useState<HintState>(() =>
eligible ? readDismissed() : { dismissed: false, failure: null },
)
if (!eligible || state.dismissed) return null
const { failure } = state
return (
<Card tone="muted" role="note" className={styles.hint}>
<Icon icon={Share} className={styles.icon} />
<p className={styles.message}>Tap Share, then Add to Home Screen to get notifications</p>
<IconButton label="Dismiss" onClick={dismiss}>
<Icon icon={X} />
</IconButton>
</Card>
<>
<Card tone="muted" role="note" className={styles.hint}>
<Icon icon={Share} className={styles.icon} />
<p className={styles.message}>Tap Share, then Add to Home Screen to get notifications</p>
<IconButton label="Dismiss" onClick={() => setState(writeDismissed())}>
<Icon icon={X} />
</IconButton>
</Card>
{failure && (
<ErrorBanner onRetry={() => setState(failure.action === 'read' ? readDismissed() : writeDismissed())}>
{failureText[failure.action]}: {failure.message}
</ErrorBanner>
)}
</>
)
}