From 04c18f5c5d9c9b650969be0f6953c130e28b64f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:06:42 +0000 Subject: [PATCH 1/4] Initial plan From 762355bb0395b0cd6b91d0d34daad6266b69cf54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:19:33 +0000 Subject: [PATCH 2/4] Add iOS Safari PWA notification for data persistence warning Implement detection of iOS/iPadOS Safari users who haven't installed the app as a PWA, and display a dismissible warning about Safari's 7-day ITP storage wipe policy. The notice encourages users to add the app to their Home Screen for safer data persistence. - Add is-ios-safari.ts utility with isIOS, isIOSSafari, isRunningAsPWA - Add IosPwaNotice component using WordPress Notice component - Integrate notice into Layout component - Add comprehensive unit tests for iOS detection Co-authored-by: ashfame <858906+ashfame@users.noreply.github.com> --- .../src/components/ios-pwa-notice/index.tsx | 93 +++++++++++++++ .../ios-pwa-notice/style.module.css | 61 ++++++++++ .../src/components/layout/index.tsx | 2 + .../personal-wp/src/lib/is-ios-safari.spec.ts | 110 ++++++++++++++++++ .../personal-wp/src/lib/is-ios-safari.ts | 71 +++++++++++ 5 files changed, 337 insertions(+) create mode 100644 packages/playground/personal-wp/src/components/ios-pwa-notice/index.tsx create mode 100644 packages/playground/personal-wp/src/components/ios-pwa-notice/style.module.css create mode 100644 packages/playground/personal-wp/src/lib/is-ios-safari.spec.ts create mode 100644 packages/playground/personal-wp/src/lib/is-ios-safari.ts diff --git a/packages/playground/personal-wp/src/components/ios-pwa-notice/index.tsx b/packages/playground/personal-wp/src/components/ios-pwa-notice/index.tsx new file mode 100644 index 00000000000..016175a1203 --- /dev/null +++ b/packages/playground/personal-wp/src/components/ios-pwa-notice/index.tsx @@ -0,0 +1,93 @@ +import { useState } from 'react'; +import { Notice, Button } from '@wordpress/components'; +import { isIOSSafari, isRunningAsPWA } from '../../lib/is-ios-safari'; +import css from './style.module.css'; + +const DISMISS_KEY = 'playground-ios-pwa-notice-dismissed'; + +/** + * A dismissible notice shown to iOS/iPadOS Safari users who have + * not installed the app as a PWA. It explains the risk of data + * loss due to Safari's Intelligent Tracking Prevention (ITP) + * which can wipe all script-writable storage after 7 days of + * inactivity, and encourages the user to add the app to their + * Home Screen. + */ +export function IosPwaNotice() { + const [dismissed, setDismissed] = useState( + () => localStorage.getItem(DISMISS_KEY) === 'true' + ); + + if (dismissed || !isIOSSafari() || isRunningAsPWA()) { + return null; + } + + const handleDismiss = () => { + localStorage.setItem(DISMISS_KEY, 'true'); + setDismissed(true); + }; + + return ( + +
+

+ + Your data may be erased by Safari after 7 days + +

+

+ Safari automatically clears website data after 7 days of + inactivity. To keep your WordPress data safe, install this + app to your Home Screen. +

+
+

+ Tap the{' '} + + Share button{' '} + +  + {/* Safari share icon (box with arrow) */} + + + {' '} + then choose{' '} + "Add to Home Screen". +

+
+
+ +
+
+
+ ); +} diff --git a/packages/playground/personal-wp/src/components/ios-pwa-notice/style.module.css b/packages/playground/personal-wp/src/components/ios-pwa-notice/style.module.css new file mode 100644 index 00000000000..12e5bebe8ef --- /dev/null +++ b/packages/playground/personal-wp/src/components/ios-pwa-notice/style.module.css @@ -0,0 +1,61 @@ +.iosPwaNotice { + margin: 0; + color: #1e1e1e; + font-size: inherit; + border-left-color: #dba617; + + .components-notice__content { + margin-right: 0; + } +} + +.content { + display: flex; + flex-direction: column; + gap: 6px; +} + +.headline { + margin: 0; + font-size: 13px; + line-height: 1.4; +} + +.body { + margin: 0; + font-size: 13px; + line-height: 1.5; +} + +.instructions { + margin: 4px 0 0; +} + +.step { + margin: 0; + font-size: 13px; + line-height: 1.5; +} + +.shareIcon { + display: inline-flex; + align-items: center; + vertical-align: middle; +} + +.shareIcon svg { + width: 14px; + height: 14px; + vertical-align: middle; + margin: 0 1px; +} + +.actions { + display: flex; + justify-content: flex-end; + margin-top: 4px; +} + +.dismissButton { + font-size: 12px; +} diff --git a/packages/playground/personal-wp/src/components/layout/index.tsx b/packages/playground/personal-wp/src/components/layout/index.tsx index 2cc78491e1d..b6d76929ead 100644 --- a/packages/playground/personal-wp/src/components/layout/index.tsx +++ b/packages/playground/personal-wp/src/components/layout/index.tsx @@ -15,6 +15,7 @@ import { MissingSiteModal } from '../missing-site-modal'; import { modalSlugs } from '../../lib/state/redux/slice-ui'; import { SiteManager } from '../site-manager'; import { useAutoBackup } from '../../lib/hooks/use-auto-backup'; +import { IosPwaNotice } from '../ios-pwa-notice'; const displayMode = getDisplayModeFromQuery(); function getDisplayModeFromQuery(): DisplayMode { @@ -35,6 +36,7 @@ export function Layout() { return (
+ { + it('returns true for iPhone user agent', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + + 'Version/17.0 Mobile/15E148 Safari/604.1'; + expect(isIOS(ua, 'iPhone', 5)).toBe(true); + }); + + it('returns true for iPad user agent', () => { + const ua = + 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + + 'Version/17.0 Mobile/15E148 Safari/604.1'; + expect(isIOS(ua, 'iPad', 5)).toBe(true); + }); + + it('returns true for iPadOS 13+ (MacIntel with touch)', () => { + const ua = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + + 'Version/17.0 Safari/605.1.15'; + expect(isIOS(ua, 'MacIntel', 5)).toBe(true); + }); + + it('returns false for macOS desktop (no touch)', () => { + const ua = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + + 'Version/17.0 Safari/605.1.15'; + expect(isIOS(ua, 'MacIntel', 0)).toBe(false); + }); + + it('returns false for Android', () => { + const ua = + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) ' + + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'Chrome/120.0.0.0 Mobile Safari/537.36'; + expect(isIOS(ua, 'Linux armv8l', 5)).toBe(false); + }); + + it('returns false for Windows', () => { + const ua = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'Chrome/120.0.0.0 Safari/537.36'; + expect(isIOS(ua, 'Win32', 0)).toBe(false); + }); +}); + +describe('isIOSSafari', () => { + it('returns true for Safari on iPhone', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + + 'Version/17.0 Mobile/15E148 Safari/604.1'; + expect(isIOSSafari(ua, 'iPhone', 5)).toBe(true); + }); + + it('returns true for Safari on iPadOS 13+', () => { + const ua = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + + 'Version/17.0 Safari/605.1.15'; + expect(isIOSSafari(ua, 'MacIntel', 5)).toBe(true); + }); + + it('returns false for Chrome on iOS (CriOS)', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + + 'CriOS/120.0.6099.119 Mobile/15E148 Safari/604.1'; + expect(isIOSSafari(ua, 'iPhone', 5)).toBe(false); + }); + + it('returns false for Firefox on iOS (FxiOS)', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + + 'FxiOS/120.0 Mobile/15E148 Safari/604.1'; + expect(isIOSSafari(ua, 'iPhone', 5)).toBe(false); + }); + + it('returns false for WKWebView (no Version/)', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + + 'Mobile/15E148'; + expect(isIOSSafari(ua, 'iPhone', 5)).toBe(false); + }); + + it('returns false for macOS Safari (no touch)', () => { + const ua = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + + 'Version/17.0 Safari/605.1.15'; + expect(isIOSSafari(ua, 'MacIntel', 0)).toBe(false); + }); + + it('returns false for Android Chrome', () => { + const ua = + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) ' + + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'Chrome/120.0.0.0 Mobile Safari/537.36'; + expect(isIOSSafari(ua, 'Linux armv8l', 5)).toBe(false); + }); +}); diff --git a/packages/playground/personal-wp/src/lib/is-ios-safari.ts b/packages/playground/personal-wp/src/lib/is-ios-safari.ts new file mode 100644 index 00000000000..d41c2dd0524 --- /dev/null +++ b/packages/playground/personal-wp/src/lib/is-ios-safari.ts @@ -0,0 +1,71 @@ +/** + * Detects whether the current environment is iOS/iPadOS Safari + * (not a WKWebView, not Chrome, not Firefox) and whether the app + * is running as an installed PWA (standalone display mode). + */ + +/** + * Returns true when the device is running iOS or iPadOS. + * + * iPads with iPadOS 13+ report as "MacIntel" in the platform + * string but expose multi-touch support, so we check both the + * legacy UA tokens and the modern platform + touchPoints + * combination. + */ +export function isIOS( + ua: string = navigator.userAgent, + platform: string = navigator.platform, + maxTouchPoints: number = navigator.maxTouchPoints +): boolean { + return ( + /iPad|iPhone|iPod/.test(ua) || + (platform === 'MacIntel' && maxTouchPoints > 1) + ); +} + +/** + * Returns true when the browser is Safari on iOS/iPadOS. + * + * Safari includes "Version/" in its user-agent string whereas + * WKWebViews (in-app browsers) do not. Chrome on iOS identifies + * itself with "CriOS" and Firefox with "FxiOS". + */ +export function isIOSSafari( + ua: string = navigator.userAgent, + platform: string = navigator.platform, + maxTouchPoints: number = navigator.maxTouchPoints +): boolean { + if (!isIOS(ua, platform, maxTouchPoints)) { + return false; + } + // Safari proper includes "Version/" + if (!/Version\//.test(ua)) { + return false; + } + // Exclude Chrome and Firefox on iOS + if (/CriOS\//.test(ua) || /FxiOS\//.test(ua)) { + return false; + } + return true; +} + +/** + * Returns true when the app is running as an installed PWA + * (standalone display mode). + */ +export function isRunningAsPWA(): boolean { + return window.matchMedia('(display-mode: standalone)').matches; +} + +/** + * Returns true when the user is on iOS Safari *and* the app is + * not installed as a PWA — i.e. the user is at risk of losing + * data due to Safari's ITP 7-day storage expiration policy. + */ +export function isIOSSafariWithoutPWA( + ua?: string, + platform?: string, + maxTouchPoints?: number +): boolean { + return isIOSSafari(ua, platform, maxTouchPoints) && !isRunningAsPWA(); +} From 649a9ebe75461da60ee6e3c6a364360a3f533d76 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:24:42 +0000 Subject: [PATCH 3/4] Fix notice placement: position as banner above browser chrome Move IosPwaNotice inside the siteView flex column so it appears as a compact banner above the browser chrome toolbar, instead of as a column beside the viewport. Co-authored-by: ashfame <858906+ashfame@users.noreply.github.com> --- .../playground/personal-wp/src/components/layout/index.tsx | 2 +- .../personal-wp/src/components/layout/style.module.css | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/playground/personal-wp/src/components/layout/index.tsx b/packages/playground/personal-wp/src/components/layout/index.tsx index b6d76929ead..f00ca9fc7f9 100644 --- a/packages/playground/personal-wp/src/components/layout/index.tsx +++ b/packages/playground/personal-wp/src/components/layout/index.tsx @@ -36,7 +36,6 @@ export function Layout() { return (
-
+
diff --git a/packages/playground/personal-wp/src/components/layout/style.module.css b/packages/playground/personal-wp/src/components/layout/style.module.css index 7fa6dfdb0ac..9f43799d7ed 100644 --- a/packages/playground/personal-wp/src/components/layout/style.module.css +++ b/packages/playground/personal-wp/src/components/layout/style.module.css @@ -37,6 +37,8 @@ body { .site-view { height: 100%; + display: flex; + flex-direction: column; } .site-manager-wrapper { @@ -127,6 +129,8 @@ body { overflow: hidden; transition: border-radius 300ms; height: 100%; + flex: 1 1 auto; + min-height: 0; } /* From 792993380a57ec9531764fea3f2805064a753bc0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:26:14 +0000 Subject: [PATCH 4/4] Address code review: wrap localStorage in try-catch, remove ZWNBSP - Wrap localStorage.getItem and setItem in try-catch to handle private browsing mode and storage quota errors - Remove zero-width no-break space character from share icon span Co-authored-by: ashfame <858906+ashfame@users.noreply.github.com> --- .../src/components/ios-pwa-notice/index.tsx | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/playground/personal-wp/src/components/ios-pwa-notice/index.tsx b/packages/playground/personal-wp/src/components/ios-pwa-notice/index.tsx index 016175a1203..013a98c96e6 100644 --- a/packages/playground/personal-wp/src/components/ios-pwa-notice/index.tsx +++ b/packages/playground/personal-wp/src/components/ios-pwa-notice/index.tsx @@ -5,6 +5,23 @@ import css from './style.module.css'; const DISMISS_KEY = 'playground-ios-pwa-notice-dismissed'; +function isDismissedInStorage(): boolean { + try { + return localStorage.getItem(DISMISS_KEY) === 'true'; + } catch { + return false; + } +} + +function persistDismissal(): void { + try { + localStorage.setItem(DISMISS_KEY, 'true'); + } catch { + // Storage unavailable — the notice will reappear on + // next visit, which is acceptable. + } +} + /** * A dismissible notice shown to iOS/iPadOS Safari users who have * not installed the app as a PWA. It explains the risk of data @@ -14,16 +31,14 @@ const DISMISS_KEY = 'playground-ios-pwa-notice-dismissed'; * Home Screen. */ export function IosPwaNotice() { - const [dismissed, setDismissed] = useState( - () => localStorage.getItem(DISMISS_KEY) === 'true' - ); + const [dismissed, setDismissed] = useState(isDismissedInStorage); if (dismissed || !isIOSSafari() || isRunningAsPWA()) { return null; } const handleDismiss = () => { - localStorage.setItem(DISMISS_KEY, 'true'); + persistDismissal(); setDismissed(true); }; @@ -54,7 +69,6 @@ export function IosPwaNotice() { role="img" aria-label="share" > -  {/* Safari share icon (box with arrow) */}