Skip to content

Commit bdd1862

Browse files
authored
Merge pull request #158 from pmndrs/feat/ios-detection
feat(ios): extend Apple GPU detection with missing chips + iOS version filter
2 parents 86e3f59 + 9b21de2 commit bdd1862

9 files changed

Lines changed: 1040 additions & 79 deletions

package.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,6 @@
6060
"build": "tsdown",
6161
"update-benchmarks": "node ./scripts/update_benchmarks.ts"
6262
},
63-
"dependencies": {
64-
"webgl-constants": "^1.1.1"
65-
},
6663
"devDependencies": {
6764
"@types/node": "^25.6.0",
6865
"@types/tar": "^7.0.87",

pnpm-lock.yaml

Lines changed: 0 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/internal/deobfuscateAppleGPU.ts

Lines changed: 108 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
1-
// Vendor
2-
import {
3-
GL_ARRAY_BUFFER,
4-
GL_COLOR_BUFFER_BIT,
5-
GL_FLOAT,
6-
GL_FRAGMENT_SHADER,
7-
GL_RGBA,
8-
GL_STATIC_DRAW,
9-
GL_TRIANGLES,
10-
GL_UNSIGNED_BYTE,
11-
GL_VERTEX_SHADER,
12-
} from 'webgl-constants';
13-
14-
// Internal
151
import { deviceInfo } from './deviceInfo';
162

3+
// WebGL enum values, inlined to avoid a dep on `webgl-constants`. Same
4+
// numbers as `gl.ARRAY_BUFFER` etc., which minifiers can't fold because
5+
// they're property accesses. SEE:
6+
// https://registry.khronos.org/webgl/specs/latest/1.0/#5.14
7+
const GL_ARRAY_BUFFER = 0x8892;
8+
const GL_COLOR_BUFFER_BIT = 0x4000;
9+
const GL_FLOAT = 0x1406;
10+
const GL_FRAGMENT_SHADER = 0x8b30;
11+
const GL_RGBA = 0x1908;
12+
const GL_STATIC_DRAW = 0x88e4;
13+
const GL_TRIANGLES = 0x0004;
14+
const GL_UNSIGNED_BYTE = 0x1401;
15+
const GL_VERTEX_SHADER = 0x8b31;
16+
1717
const debug = false ? console.warn : undefined;
1818

1919
export function deobfuscateAppleGPU(
@@ -25,67 +25,78 @@ export function deobfuscateAppleGPU(
2525
debug?.('Safari 14+ obfuscates its GPU type and version, using fallback');
2626
return [renderer];
2727
}
28-
const pixelId = calculateMagicPixelId(gl);
2928
const codeA = '801621810' as const;
3029
const codeB = '8016218135' as const;
3130
const codeC = '80162181161' as const;
3231
const codeFB = '80162181255';
3332

3433
// All chipsets that support at least iOS 12:
35-
const possibleChipsets: [
36-
string,
37-
typeof codeA | typeof codeB | typeof codeC,
38-
number,
39-
][] = deviceInfo?.isIpad
40-
? [
41-
// ['a4', 5], // ipad 1st gen
42-
// ['a5', 9], // ipad 2 / ipad mini 1st gen
43-
// ['a5x', 9], // ipad 3rd gen
44-
// ['a6x', 10], // ipad 4th gen
45-
['a7', codeC, 12], // ipad air / ipad mini 2 / ipad mini 3
46-
['a8', codeB, 15], // pad mini 4
47-
['a8x', codeB, 15], // ipad air 2
48-
['a9', codeB, 15], // ipad 5th gen
49-
['a9x', codeB, 15], // pro 9.7 2016 / pro 12.9 2015
50-
['a10', codeB, 15], // ipad 7th gen / ipad 6th gen
51-
['a10x', codeB, 15], // pro 10.5 2017 / pro 12.9 2nd gen, 2017
52-
['a12', codeA, 15], // ipad 8th gen / ipad air 3rd gen / ipad mini 5th gen
53-
['a12x', codeA, 15], // ipad pro 11 3st gen / ipad pro 12.9 3rd gen
54-
['a12z', codeA, 15], // ipad pro 11 4nd gen / ipad pro 12.9 4th gen
55-
['a14', codeA, 15], // ipad air 4th gen
56-
['a15', codeA, 15], // ipad mini 6th gen / ipad 10th gen
57-
['m1', codeA, 15], // ipad pro 11 5nd gen / ipad pro 12.9 5th gen / ipad air 5th gen
58-
['m2', codeA, 15], // ipad pro 11 6nd gen / ipad pro 12.9 6th gen
59-
]
60-
: [
61-
// ['a4', 7], // 4 / ipod touch 4th gen
62-
// ['a5', 9], // 4S / ipod touch 5th gen
63-
// ['a6', 10], // 5 / 5C
64-
['a7', codeC, 12], // 5S
65-
['a8', codeB, 12], // 6 / 6 plus / ipod touch 6th gen
66-
['a9', codeB, 15], // 6s / 6s plus/ se 1st gen
67-
['a10', codeB, 15], // 7 / 7 plus / iPod Touch 7th gen
68-
['a11', codeA, 15], // 8 / 8 plus / X
69-
['a12', codeA, 15], // XS / XS Max / XR
70-
['a13', codeA, 15], // 11 / 11 pro / 11 pro max / se 2nd gen
71-
['a14', codeA, 15], // 12 / 12 mini / 12 pro / 12 pro max
72-
['a15', codeA, 15], // 13 / 13 mini / 13 pro / 13 pro max / se 3rd gen / 14 / 14 plus
73-
['a16', codeA, 15], // 14 pro / 14 pro max / 15 / 15 plus
74-
['a17', codeA, 15], // 15 pro / 15 pro max
75-
];
76-
let chipsets: typeof possibleChipsets;
77-
78-
// In iOS 14.x Apple started normalizing the outcome of this hack,
79-
// we use this fact to limit the list to devices that support ios 14+
80-
if (pixelId === codeFB) {
81-
chipsets = possibleChipsets.filter(([, , iosVersion]) => iosVersion >= 14);
82-
} else {
83-
chipsets = possibleChipsets.filter(([, id]) => id === pixelId);
84-
// If nothing was found to match the pixel id, include all chipsets:
85-
if (!chipsets.length) {
86-
chipsets = possibleChipsets;
34+
let chipsets: [string, typeof codeA | typeof codeB | typeof codeC, number][] =
35+
deviceInfo?.isIpad
36+
? [
37+
// ['a4', 5], // ipad 1st gen
38+
// ['a5', 9], // ipad 2 / ipad mini 1st gen
39+
// ['a5x', 9], // ipad 3rd gen
40+
// ['a6x', 10], // ipad 4th gen
41+
['a7', codeC, 12], // ipad air / ipad mini 2 / ipad mini 3
42+
['a8', codeB, 15], // pad mini 4
43+
['a8x', codeB, 15], // ipad air 2
44+
['a9', codeB, 15], // ipad 5th gen
45+
['a9x', codeB, 15], // pro 9.7 2016 / pro 12.9 2015
46+
['a10', codeB, 15], // ipad 7th gen / ipad 6th gen
47+
['a10x', codeB, 15], // pro 10.5 2017 / pro 12.9 2nd gen, 2017
48+
['a12', codeA, 15], // ipad 8th gen / ipad air 3rd gen / ipad mini 5th gen
49+
['a12x', codeA, 15], // ipad pro 11 3st gen / ipad pro 12.9 3rd gen
50+
['a12z', codeA, 15], // ipad pro 11 4nd gen / ipad pro 12.9 4th gen
51+
['a14', codeA, 15], // ipad air 4th gen
52+
['a15', codeA, 15], // ipad mini 6th gen / ipad 10th gen
53+
['a16', codeA, 15], // ipad air 11 / ipad air 13 (a16, 2025) / ipad 11th gen
54+
['a17 pro', codeA, 15], // ipad mini 7th gen
55+
['m1', codeA, 15], // ipad pro 11 5nd gen / ipad pro 12.9 5th gen / ipad air 5th gen
56+
['m2', codeA, 15], // ipad pro 11 6nd gen / ipad pro 12.9 6th gen
57+
['m3', codeA, 15], // ipad air 11-inch (m3, 2025) / ipad air 13-inch (m3, 2025)
58+
['m4', codeA, 15], // ipad pro 11-inch (m4, 2024) / ipad pro 13-inch (m4, 2024)
59+
['m5', codeA, 15], // ipad pro 11-inch (m5, 2025) / ipad pro 13-inch (m5, 2025)
60+
]
61+
: [
62+
// ['a4', 7], // 4 / ipod touch 4th gen
63+
// ['a5', 9], // 4S / ipod touch 5th gen
64+
// ['a6', 10], // 5 / 5C
65+
['a7', codeC, 12], // 5S
66+
['a8', codeB, 12], // 6 / 6 plus / ipod touch 6th gen
67+
['a9', codeB, 15], // 6s / 6s plus/ se 1st gen
68+
['a10', codeB, 15], // 7 / 7 plus / iPod Touch 7th gen
69+
['a11', codeA, 15], // 8 / 8 plus / X
70+
['a12', codeA, 15], // XS / XS Max / XR
71+
['a13', codeA, 15], // 11 / 11 pro / 11 pro max / se 2nd gen
72+
['a14', codeA, 15], // 12 / 12 mini / 12 pro / 12 pro max
73+
['a15', codeA, 15], // 13 / 13 mini / 13 pro / 13 pro max / se 3rd gen / 14 / 14 plus
74+
['a16', codeA, 15], // 14 pro / 14 pro max / 15 / 15 plus
75+
['a17 pro', codeA, 15], // 15 pro / 15 pro max
76+
['a18', codeA, 15], // iphone 16 / iphone 16 plus / iphone 16e
77+
['a18 pro', codeA, 15], // iphone 16 pro / iphone 16 pro max
78+
['a19', codeA, 15], // iphone 17
79+
['a19 pro', codeA, 15], // iphone 17 air / iphone 17 pro / iphone 17 pro max
80+
];
81+
// On iOS 14+ the pixel ID was normalized by Apple and only tells us
82+
// "iOS 14+" — which the UA-based iOSVersion already does, without a
83+
// WebGL draw call. Skip the shader when we can.
84+
const skipShader =
85+
deviceInfo?.iOSVersion !== undefined && deviceInfo.iOSVersion >= 14;
86+
if (!skipShader) {
87+
const pixelId = calculateMagicPixelId(gl);
88+
if (pixelId === codeFB) {
89+
chipsets = chipsets.filter(([, , iosVersion]) => iosVersion >= 14);
90+
} else {
91+
const matched = chipsets.filter(([, id]) => id === pixelId);
92+
if (matched.length) chipsets = matched;
8793
}
8894
}
95+
chipsets = filterByIOSVersion(
96+
chipsets,
97+
deviceInfo?.iOSVersion,
98+
!!deviceInfo?.isIpad
99+
);
89100
const renderers = chipsets.map(([gpu]) => `apple ${gpu} gpu`);
90101
debug?.(
91102
`iOS 12.2+ obfuscates its GPU type and version, using closest matches: ${JSON.stringify(
@@ -95,6 +106,36 @@ export function deobfuscateAppleGPU(
95106
return renderers;
96107
}
97108

109+
const IOS_CHIP_CUTOFFS = [
110+
{ minIOS: 13, iphone: 9, ipad: 8 },
111+
{ minIOS: 16, iphone: 10, ipad: 9 },
112+
{ minIOS: 17, iphone: 12, ipad: 10 },
113+
{ minIOS: 26, iphone: 13, ipad: 12 },
114+
];
115+
116+
function minimumAChip(iOSVersion: number, isIpad: boolean): number {
117+
const cutoff = IOS_CHIP_CUTOFFS.findLast((c) => iOSVersion >= c.minIOS);
118+
return cutoff ? (isIpad ? cutoff.ipad : cutoff.iphone) : 0;
119+
}
120+
121+
// Drop A-series chipsets that cannot run the detected iOS major. M-series
122+
// chips (2021+) and any unrecognized prefix are preserved.
123+
/** @internal — exported for test access only. */
124+
export function filterByIOSVersion<T extends readonly [string, ...unknown[]]>(
125+
chipsets: T[],
126+
iOSVersion: number | undefined,
127+
isIpad: boolean
128+
): T[] {
129+
if (!iOSVersion) return chipsets;
130+
const minAChip = minimumAChip(iOSVersion, isIpad);
131+
if (!minAChip) return chipsets;
132+
return chipsets.filter(([gpu]) => {
133+
if (gpu.startsWith('m')) return true;
134+
const match = /^a(\d+)/.exec(gpu);
135+
return match ? parseInt(match[1], 10) >= minAChip : true;
136+
});
137+
}
138+
98139
// Apple GPU (iOS 12.2+, Safari 14+)
99140
// SEE: https://github.com/pmndrs/detect-gpu/issues/7
100141
// CREDIT: https://medium.com/@Samsy/detecting-apple-a10-iphone-7-to-a11-iphone-8-and-b019b8f0eb87

src/internal/deviceInfo.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,30 @@
11
import { isSSR } from './ssr';
22

3+
// iOS 26+ freezes the legacy "iPhone OS 18_6" token as a fingerprinting
4+
// countermeasure, so we prefer the Safari "Version/" token whenever it
5+
// disagrees upward — except on pre-iOS-7 UAs where Version/ tracked Safari
6+
// (then 4 or 5), not iOS. Returns undefined for in-app browsers that expose
7+
// the version only in proprietary tokens (FBSV, etc.).
8+
//
9+
// `isAppleMobile` must be true only for iPhone/iPod/iPad UAs (including the
10+
// iPadOS-as-MacIntel masquerade). On those UAs Safari's Version/ tracks the
11+
// iOS/iPadOS major; on real macOS it tracks the macOS major and would yield
12+
// a wrong number — the gate is the only thing keeping that out.
13+
export function parseIOSVersion(
14+
userAgent: string,
15+
isAppleMobile: boolean
16+
): number | undefined {
17+
if (!isAppleMobile) return undefined;
18+
const osMatch = /(?:iPhone|CPU) OS (\d+)[._ ;)]/.exec(userAgent);
19+
const versionMatch = /Version\/(\d+)/.exec(userAgent);
20+
const os = osMatch ? parseInt(osMatch[1], 10) : undefined;
21+
const version = versionMatch ? parseInt(versionMatch[1], 10) : undefined;
22+
if (os !== undefined && version !== undefined) {
23+
return os >= 7 && version > os ? version : os;
24+
}
25+
return os ?? version;
26+
}
27+
328
export const deviceInfo = (() => {
429
if (isSSR) {
530
return;
@@ -19,10 +44,13 @@ export const deviceInfo = (() => {
1944

2045
const isAndroid = /android/i.test(userAgent);
2146

47+
const iOSVersion = parseIOSVersion(userAgent, isIOS || isIpad);
48+
2249
return {
2350
isIpad,
2451
isMobile: isAndroid || isIOS || isIpad,
2552
isSafari12: /Version\/12.+Safari/.test(userAgent),
2653
isFirefox: /Firefox/.test(userAgent),
54+
iOSVersion,
2755
};
2856
})();

0 commit comments

Comments
 (0)