Refactor(system-detect): harden UA sniffing and tighten comments

Rewrite browser/OS detection to drop the deprecated `navigator.platform`,
recognize modern Edge ("Edg/"), iOS WebKit wrappers (CriOS / FxiOS / EdgiOS),
iPadOS 13+ desktop-UA disguise (via maxTouchPoints), and Chrome OS. Expand
test coverage to pin the tricky cases, then trim the explanatory comments
in both files to the non-obvious WHY.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jason5ng32andClaude Opus 4.7 committed 2026-05-27 23:51:30 +08:00
1 parent 2fc5fe5cf4
commit 4e4ac74579
2 files changed
+189 -98

No files matched your search

+40 -18
View File
@@ -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 };
export { detectBrowser, detectOS };
+149 -80
View File
@@ -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;
});