Skip to content

Commit d576de6

Browse files
committed
feat(ios): UA-aware chipset filter and A17 Pro / iPad additions
Builds on the chip-list additions (A18/A18 Pro/A19/A19 Pro; M3/M4/M5) with four changes: 1. **A17 Pro rename + missing iPad chips.** Apple only ever shipped A17 Pro, so the previous iPhone `a17` entry emitted `apple a17 gpu` which never matched any benchmark row. Rename to `a17 pro`. Also add A16 (2025 iPad Air / iPad 11) and A17 Pro (iPad mini 7) to the iPad chipset list. Drop FALLBACK test cases for A19 / A19 Pro / M5 — all three are now present in the benchmark JSON and correctly resolve to BENCHMARK. 2. **UA-aware iOS version parsing and chipset filter.** parseIOSVersion extracts the iOS major from the UA. The deobfuscator then drops A-series chips the device cannot run, narrowing the candidate pool before benchmark matching: iOS/iPadOS 13-15 iPhone A9+ iPad A8+ iOS/iPadOS 16 iPhone A10+ iPad A9+ iOS/iPadOS 17-18 iPhone A12+ iPad A10+ iOS/iPadOS 26 iPhone A13+ iPad A12+ (Neural Engine) Two non-obvious UA quirks are handled: - iOS 26+ freezes the legacy `iPhone OS 18_6` token for fingerprinting reduction; the true OS major is exposed only via Safari's `Version/N` token. parseIOSVersion prefers Version/ whenever it disagrees upward with the OS token. - Pre-iOS-7 Safari `Version/` tracked Safari (then 4 or 5), not iOS. Falls back to the OS token when OS < 7. - iPadOS "Request Desktop Site" UAs (`Macintosh; Intel Mac OS X`) still resolve because `isIpad` is true via the `MacIntel + maxTouchPoints > 0` detection. - In-app browsers (Facebook, Slack, Snapchat) hide the iOS version in proprietary tokens. They yield `undefined`, making the filter a safe no-op — preferred over guessing wrong. 3. **Skip the pixel-ID shader on iOS 14+.** Apple normalized the shader output in iOS 14, so every modern device returns the same `codeFB` — which the pre-existing code was using purely as an "is iOS 14+" signal. The UA now provides that directly, without the shader compile + draw + readPixels round-trip. The shader path is retained as the fallback for iOS 12.x / 13.x devices where the three distinct pixel IDs still discriminate A7 vs A8-A10 vs A11+ within a single iOS major. 4. **Tests.** 24 new tests: - `test/deobfuscateAppleGPU.test.ts` — 11 unit tests for filterByIOSVersion, covering every cutoff and the M-series passthrough. - `test/deviceInfo.test.ts` — 6 UA-shape tests for parseIOSVersion via a stubbed navigator. - `test/iosUserAgents.test.ts` — runs parseIOSVersion against a 129-entry iOS UA corpus (iOS 1-18) sourced from ua-parser/ uap-core's test_os.yaml, plus explicit pre-iOS-7 / iOS 26 freeze / separator-variant tests. Asserts the parser never returns a wrong major and recognizes 100% of UAs with one of the two standard tokens.
1 parent 700b0f1 commit d576de6

7 files changed

Lines changed: 990 additions & 76 deletions

File tree

src/internal/deobfuscateAppleGPU.ts

Lines changed: 94 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -25,74 +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-
['m3', codeA, 15], // ipad air 11-inch (m3, 2025) / ipad air 13-inch (m3, 2025)
60-
['m4', codeA, 15], // ipad pro 11-inch (m4, 2024) / ipad pro 13-inch (m4, 2024)
61-
['m5', codeA, 15], // ipad pro 11-inch (m5, 2025) / ipad pro 13-inch (m5, 2025)
62-
]
63-
: [
64-
// ['a4', 7], // 4 / ipod touch 4th gen
65-
// ['a5', 9], // 4S / ipod touch 5th gen
66-
// ['a6', 10], // 5 / 5C
67-
['a7', codeC, 12], // 5S
68-
['a8', codeB, 12], // 6 / 6 plus / ipod touch 6th gen
69-
['a9', codeB, 15], // 6s / 6s plus/ se 1st gen
70-
['a10', codeB, 15], // 7 / 7 plus / iPod Touch 7th gen
71-
['a11', codeA, 15], // 8 / 8 plus / X
72-
['a12', codeA, 15], // XS / XS Max / XR
73-
['a13', codeA, 15], // 11 / 11 pro / 11 pro max / se 2nd gen
74-
['a14', codeA, 15], // 12 / 12 mini / 12 pro / 12 pro max
75-
['a15', codeA, 15], // 13 / 13 mini / 13 pro / 13 pro max / se 3rd gen / 14 / 14 plus
76-
['a16', codeA, 15], // 14 pro / 14 pro max / 15 / 15 plus
77-
['a17', codeA, 15], // 15 pro / 15 pro max
78-
['a18', codeA, 15], // iphone 16 / iphone 16 plus / iphone 16e
79-
['a18 pro', codeA, 15], // iphone 16 pro / iphone 16 pro max
80-
['a19', codeA, 15], // iphone 17
81-
['a19 pro', codeA, 15], // iphone 17 air / iphone 17 pro / iphone 17 pro max
82-
];
83-
let chipsets: typeof possibleChipsets;
84-
85-
// In iOS 14.x Apple started normalizing the outcome of this hack,
86-
// we use this fact to limit the list to devices that support ios 14+
87-
if (pixelId === codeFB) {
88-
chipsets = possibleChipsets.filter(([, , iosVersion]) => iosVersion >= 14);
89-
} else {
90-
chipsets = possibleChipsets.filter(([, id]) => id === pixelId);
91-
// If nothing was found to match the pixel id, include all chipsets:
92-
if (!chipsets.length) {
93-
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;
9493
}
9594
}
95+
chipsets = filterByIOSVersion(
96+
chipsets,
97+
deviceInfo?.iOSVersion,
98+
!!deviceInfo?.isIpad
99+
);
96100
const renderers = chipsets.map(([gpu]) => `apple ${gpu} gpu`);
97101
debug?.(
98102
`iOS 12.2+ obfuscates its GPU type and version, using closest matches: ${JSON.stringify(
@@ -102,6 +106,36 @@ export function deobfuscateAppleGPU(
102106
return renderers;
103107
}
104108

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+
105139
// Apple GPU (iOS 12.2+, Safari 14+)
106140
// SEE: https://github.com/pmndrs/detect-gpu/issues/7
107141
// 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
})();

test/deobfuscateAppleGPU.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { describe, expect, test } from 'vitest';
2+
3+
import { filterByIOSVersion } from '../src/internal/deobfuscateAppleGPU';
4+
5+
// Minimal shape matching the possibleChipsets tuple in deobfuscateAppleGPU.ts.
6+
// The filter only reads the first element (gpu name) so the rest is ignored.
7+
type Chipset = readonly [string, unknown, number];
8+
9+
const iphoneChipsets: Chipset[] = [
10+
['a7', 'code', 12],
11+
['a8', 'code', 12],
12+
['a9', 'code', 15],
13+
['a10', 'code', 15],
14+
['a11', 'code', 15],
15+
['a12', 'code', 15],
16+
['a13', 'code', 15],
17+
['a14', 'code', 15],
18+
['a15', 'code', 15],
19+
['a16', 'code', 15],
20+
['a17 pro', 'code', 15],
21+
['a18', 'code', 15],
22+
['a18 pro', 'code', 15],
23+
['a19', 'code', 15],
24+
['a19 pro', 'code', 15],
25+
];
26+
27+
const ipadChipsets: Chipset[] = [
28+
['a7', 'code', 12],
29+
['a8', 'code', 15],
30+
['a8x', 'code', 15],
31+
['a9', 'code', 15],
32+
['a9x', 'code', 15],
33+
['a10', 'code', 15],
34+
['a10x', 'code', 15],
35+
['a12', 'code', 15],
36+
['a12x', 'code', 15],
37+
['a12z', 'code', 15],
38+
['a14', 'code', 15],
39+
['a15', 'code', 15],
40+
['a16', 'code', 15],
41+
['a17 pro', 'code', 15],
42+
['m1', 'code', 15],
43+
['m2', 'code', 15],
44+
['m3', 'code', 15],
45+
['m4', 'code', 15],
46+
['m5', 'code', 15],
47+
];
48+
49+
const names = (chipsets: Chipset[]) => chipsets.map(([gpu]) => gpu);
50+
51+
describe('filterByIOSVersion', () => {
52+
test('returns every chipset when iOS version is unknown', () => {
53+
expect(filterByIOSVersion(iphoneChipsets, undefined, false)).toEqual(
54+
iphoneChipsets
55+
);
56+
expect(filterByIOSVersion(ipadChipsets, undefined, true)).toEqual(
57+
ipadChipsets
58+
);
59+
});
60+
61+
test('applies no cutoff below iOS 13', () => {
62+
expect(names(filterByIOSVersion(iphoneChipsets, 12, false))).toEqual(
63+
names(iphoneChipsets)
64+
);
65+
});
66+
67+
test('iOS 13 drops A8 and below on iPhone (requires A9+)', () => {
68+
const result = names(filterByIOSVersion(iphoneChipsets, 13, false));
69+
expect(result).not.toContain('a7');
70+
expect(result).not.toContain('a8');
71+
expect(result[0]).toBe('a9');
72+
});
73+
74+
test('iOS 16 drops A9 and below on iPhone (requires A10+)', () => {
75+
const result = names(filterByIOSVersion(iphoneChipsets, 16, false));
76+
expect(result).not.toContain('a9');
77+
expect(result[0]).toBe('a10');
78+
});
79+
80+
test('iOS 17 drops A11 and below on iPhone (requires A12+)', () => {
81+
const result = names(filterByIOSVersion(iphoneChipsets, 17, false));
82+
expect(result).not.toContain('a10');
83+
expect(result).not.toContain('a11');
84+
expect(result[0]).toBe('a12');
85+
});
86+
87+
test('iOS 18 uses the same cutoff as iOS 17', () => {
88+
expect(names(filterByIOSVersion(iphoneChipsets, 18, false))).toEqual(
89+
names(filterByIOSVersion(iphoneChipsets, 17, false))
90+
);
91+
});
92+
93+
test('iPadOS 16 drops A8x and below on iPad (requires A9+)', () => {
94+
const result = names(filterByIOSVersion(ipadChipsets, 16, true));
95+
expect(result).not.toContain('a8');
96+
expect(result).not.toContain('a8x');
97+
expect(result[0]).toBe('a9');
98+
});
99+
100+
test('iPadOS 17 drops A9x and below on iPad (requires A10+)', () => {
101+
const result = names(filterByIOSVersion(ipadChipsets, 17, true));
102+
expect(result).not.toContain('a9');
103+
expect(result).not.toContain('a9x');
104+
expect(result[0]).toBe('a10');
105+
});
106+
107+
test('iOS 26 drops A12 and below on iPhone (requires A13+)', () => {
108+
const result = names(filterByIOSVersion(iphoneChipsets, 26, false));
109+
expect(result).toEqual([
110+
'a13',
111+
'a14',
112+
'a15',
113+
'a16',
114+
'a17 pro',
115+
'a18',
116+
'a18 pro',
117+
'a19',
118+
'a19 pro',
119+
]);
120+
});
121+
122+
test('iPadOS 26 drops A10 and below on iPad (requires A12+/Neural Engine)', () => {
123+
const result = names(filterByIOSVersion(ipadChipsets, 26, true));
124+
expect(result).toEqual([
125+
'a12',
126+
'a12x',
127+
'a12z',
128+
'a14',
129+
'a15',
130+
'a16',
131+
'a17 pro',
132+
'm1',
133+
'm2',
134+
'm3',
135+
'm4',
136+
'm5',
137+
]);
138+
});
139+
140+
test('all M-series chips pass every iOS cutoff', () => {
141+
const mOnly: Chipset[] = [
142+
['m1', 'code', 15],
143+
['m2', 'code', 15],
144+
['m3', 'code', 15],
145+
['m4', 'code', 15],
146+
['m5', 'code', 15],
147+
];
148+
expect(names(filterByIOSVersion(mOnly, 26, true))).toEqual(names(mOnly));
149+
expect(names(filterByIOSVersion(mOnly, 99, true))).toEqual(names(mOnly));
150+
});
151+
});

0 commit comments

Comments
 (0)