mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
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:
1 parent
2fc5fe5cf4
commit
4e4ac74579
2 files changed
+189
-98
No files matched your search
@@ -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
@@ -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;
|
||||
});
|
||||
Reference in new issue
Block a user