diff --git a/frontend/utils/system-detect.js b/frontend/utils/system-detect.js index d439320d..e9d91f15 100644 --- a/frontend/utils/system-detect.js +++ b/frontend/utils/system-detect.js @@ -1,26 +1,48 @@ -// browserDetect.js -const detectBrowser = () => { - const userAgent = window.navigator.userAgent; - const isFirefox = userAgent.includes('Firefox'); - const isOpera = userAgent.includes('Opera') || userAgent.includes('OPR'); - const isIE = userAgent.includes('Trident'); - const isEdge = userAgent.includes('Edge'); - const isChrome = userAgent.includes('Chrome'); - const isSafari = userAgent.includes('Safari') && !isChrome; +// Minimal browser & OS detection for the PWA install flow and a couple of +// mobile-only debug switches. UA sniffing is unreliable — only use where a +// wrong answer is harmless; prefer feature detection elsewhere. +// +// We avoid `navigator.platform` (deprecated, frozen to legacy strings like +// "MacIntel" / "Win32") and `navigator.userAgentData` (Firefox / Safari +// don't ship it, high-entropy fields are async). +const getUA = () => (typeof window !== 'undefined' && window.navigator?.userAgent) || ''; +const getMaxTouch = () => (typeof window !== 'undefined' && window.navigator?.maxTouchPoints) || 0; + +const detectBrowser = () => { + const ua = getUA(); + const isFirefox = /Firefox\/|FxiOS\//.test(ua); + // Chromium Opera → "OPR/"; legacy Presto Opera → "Opera/". + const isOpera = /OPR\/|Opera\//.test(ua); + const isIE = /Trident\//.test(ua); + // Modern Edge is "Edg/" (no 'e'); legacy "Edge/" (EdgeHTML) is retired. + const isEdge = /Edg\//.test(ua); + // Chromium-based, NOT strictly Google Chrome — Edge / Opera / Brave / + // Samsung Internet all carry "Chrome/". Combine with !isEdge && !isOpera + // at the call site if you need real Chrome. + const isChrome = /Chrome\//.test(ua); + // Real Safari: "Safari/" without any other-engine / iOS-wrapper marker. + const isSafari = /Safari\//.test(ua) + && !/Chrome\/|Chromium\/|Edg\/|OPR\/|FxiOS\/|CriOS\/|EdgiOS\//.test(ua); return { isFirefox, isOpera, isIE, isEdge, isChrome, isSafari }; }; const detectOS = () => { - const platform = window.navigator.platform; - const userAgent = window.navigator.userAgent; - const isMac = platform.includes('Mac') || userAgent.includes('Mac' || 'Macintosh' || 'MacIntel' || 'MacPPC' || 'Mac68K'); - const isWindows = platform.startsWith('Win') || userAgent.includes('Windows' || 'Win64' || 'Win32'); - const isLinux = platform.includes('Linux') && !userAgent.includes('Android'); - const isAndroid = userAgent.includes('Android'); - const isIOS = /iPhone|iPod|iOS/.test(platform) || userAgent.includes('iPhone' || 'iPod' || 'iOS'); - + const ua = getUA(); + // iPadOS 13+ requests the desktop site by default — its UA is identical + // to macOS Safari. Touch capability is the only reliable distinguisher + // (desktop Macs have no native touchscreen). + const isIPadOS = /Macintosh/.test(ua) && getMaxTouch() > 1; + const isIOS = /iPhone|iPod|iPad/.test(ua) || isIPadOS; + const isAndroid = /Android/.test(ua); + // iPhone UA contains "Mac OS X" and iPadOS impersonates "Macintosh" — + // iOS must win first, otherwise both also flag as macOS. + const isMac = !isIOS && /Macintosh|Mac OS X/.test(ua); + const isWindows = /Windows|Win64|Win32/.test(ua); + // Chromebooks carry "CrOS", not "Linux" — exclude so they don't flag + // as Linux desktop. + const isLinux = !isAndroid && !/CrOS/.test(ua) && /Linux/.test(ua); return { isMac, isWindows, isLinux, isAndroid, isIOS }; }; -export { detectBrowser, detectOS }; \ No newline at end of file +export { detectBrowser, detectOS }; diff --git a/tests/system-detect.test.js b/tests/system-detect.test.js index 70b6728d..565eda2b 100644 --- a/tests/system-detect.test.js +++ b/tests/system-detect.test.js @@ -1,114 +1,183 @@ import assert from 'node:assert/strict'; -import { describe, it, before, after } from 'node:test'; +import { describe, it, after } from 'node:test'; -// The tested module accesses UA / platform via window.navigator, Node environment does not have window, -// here we inject a minimal globalThis.window stub directly, the module reads the value after loading, -// so it must be injected before the import. +// The module reads `userAgent` / `maxTouchPoints` via getters on every +// call, so we just swap a stub on globalThis between cases. Node has no +// window of its own. -function setNavigator({ userAgent = '', platform = '' } = {}) { - globalThis.window = { navigator: { userAgent, platform } }; +function setNavigator({ userAgent = '', maxTouchPoints = 0 } = {}) { + globalThis.window = { navigator: { userAgent, maxTouchPoints } }; } setNavigator(); const { detectBrowser, detectOS } = await import('../frontend/utils/system-detect.js'); describe('detectBrowser()', () => { - it('recognizes Chrome on macOS', () => { - setNavigator({ - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + it('recognizes Chrome on macOS', () => { + setNavigator({ + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }); + const b = detectBrowser(); + assert.equal(b.isChrome, true); + assert.equal(b.isSafari, false, 'Chrome UA contains "Safari" but should not be classified as Safari'); + assert.equal(b.isFirefox, false); + assert.equal(b.isEdge, false); }); - const b = detectBrowser(); - assert.equal(b.isChrome, true); - assert.equal(b.isSafari, false, 'Chrome UA contains "Safari" but should not be classified as Safari'); - assert.equal(b.isFirefox, false); - }); - it('recognizes Safari (no Chrome substring)', () => { - setNavigator({ - userAgent: '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', + it('recognizes Safari (no Chrome substring)', () => { + setNavigator({ + userAgent: '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', + }); + const b = detectBrowser(); + assert.equal(b.isSafari, true); + assert.equal(b.isChrome, false); }); - const b = detectBrowser(); - assert.equal(b.isSafari, true); - assert.equal(b.isChrome, false); - }); - it('recognizes Firefox', () => { - setNavigator({ - userAgent: 'Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0', + it('recognizes Firefox', () => { + setNavigator({ + userAgent: 'Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0', + }); + const b = detectBrowser(); + assert.equal(b.isFirefox, true); + assert.equal(b.isChrome, false); }); - const b = detectBrowser(); - assert.equal(b.isFirefox, true); - assert.equal(b.isChrome, false); - }); - it('recognizes Opera (OPR)', () => { - setNavigator({ - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36 OPR/106.0.0.0', + it('recognizes Opera (OPR)', () => { + setNavigator({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36 OPR/106.0.0.0', + }); + const b = detectBrowser(); + assert.equal(b.isOpera, true); }); - const b = detectBrowser(); - assert.equal(b.isOpera, true); - }); - it('recognizes IE via Trident', () => { - setNavigator({ - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko', + it('recognizes IE via Trident', () => { + setNavigator({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko', + }); + assert.equal(detectBrowser().isIE, true); + }); + + // Modern Edge is "Edg/" — guard against regressing to legacy "Edge/". + it('recognizes Chromium Edge ("Edg/")', () => { + setNavigator({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0', + }); + const b = detectBrowser(); + assert.equal(b.isEdge, true); + assert.equal(b.isChrome, true, 'Edge is Chromium-based; isChrome marks engine, not vendor'); + assert.equal(b.isSafari, false); + }); + + // iOS Chrome ("CriOS/") is a WebKit wrapper — must not register as + // Safari just because the UA carries a trailing "Safari/" token. + it('does not classify iOS Chrome (CriOS) as Safari', () => { + setNavigator({ + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.0.0 Mobile/15E148 Safari/604.1', + }); + const b = detectBrowser(); + assert.equal(b.isSafari, false); + assert.equal(b.isChrome, false, 'iOS Chrome is a WebKit wrapper, not Chromium'); + }); + + it('does not classify iOS Firefox (FxiOS) as Safari', () => { + setNavigator({ + userAgent: '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/605.1.15', + }); + const b = detectBrowser(); + assert.equal(b.isSafari, false); + assert.equal(b.isFirefox, true); }); - assert.equal(detectBrowser().isIE, true); - }); }); describe('detectOS()', () => { - it('recognizes macOS', () => { - setNavigator({ - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', - platform: 'MacIntel', + it('recognizes macOS', () => { + setNavigator({ + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', + }); + const os = detectOS(); + assert.equal(os.isMac, true); + assert.equal(os.isWindows, false); + assert.equal(os.isIOS, false); }); - const os = detectOS(); - assert.equal(os.isMac, true); - assert.equal(os.isWindows, false); - }); - it('recognizes Windows', () => { - setNavigator({ - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - platform: 'Win32', + it('recognizes Windows', () => { + setNavigator({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }); + const os = detectOS(); + assert.equal(os.isWindows, true); + assert.equal(os.isMac, false); }); - const os = detectOS(); - assert.equal(os.isWindows, true); - assert.equal(os.isMac, false); - }); - it('recognizes Linux (not Android)', () => { - setNavigator({ - userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36', - platform: 'Linux x86_64', + it('recognizes Linux (not Android)', () => { + setNavigator({ + userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36', + }); + const os = detectOS(); + assert.equal(os.isLinux, true); + assert.equal(os.isAndroid, false); }); - const os = detectOS(); - assert.equal(os.isLinux, true); - assert.equal(os.isAndroid, false); - }); - it('recognizes Android as not Linux', () => { - setNavigator({ - userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36', - platform: 'Linux armv8l', + it('recognizes Android as not Linux', () => { + setNavigator({ + userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36', + }); + const os = detectOS(); + assert.equal(os.isAndroid, true); + assert.equal(os.isLinux, false, 'Android should not also register as Linux'); }); - const os = detectOS(); - assert.equal(os.isAndroid, true); - assert.equal(os.isLinux, false, 'Android should not also register as Linux'); - }); - it('recognizes iOS', () => { - setNavigator({ - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15', - platform: 'iPhone', + it('recognizes iOS (iPhone)', () => { + setNavigator({ + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15', + }); + const os = detectOS(); + assert.equal(os.isIOS, true); + }); + + // iPhone UA contains "Mac OS X" — without the iOS-first guard it would + // also flag as macOS. + it('does not classify iPhone as macOS', () => { + setNavigator({ + userAgent: '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', + }); + const os = detectOS(); + assert.equal(os.isIOS, true); + assert.equal(os.isMac, false, 'iPhone UA contains "Mac OS X" but the device is iOS, not macOS'); + }); + + // iPadOS 13+ ships an identical UA to macOS Safari; touch capability + // is the only reliable distinguisher. + it('recognizes iPadOS 13+ disguised as macOS (touch capability)', () => { + setNavigator({ + userAgent: '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', + maxTouchPoints: 5, + }); + const os = detectOS(); + assert.equal(os.isIOS, true); + assert.equal(os.isMac, false, 'iPad on iPadOS 13+ must be classified as iOS, not macOS'); + }); + + // Same UA as the iPad above with no touch — pins the disambiguation. + it('keeps desktop Mac classified as macOS when no touch is present', () => { + setNavigator({ + userAgent: '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', + maxTouchPoints: 0, + }); + const os = detectOS(); + assert.equal(os.isMac, true); + assert.equal(os.isIOS, false); + }); + + it('does not classify Chrome OS as Linux', () => { + setNavigator({ + userAgent: 'Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }); + const os = detectOS(); + assert.equal(os.isLinux, false, 'Chromebook UA carries "CrOS"; it is not Linux desktop'); }); - const os = detectOS(); - assert.equal(os.isIOS, true); - }); }); -// clean up global pollution after(() => { - delete globalThis.window; + delete globalThis.window; });