Skip to content

Instantly share code, notes, and snippets.

@fuweichin
Last active March 7, 2024 16:58
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save fuweichin/18522d21d3cd947026c2819bda25e0a6 to your computer and use it in GitHub Desktop.
Save fuweichin/18522d21d3cd947026c2819bda25e0a6 to your computer and use it in GitHub Desktop.
User Agent Client Hints API (navigator.userAgentData) polyfill and ponyfill
<h3>native navigator.userAgentData</h3>
<pre><code id="naviveUserAgentData">...</code></pre>
<h3>polyfilled navigator.userAgentData</h3>
<pre><code id="customUserAgentData">...</code></pre>
<script type="module">
import {ponyfill, polyfill} from './user-agent-data.js';
const $ = (s, c = document) => c.querySelector(s);
function main() {
if (location.protocol !== 'https:') {
$('#naviveUserAgentData').textContent = 'navigator.userAgentData is not available in insecure context';
$('#customUserAgentData').textContent = 'navigator.userAgentData polyfill is designed to work in secure context';
return;
}
let keys = ['platformVersion', 'architecture', 'bitness', 'model', 'fullVersionList'];
if (!navigator.userAgentData) {
$('#naviveUserAgentData').textContent = 'Your browser doesn\'t support client hints';
} else {
navigator.userAgentData.getHighEntropyValues(keys).then((result) => {
$('#naviveUserAgentData').textContent = JSON.stringify(result, null, 2);
});
}
let userAgentData = ponyfill();
userAgentData.getHighEntropyValues(keys).then((result) => {
$('#customUserAgentData').textContent = JSON.stringify(result, null, 2);
});
}
document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', main) : main();
</script>
function getClientHints(navigator) {
let {userAgent} = navigator;
let mobile, platform = '', platformVersion = '', architecture = '', bitness = '', model = '', uaFullVersion = '', fullVersionList = [];
let platformInfo = userAgent;
let found = false;
let versionInfo = userAgent.replace(/\(([^)]+)\)?/g, ($0, $1) => {
if (!found) {
platformInfo = $1;
found = true;
}
return '';
});
let items = versionInfo.match(/(\S+)\/(\S+)/g);
let webview = false;
// detect mobile
mobile = userAgent.indexOf('Mobile') !== -1;
let m;
let m2;
// detect platform
if ((m = /Windows NT (\d+(\.\d+)*)/.exec(platformInfo)) !== null) {
platform = 'Windows';
// see https://docs.microsoft.com/en-us/microsoft-edge/web-platform/how-to-detect-win11
let nt2win = {
'6.1': '0.1', // win-7
'6.2': '0.2', // win-8
'6.3': '0.3', // win-8.1
'10.0': '10.0', // win-10
'11.0': '13.0', // win-11
};
let ver = nt2win[m[1]];
if (ver)
platformVersion = padVersion(ver, 3);
if ((m2 = /\b(WOW64|Win64|x64)\b/.exec(platformInfo)) !== null) {
architecture = 'x86';
bitness = '64';
}
} else if ((m = /Android (\d+(\.\d+)*)/.exec(platformInfo)) !== null) {
platform = 'Android';
platformVersion = padVersion(m[1]);
if ((m2 = /Linux (\w+)/.exec(navigator.platform)) !== null) {
if (m2[1]) {
m2 = parseArch(m2[1]);
architecture = m2[0];
bitness = m2[1];
}
}
} else if ((m = /(iPhone|iPod touch); CPU iPhone OS (\d+(_\d+)*)/.exec(platformInfo)) !== null) {
// see special notes at https://www.whatismybrowser.com/guides/the-latest-user-agent/safari
platform = 'iOS';
platformVersion = padVersion(m[2].replace(/_/g, '.'));
} else if ((m = /(iPad); CPU OS (\d+(_\d+)*)/.exec(platformInfo)) !== null) {
platform = 'iOS';
platformVersion = padVersion(m[2].replace(/_/g, '.'));
} else if ((m = /Macintosh; (Intel|\w+) Mac OS X (\d+([_.]\d+)*)/.exec(platformInfo)) !== null) {
platform = 'macOS';
platformVersion = padVersion(m[2].replace(/_/g, '.'));
} else if ((m = /Linux/.exec(platformInfo)) !== null) {
platform = 'Linux';
platformVersion = '';
// TODO
} else if ((m = /CrOS (\w+) (\d+(\.\d+)*)/.exec(platformInfo)) !== null) {
platform = 'Chrome OS';
platformVersion = padVersion(m[2]);
m2 = parseArch(m[1]);
architecture = m2[0];
bitness = m2[1];
}
if (!platform) {
platform = 'Unknown';
}
// detect fullVersionList / brands
let notABrand = {brand: ' Not;A Brand', version: '99.0.0.0'};
if ((m = /Chrome\/(\d+(\.\d+)*)/.exec(versionInfo)) !== null && navigator.vendor === 'Google Inc.') {
fullVersionList.push({brand: 'Chromium', version: padVersion(m[1], 4)});
if ((m2 = /(Edge?)\/(\d+(\.\d+)*)/.exec(versionInfo)) !== null) {
let identBrandMap = {
'Edge': 'Microsoft Edge',
'Edg': 'Microsoft Edge',
};
let brand = identBrandMap[m[1]];
fullVersionList.push({brand: brand, version: padVersion(m2[2], 4)});
} else {
fullVersionList.push({brand: 'Google Chrome', version: padVersion(m[1], 4)});
}
if (/\bwv\b/.exec(platformInfo)) {
webview = true;
}
} else if ((m = /AppleWebKit\/(\d+(\.\d+)*)/.exec(versionInfo)) !== null && navigator.vendor === 'Apple Computer, Inc.') {
fullVersionList.push({brand: 'WebKit', version: padVersion(m[1])});
if (platform === 'iOS' && (m2 = /(CriOS|EdgiOS|FxiOS|Version)\/(\d+(\.\d+)*)/.exec(versionInfo)) != null) {
let identBrandMap = { // no
'CriOS': 'Google Chrome',
'EdgiOS': 'Microsoft Edge',
'FxiOS': 'Mozilla Firefox',
'Version': 'Apple Safari',
};
let brand = identBrandMap[m2[1]];
fullVersionList.push({brand, version: padVersion(m2[2])});
if (items.findIndex((s) => s.startsWith('Safari/')) === -1) {
webview = true;
}
}
} else if ((m = /Firefox\/(\d+(\.\d+)*)/.exec(versionInfo)) !== null) {
fullVersionList.push({brand: 'Firefox', version: padVersion(m[1])});
} else {
fullVersionList.push(notABrand);
}
uaFullVersion = fullVersionList.length > 0 ? fullVersionList[fullVersionList.length - 1] : '';
let brands = fullVersionList.map((b) => {
let pos = b.version.indexOf('.');
let version = pos === -1 ? b.version : b.version.slice(0, pos);
return {brand: b.brand, version};
});
// TODO detect architecture, bitness and model
return {
mobile,
platform,
brands,
platformVersion,
architecture,
bitness,
model,
uaFullVersion,
fullVersionList,
webview
};
}
function parseArch(arch) {
switch (arch) {
case 'x86_64':
case 'x64':
return ['x86', '64'];
case 'x86_32':
case 'x86':
return ['x86', ''];
case 'armv6l':
case 'armv7l':
case 'armv8l':
return [arch, ''];
case 'aarch64':
return ['arm', '64'];
default:
return ['', ''];
}
}
function padVersion(ver, minSegs = 3) {
let parts = ver.split('.');
let len = parts.length;
if (len < minSegs) {
for (let i = 0, lenToPad = minSegs - len; i < lenToPad; i += 1) {
parts.push('0');
}
return parts.join('.');
}
return ver;
}
class NavigatorUAData {
constructor() {
this._ch = getClientHints(navigator);
Object.defineProperties(this, {
_ch: {enumerable: false},
});
}
get mobile() {
return this._ch.mobile;
}
get platform() {
return this._ch.platform;
}
get brands() {
return this._ch.brands;
}
getHighEntropyValues(hints) {
return new Promise((resolve, reject) => {
if (!Array.isArray(hints)) {
throw new TypeError('argument hints is not an array');
}
let hintSet = new Set(hints);
let data = this._ch;
let obj = {
mobile: data.mobile,
platform: data.platform,
brands: data.brands,
};
if (hintSet.has('architecture'))
obj.architecture = data.architecture;
if (hintSet.has('bitness'))
obj.bitness = data.bitness;
if (hintSet.has('model'))
obj.model = data.model;
if (hintSet.has('platformVersion'))
obj.platformVersion = data.platformVersion;
if (hintSet.has('uaFullVersion'))
obj.uaFullVersion = data.uaFullVersion;
if (hintSet.has('fullVersionList'))
obj.fullVersionList = data.fullVersionList;
resolve(obj);
});
}
toJSON() {
let data = this._ch;
return {
mobile: data.mobile,
brands: data.brands,
};
}
}
Object.defineProperty(NavigatorUAData.prototype, Symbol.toStringTag, {
enumerable: false,
configurable: true,
writable: false,
value: 'NavigatorUAData'
});
function ponyfill() {
return new NavigatorUAData(navigator);
}
function polyfill() {
if (location.protocol === 'https:' && !navigator.userAgentData) {
let userAgentData = new NavigatorUAData(navigator);
Object.defineProperty(Navigator.prototype, 'userAgentData', {
enumerable: true,
configurable: true,
get: function getUseAgentData() {
return userAgentData;
}
});
Object.defineProperty(window, 'NavigatorUAData', {
enumerable: false,
configurable: true,
writable: true,
value: NavigatorUAData
});
return true;
}
return false;
}
export {ponyfill, polyfill};
@geekshit
Copy link

geekshit commented Mar 7, 2024

On macOS FF (Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:123.0) Gecko/20100101 Firefox/123.0) i think there is a litle bug on line 56: variable "m2" is undefined.

@fuweichin
Copy link
Author

Fixed line 56 and line 54

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment