preact-missing-hooks 4.8.0 → 4.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Readme.md +88 -34
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.modern.mjs +1 -1
- package/dist/index.modern.mjs.map +1 -1
- package/dist/index.module.js +1 -1
- package/dist/index.module.js.map +1 -1
- package/dist/index.umd.js +1 -1
- package/dist/index.umd.js.map +1 -1
- package/dist/react.js +153 -6
- package/dist/useDeviceData.d.ts +26 -2
- package/docs/README.md +1 -1
- package/docs/main.js +25 -7
- package/package.json +1 -1
- package/src/useDeviceData.ts +199 -6
- package/tests/useDeviceData.test.tsx +129 -26
package/dist/react.js
CHANGED
|
@@ -2161,11 +2161,15 @@ function usePoll(pollFn, options = {}) {
|
|
|
2161
2161
|
return { data, done, error, pollCount, start, stop };
|
|
2162
2162
|
}
|
|
2163
2163
|
|
|
2164
|
+
const UNKNOWN_BROWSER = { name: "Unknown", version: "" };
|
|
2165
|
+
const UNKNOWN_OS = { name: "Unknown", version: "" };
|
|
2164
2166
|
const SSR_DEVICE_DATA = {
|
|
2165
2167
|
userAgent: "",
|
|
2166
2168
|
language: "en",
|
|
2167
2169
|
languages: ["en"],
|
|
2168
2170
|
platform: "",
|
|
2171
|
+
browser: UNKNOWN_BROWSER,
|
|
2172
|
+
os: UNKNOWN_OS,
|
|
2169
2173
|
cookieEnabled: false,
|
|
2170
2174
|
online: true,
|
|
2171
2175
|
maxTouchPoints: 0,
|
|
@@ -2183,6 +2187,95 @@ const SSR_DEVICE_DATA = {
|
|
|
2183
2187
|
reducedMotion: false,
|
|
2184
2188
|
colorScheme: "no-preference",
|
|
2185
2189
|
};
|
|
2190
|
+
const NOT_A_BRAND = /not.?a.?brand/i;
|
|
2191
|
+
/** Parse browser and OS from a user-agent string (sync fallback). */
|
|
2192
|
+
function parseUserAgent(userAgent) {
|
|
2193
|
+
var _a;
|
|
2194
|
+
const browser = Object.assign({}, UNKNOWN_BROWSER);
|
|
2195
|
+
const os = Object.assign({}, UNKNOWN_OS);
|
|
2196
|
+
if (!userAgent) {
|
|
2197
|
+
return { browser, os };
|
|
2198
|
+
}
|
|
2199
|
+
const win = userAgent.match(/Windows NT ([\d.]+)/);
|
|
2200
|
+
if (win) {
|
|
2201
|
+
os.name = "Windows";
|
|
2202
|
+
os.version = win[1];
|
|
2203
|
+
}
|
|
2204
|
+
else {
|
|
2205
|
+
const mac = userAgent.match(/Mac OS X ([\d._]+)/);
|
|
2206
|
+
if (mac) {
|
|
2207
|
+
os.name = "macOS";
|
|
2208
|
+
os.version = mac[1].replace(/_/g, ".");
|
|
2209
|
+
}
|
|
2210
|
+
else {
|
|
2211
|
+
const android = userAgent.match(/Android ([\d.]+)/);
|
|
2212
|
+
if (android) {
|
|
2213
|
+
os.name = "Android";
|
|
2214
|
+
os.version = android[1];
|
|
2215
|
+
}
|
|
2216
|
+
else {
|
|
2217
|
+
const ios = userAgent.match(/(?:iPhone OS|CPU OS) ([\d_]+)/);
|
|
2218
|
+
if (ios) {
|
|
2219
|
+
os.name = "iOS";
|
|
2220
|
+
os.version = ios[1].replace(/_/g, ".");
|
|
2221
|
+
}
|
|
2222
|
+
else if (/Linux/.test(userAgent)) {
|
|
2223
|
+
os.name = "Linux";
|
|
2224
|
+
const linux = userAgent.match(/Linux ([\d.]+)/);
|
|
2225
|
+
os.version = (_a = linux === null || linux === void 0 ? void 0 : linux[1]) !== null && _a !== void 0 ? _a : "";
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
const edg = userAgent.match(/Edg(?:A|iOS)?\/([\d.]+)/);
|
|
2231
|
+
const opera = userAgent.match(/OPR\/([\d.]+)/);
|
|
2232
|
+
const firefox = userAgent.match(/Firefox\/([\d.]+)/);
|
|
2233
|
+
const safari = /Version\/([\d.]+)/.test(userAgent) &&
|
|
2234
|
+
/Safari/.test(userAgent) &&
|
|
2235
|
+
!/Chrome|Chromium|Edg|OPR/.test(userAgent)
|
|
2236
|
+
? userAgent.match(/Version\/([\d.]+)/)
|
|
2237
|
+
: null;
|
|
2238
|
+
const chrome = userAgent.match(/Chrome\/([\d.]+)/);
|
|
2239
|
+
if (edg) {
|
|
2240
|
+
browser.name = "Edge";
|
|
2241
|
+
browser.version = edg[1];
|
|
2242
|
+
}
|
|
2243
|
+
else if (opera) {
|
|
2244
|
+
browser.name = "Opera";
|
|
2245
|
+
browser.version = opera[1];
|
|
2246
|
+
}
|
|
2247
|
+
else if (firefox) {
|
|
2248
|
+
browser.name = "Firefox";
|
|
2249
|
+
browser.version = firefox[1];
|
|
2250
|
+
}
|
|
2251
|
+
else if (safari) {
|
|
2252
|
+
browser.name = "Safari";
|
|
2253
|
+
browser.version = safari[1];
|
|
2254
|
+
}
|
|
2255
|
+
else if (chrome) {
|
|
2256
|
+
browser.name = "Chrome";
|
|
2257
|
+
browser.version = chrome[1];
|
|
2258
|
+
}
|
|
2259
|
+
return { browser, os };
|
|
2260
|
+
}
|
|
2261
|
+
function pickBrowserBrand(brands) {
|
|
2262
|
+
const meaningful = brands.find((b) => !NOT_A_BRAND.test(b.brand));
|
|
2263
|
+
const pick = meaningful !== null && meaningful !== void 0 ? meaningful : brands[0];
|
|
2264
|
+
if (!pick)
|
|
2265
|
+
return Object.assign({}, UNKNOWN_BROWSER);
|
|
2266
|
+
return { name: pick.brand, version: pick.version };
|
|
2267
|
+
}
|
|
2268
|
+
function mergeBrowserOsFromUaData(uaData, fallback) {
|
|
2269
|
+
var _a, _b;
|
|
2270
|
+
const browser = ((_a = uaData.brands) === null || _a === void 0 ? void 0 : _a.length)
|
|
2271
|
+
? pickBrowserBrand(uaData.brands)
|
|
2272
|
+
: Object.assign({}, fallback.browser);
|
|
2273
|
+
const os = {
|
|
2274
|
+
name: ((_b = uaData.platform) === null || _b === void 0 ? void 0 : _b.trim()) || fallback.os.name,
|
|
2275
|
+
version: fallback.os.version,
|
|
2276
|
+
};
|
|
2277
|
+
return { browser, os };
|
|
2278
|
+
}
|
|
2186
2279
|
function getColorScheme() {
|
|
2187
2280
|
if (typeof window === "undefined" || !window.matchMedia) {
|
|
2188
2281
|
return "no-preference";
|
|
@@ -2210,11 +2303,19 @@ function getDeviceData() {
|
|
|
2210
2303
|
const nav = navigator;
|
|
2211
2304
|
const screen = typeof globalThis.screen !== "undefined" ? globalThis.screen : null;
|
|
2212
2305
|
const win = typeof globalThis.window !== "undefined" ? globalThis.window : null;
|
|
2306
|
+
const ua = (_a = nav.userAgent) !== null && _a !== void 0 ? _a : "";
|
|
2307
|
+
const parsed = parseUserAgent(ua);
|
|
2308
|
+
const uaData = nav.userAgentData;
|
|
2309
|
+
const { browser, os } = uaData
|
|
2310
|
+
? mergeBrowserOsFromUaData(uaData, parsed)
|
|
2311
|
+
: parsed;
|
|
2213
2312
|
const data = {
|
|
2214
|
-
userAgent:
|
|
2313
|
+
userAgent: ua,
|
|
2215
2314
|
language: (_b = nav.language) !== null && _b !== void 0 ? _b : "",
|
|
2216
2315
|
languages: nav.languages ? [...nav.languages] : [],
|
|
2217
2316
|
platform: (_c = nav.platform) !== null && _c !== void 0 ? _c : "",
|
|
2317
|
+
browser,
|
|
2318
|
+
os,
|
|
2218
2319
|
cookieEnabled: Boolean(nav.cookieEnabled),
|
|
2219
2320
|
online: Boolean(nav.onLine),
|
|
2220
2321
|
maxTouchPoints: (_d = nav.maxTouchPoints) !== null && _d !== void 0 ? _d : 0,
|
|
@@ -2241,7 +2342,6 @@ function getDeviceData() {
|
|
|
2241
2342
|
if (typeof nav.deviceMemory === "number") {
|
|
2242
2343
|
data.deviceMemory = nav.deviceMemory;
|
|
2243
2344
|
}
|
|
2244
|
-
const uaData = nav.userAgentData;
|
|
2245
2345
|
if (uaData) {
|
|
2246
2346
|
data.userAgentData = {
|
|
2247
2347
|
mobile: Boolean(uaData.mobile),
|
|
@@ -2251,6 +2351,30 @@ function getDeviceData() {
|
|
|
2251
2351
|
}
|
|
2252
2352
|
return data;
|
|
2253
2353
|
}
|
|
2354
|
+
function readHighEntropyBrowserOs(uaData, current) {
|
|
2355
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
2356
|
+
var _a, _b;
|
|
2357
|
+
if (!uaData.getHighEntropyValues)
|
|
2358
|
+
return undefined;
|
|
2359
|
+
try {
|
|
2360
|
+
const hints = yield uaData.getHighEntropyValues([
|
|
2361
|
+
"platformVersion",
|
|
2362
|
+
"fullVersionList",
|
|
2363
|
+
]);
|
|
2364
|
+
const browser = ((_a = hints.fullVersionList) === null || _a === void 0 ? void 0 : _a.length)
|
|
2365
|
+
? pickBrowserBrand(hints.fullVersionList)
|
|
2366
|
+
: Object.assign({}, current.browser);
|
|
2367
|
+
const os = {
|
|
2368
|
+
name: current.os.name,
|
|
2369
|
+
version: ((_b = hints.platformVersion) === null || _b === void 0 ? void 0 : _b.trim()) || current.os.version,
|
|
2370
|
+
};
|
|
2371
|
+
return { browser, os };
|
|
2372
|
+
}
|
|
2373
|
+
catch (_c) {
|
|
2374
|
+
return undefined;
|
|
2375
|
+
}
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2254
2378
|
function readBattery() {
|
|
2255
2379
|
return __awaiter(this, void 0, void 0, function* () {
|
|
2256
2380
|
if (typeof navigator === "undefined")
|
|
@@ -2282,22 +2406,22 @@ function readBattery() {
|
|
|
2282
2406
|
* const device = useDeviceData();
|
|
2283
2407
|
* return (
|
|
2284
2408
|
* <dl>
|
|
2409
|
+
* <dt>Browser</dt><dd>{device.browser.name} {device.browser.version}</dd>
|
|
2410
|
+
* <dt>OS</dt><dd>{device.os.name} {device.os.version}</dd>
|
|
2285
2411
|
* <dt>Language</dt><dd>{device.language}</dd>
|
|
2286
|
-
* <dt>CPUs</dt><dd>{device.hardwareConcurrency ?? '—'}</dd>
|
|
2287
2412
|
* <dt>Viewport</dt><dd>{device.viewport.width}×{device.viewport.height}</dd>
|
|
2288
|
-
* <dt>Theme</dt><dd>{device.colorScheme}</dd>
|
|
2289
2413
|
* </dl>
|
|
2290
2414
|
* );
|
|
2291
2415
|
* }
|
|
2292
2416
|
* ```
|
|
2293
2417
|
*/
|
|
2294
2418
|
function useDeviceData(options = {}) {
|
|
2295
|
-
const { includeBattery = true, batteryPollIntervalMs = 60000 } = options;
|
|
2419
|
+
const { includeBattery = true, batteryPollIntervalMs = 60000, includeHighEntropy = true, } = options;
|
|
2296
2420
|
const [data, setData] = react.useState(() => getDeviceData());
|
|
2297
2421
|
const refresh = react.useCallback(() => {
|
|
2298
2422
|
setData((prev) => {
|
|
2299
2423
|
const next = getDeviceData();
|
|
2300
|
-
return
|
|
2424
|
+
return Object.assign(Object.assign({}, next), (prev.battery ? { battery: prev.battery } : {}));
|
|
2301
2425
|
});
|
|
2302
2426
|
}, []);
|
|
2303
2427
|
react.useEffect(() => {
|
|
@@ -2327,6 +2451,28 @@ function useDeviceData(options = {}) {
|
|
|
2327
2451
|
(_c = lightMq === null || lightMq === void 0 ? void 0 : lightMq.removeEventListener) === null || _c === void 0 ? void 0 : _c.call(lightMq, "change", onMediaChange);
|
|
2328
2452
|
};
|
|
2329
2453
|
}, [refresh]);
|
|
2454
|
+
react.useEffect(() => {
|
|
2455
|
+
if (!includeHighEntropy || typeof navigator === "undefined")
|
|
2456
|
+
return;
|
|
2457
|
+
const uaData = navigator.userAgentData;
|
|
2458
|
+
if (!(uaData === null || uaData === void 0 ? void 0 : uaData.getHighEntropyValues))
|
|
2459
|
+
return;
|
|
2460
|
+
let cancelled = false;
|
|
2461
|
+
const updateHighEntropy = () => __awaiter(this, void 0, void 0, function* () {
|
|
2462
|
+
const base = getDeviceData();
|
|
2463
|
+
const enriched = yield readHighEntropyBrowserOs(uaData, {
|
|
2464
|
+
browser: base.browser,
|
|
2465
|
+
os: base.os,
|
|
2466
|
+
});
|
|
2467
|
+
if (cancelled || !enriched)
|
|
2468
|
+
return;
|
|
2469
|
+
setData((prev) => (Object.assign(Object.assign({}, prev), { browser: enriched.browser, os: enriched.os })));
|
|
2470
|
+
});
|
|
2471
|
+
void updateHighEntropy();
|
|
2472
|
+
return () => {
|
|
2473
|
+
cancelled = true;
|
|
2474
|
+
};
|
|
2475
|
+
}, [includeHighEntropy]);
|
|
2330
2476
|
react.useEffect(() => {
|
|
2331
2477
|
if (!includeBattery || typeof navigator === "undefined")
|
|
2332
2478
|
return;
|
|
@@ -2352,6 +2498,7 @@ function useDeviceData(options = {}) {
|
|
|
2352
2498
|
}
|
|
2353
2499
|
|
|
2354
2500
|
exports.getDeviceData = getDeviceData;
|
|
2501
|
+
exports.parseUserAgent = parseUserAgent;
|
|
2355
2502
|
exports.useClipboard = useClipboard;
|
|
2356
2503
|
exports.useDeviceData = useDeviceData;
|
|
2357
2504
|
exports.useEventBus = useEventBus;
|
package/dist/useDeviceData.d.ts
CHANGED
|
@@ -26,12 +26,26 @@ export interface DeviceBatteryInfo {
|
|
|
26
26
|
charging: boolean;
|
|
27
27
|
level: number;
|
|
28
28
|
}
|
|
29
|
+
/** Detected browser name and version */
|
|
30
|
+
export interface DeviceBrowserInfo {
|
|
31
|
+
name: string;
|
|
32
|
+
version: string;
|
|
33
|
+
}
|
|
34
|
+
/** Detected operating system name and version */
|
|
35
|
+
export interface DeviceOsInfo {
|
|
36
|
+
name: string;
|
|
37
|
+
version: string;
|
|
38
|
+
}
|
|
29
39
|
/** Snapshot of device / browser data from native Navigator and related APIs */
|
|
30
40
|
export interface DeviceData {
|
|
31
41
|
userAgent: string;
|
|
32
42
|
language: string;
|
|
33
43
|
languages: readonly string[];
|
|
34
44
|
platform: string;
|
|
45
|
+
/** Detected browser (from Client Hints or user-agent parsing) */
|
|
46
|
+
browser: DeviceBrowserInfo;
|
|
47
|
+
/** Detected OS (from Client Hints or user-agent parsing) */
|
|
48
|
+
os: DeviceOsInfo;
|
|
35
49
|
cookieEnabled: boolean;
|
|
36
50
|
online: boolean;
|
|
37
51
|
hardwareConcurrency?: number;
|
|
@@ -52,7 +66,17 @@ export interface UseDeviceDataOptions {
|
|
|
52
66
|
includeBattery?: boolean;
|
|
53
67
|
/** Battery refresh interval in ms (default: 60000) */
|
|
54
68
|
batteryPollIntervalMs?: number;
|
|
69
|
+
/**
|
|
70
|
+
* Request high-entropy Client Hints (`platformVersion`, `fullVersionList`)
|
|
71
|
+
* when `navigator.userAgentData` supports it (default: true)
|
|
72
|
+
*/
|
|
73
|
+
includeHighEntropy?: boolean;
|
|
55
74
|
}
|
|
75
|
+
/** Parse browser and OS from a user-agent string (sync fallback). */
|
|
76
|
+
export declare function parseUserAgent(userAgent: string): {
|
|
77
|
+
browser: DeviceBrowserInfo;
|
|
78
|
+
os: DeviceOsInfo;
|
|
79
|
+
};
|
|
56
80
|
/** Reads synchronous device / browser data from Navigator, Screen, and matchMedia. */
|
|
57
81
|
export declare function getDeviceData(): DeviceData;
|
|
58
82
|
/**
|
|
@@ -70,10 +94,10 @@ export declare function getDeviceData(): DeviceData;
|
|
|
70
94
|
* const device = useDeviceData();
|
|
71
95
|
* return (
|
|
72
96
|
* <dl>
|
|
97
|
+
* <dt>Browser</dt><dd>{device.browser.name} {device.browser.version}</dd>
|
|
98
|
+
* <dt>OS</dt><dd>{device.os.name} {device.os.version}</dd>
|
|
73
99
|
* <dt>Language</dt><dd>{device.language}</dd>
|
|
74
|
-
* <dt>CPUs</dt><dd>{device.hardwareConcurrency ?? '—'}</dd>
|
|
75
100
|
* <dt>Viewport</dt><dd>{device.viewport.width}×{device.viewport.height}</dd>
|
|
76
|
-
* <dt>Theme</dt><dd>{device.colorScheme}</dd>
|
|
77
101
|
* </dl>
|
|
78
102
|
* );
|
|
79
103
|
* }
|
package/docs/README.md
CHANGED
|
@@ -41,7 +41,7 @@ Open `docs/index.html` in a browser that supports ES modules and import maps. Th
|
|
|
41
41
|
| **useWrappedChildren** | Children buttons get injected styles. |
|
|
42
42
|
| **usePreferredTheme** | Shows light / dark / no-preference from system. |
|
|
43
43
|
| **useNetworkState** | Online/offline and connection type. |
|
|
44
|
-
| **useDeviceData** | Live
|
|
44
|
+
| **useDeviceData** | Live browser/OS name & version (Client Hints + UA parse), language, CPUs, memory, viewport, screen, touch, theme prefs, UA brands, optional battery. |
|
|
45
45
|
| **usePrefetch** | Hover or click to prefetch a URL (document or fetch); see prefetched status. |
|
|
46
46
|
| **usePoll** | Poll until done (3 ticks); see poll count and result. Stop button to cancel. |
|
|
47
47
|
| **useClipboard** | Copy and paste; see “Copied!” and pasted text. |
|
package/docs/main.js
CHANGED
|
@@ -88,21 +88,39 @@ function DemoNetworkState() {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
function DemoDeviceData() {
|
|
91
|
-
const device = useDeviceData({ includeBattery:
|
|
91
|
+
const device = useDeviceData({ includeBattery: true, includeHighEntropy: true });
|
|
92
|
+
const formatBrowser = () =>
|
|
93
|
+
device.browser.name + (device.browser.version ? ' ' + device.browser.version : '');
|
|
94
|
+
const formatOs = () =>
|
|
95
|
+
device.os.name + (device.os.version ? ' ' + device.os.version : '');
|
|
92
96
|
const rows = [
|
|
97
|
+
['Browser', formatBrowser()],
|
|
98
|
+
['OS', formatOs()],
|
|
93
99
|
['Language', device.language],
|
|
94
|
-
['Platform', device.platform ||
|
|
100
|
+
['Platform (navigator)', device.platform || '—'],
|
|
95
101
|
['CPUs', device.hardwareConcurrency != null ? String(device.hardwareConcurrency) : '—'],
|
|
96
102
|
['Memory (GB)', device.deviceMemory != null ? String(device.deviceMemory) : '—'],
|
|
97
103
|
['Viewport', device.viewport.width + '×' + device.viewport.height],
|
|
98
|
-
['Screen', device.screen.width + '×' + device.screen.height],
|
|
104
|
+
['Screen', device.screen.width + '×' + device.screen.height + ' @' + device.screen.pixelRatio + 'x'],
|
|
99
105
|
['Touch', device.touch ? 'yes' : 'no'],
|
|
100
106
|
['Color scheme', device.colorScheme],
|
|
101
107
|
['Reduced motion', device.reducedMotion ? 'yes' : 'no'],
|
|
102
108
|
['Online', device.online ? 'yes' : 'no'],
|
|
103
109
|
];
|
|
104
110
|
if (device.userAgentData?.mobile) {
|
|
105
|
-
rows.push(['Mobile (
|
|
111
|
+
rows.push(['Mobile (Client Hints)', 'yes']);
|
|
112
|
+
}
|
|
113
|
+
if (device.userAgentData?.brands?.length) {
|
|
114
|
+
rows.push([
|
|
115
|
+
'UA brands',
|
|
116
|
+
device.userAgentData.brands.map((b) => b.brand + '/' + b.version).join(', '),
|
|
117
|
+
]);
|
|
118
|
+
}
|
|
119
|
+
if (device.battery) {
|
|
120
|
+
rows.push([
|
|
121
|
+
'Battery',
|
|
122
|
+
Math.round(device.battery.level * 100) + '%' + (device.battery.charging ? ' (charging)' : ''),
|
|
123
|
+
]);
|
|
106
124
|
}
|
|
107
125
|
return h('div', { class: 'status', style: { fontSize: '0.85rem' } },
|
|
108
126
|
rows.map(([label, value]) =>
|
|
@@ -592,9 +610,9 @@ const HOOKS = [
|
|
|
592
610
|
},
|
|
593
611
|
{
|
|
594
612
|
name: 'useDeviceData',
|
|
595
|
-
flow: 'Component → useDeviceData() →
|
|
596
|
-
summary: '
|
|
597
|
-
code: `const device = useDeviceData();\n// device.
|
|
613
|
+
flow: 'Component → useDeviceData() → Client Hints + UA parse → browser/os → Screen + matchMedia (+ battery)',
|
|
614
|
+
summary: 'Device/browser snapshot: browser name & version, OS name & version (Client Hints + UA fallback), language, CPUs, memory, viewport, screen, touch, theme prefs, optional battery.',
|
|
615
|
+
code: `const device = useDeviceData({ includeHighEntropy: true });\n// device.browser.name, device.browser.version\n// device.os.name, device.os.version\n// device.viewport, device.colorScheme, device.battery`,
|
|
598
616
|
Live: DemoDeviceData,
|
|
599
617
|
},
|
|
600
618
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "preact-missing-hooks",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.9.0",
|
|
4
4
|
"description": "A lightweight, extendable collection of missing React-like hooks for Preact — plus fresh, powerful new ones designed specifically for modern Preact apps.",
|
|
5
5
|
"author": "Prakhar Dubey",
|
|
6
6
|
"license": "MIT",
|
package/src/useDeviceData.ts
CHANGED
|
@@ -29,12 +29,28 @@ export interface DeviceBatteryInfo {
|
|
|
29
29
|
level: number;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** Detected browser name and version */
|
|
33
|
+
export interface DeviceBrowserInfo {
|
|
34
|
+
name: string;
|
|
35
|
+
version: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Detected operating system name and version */
|
|
39
|
+
export interface DeviceOsInfo {
|
|
40
|
+
name: string;
|
|
41
|
+
version: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
32
44
|
/** Snapshot of device / browser data from native Navigator and related APIs */
|
|
33
45
|
export interface DeviceData {
|
|
34
46
|
userAgent: string;
|
|
35
47
|
language: string;
|
|
36
48
|
languages: readonly string[];
|
|
37
49
|
platform: string;
|
|
50
|
+
/** Detected browser (from Client Hints or user-agent parsing) */
|
|
51
|
+
browser: DeviceBrowserInfo;
|
|
52
|
+
/** Detected OS (from Client Hints or user-agent parsing) */
|
|
53
|
+
os: DeviceOsInfo;
|
|
38
54
|
cookieEnabled: boolean;
|
|
39
55
|
online: boolean;
|
|
40
56
|
hardwareConcurrency?: number;
|
|
@@ -56,12 +72,21 @@ export interface UseDeviceDataOptions {
|
|
|
56
72
|
includeBattery?: boolean;
|
|
57
73
|
/** Battery refresh interval in ms (default: 60000) */
|
|
58
74
|
batteryPollIntervalMs?: number;
|
|
75
|
+
/**
|
|
76
|
+
* Request high-entropy Client Hints (`platformVersion`, `fullVersionList`)
|
|
77
|
+
* when `navigator.userAgentData` supports it (default: true)
|
|
78
|
+
*/
|
|
79
|
+
includeHighEntropy?: boolean;
|
|
59
80
|
}
|
|
60
81
|
|
|
61
82
|
interface NavigatorUAData {
|
|
62
83
|
mobile?: boolean;
|
|
63
84
|
platform?: string;
|
|
64
85
|
brands?: Array<{ brand: string; version: string }>;
|
|
86
|
+
getHighEntropyValues?: (hints: string[]) => Promise<{
|
|
87
|
+
platformVersion?: string;
|
|
88
|
+
fullVersionList?: Array<{ brand: string; version: string }>;
|
|
89
|
+
}>;
|
|
65
90
|
}
|
|
66
91
|
|
|
67
92
|
type NavigatorWithExtras = Navigator & {
|
|
@@ -69,11 +94,16 @@ type NavigatorWithExtras = Navigator & {
|
|
|
69
94
|
userAgentData?: NavigatorUAData;
|
|
70
95
|
};
|
|
71
96
|
|
|
97
|
+
const UNKNOWN_BROWSER: DeviceBrowserInfo = { name: "Unknown", version: "" };
|
|
98
|
+
const UNKNOWN_OS: DeviceOsInfo = { name: "Unknown", version: "" };
|
|
99
|
+
|
|
72
100
|
const SSR_DEVICE_DATA: DeviceData = {
|
|
73
101
|
userAgent: "",
|
|
74
102
|
language: "en",
|
|
75
103
|
languages: ["en"],
|
|
76
104
|
platform: "",
|
|
105
|
+
browser: UNKNOWN_BROWSER,
|
|
106
|
+
os: UNKNOWN_OS,
|
|
77
107
|
cookieEnabled: false,
|
|
78
108
|
online: true,
|
|
79
109
|
maxTouchPoints: 0,
|
|
@@ -92,6 +122,102 @@ const SSR_DEVICE_DATA: DeviceData = {
|
|
|
92
122
|
colorScheme: "no-preference",
|
|
93
123
|
};
|
|
94
124
|
|
|
125
|
+
const NOT_A_BRAND = /not.?a.?brand/i;
|
|
126
|
+
|
|
127
|
+
/** Parse browser and OS from a user-agent string (sync fallback). */
|
|
128
|
+
export function parseUserAgent(userAgent: string): {
|
|
129
|
+
browser: DeviceBrowserInfo;
|
|
130
|
+
os: DeviceOsInfo;
|
|
131
|
+
} {
|
|
132
|
+
const browser: DeviceBrowserInfo = { ...UNKNOWN_BROWSER };
|
|
133
|
+
const os: DeviceOsInfo = { ...UNKNOWN_OS };
|
|
134
|
+
|
|
135
|
+
if (!userAgent) {
|
|
136
|
+
return { browser, os };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const win = userAgent.match(/Windows NT ([\d.]+)/);
|
|
140
|
+
if (win) {
|
|
141
|
+
os.name = "Windows";
|
|
142
|
+
os.version = win[1];
|
|
143
|
+
} else {
|
|
144
|
+
const mac = userAgent.match(/Mac OS X ([\d._]+)/);
|
|
145
|
+
if (mac) {
|
|
146
|
+
os.name = "macOS";
|
|
147
|
+
os.version = mac[1].replace(/_/g, ".");
|
|
148
|
+
} else {
|
|
149
|
+
const android = userAgent.match(/Android ([\d.]+)/);
|
|
150
|
+
if (android) {
|
|
151
|
+
os.name = "Android";
|
|
152
|
+
os.version = android[1];
|
|
153
|
+
} else {
|
|
154
|
+
const ios = userAgent.match(/(?:iPhone OS|CPU OS) ([\d_]+)/);
|
|
155
|
+
if (ios) {
|
|
156
|
+
os.name = "iOS";
|
|
157
|
+
os.version = ios[1].replace(/_/g, ".");
|
|
158
|
+
} else if (/Linux/.test(userAgent)) {
|
|
159
|
+
os.name = "Linux";
|
|
160
|
+
const linux = userAgent.match(/Linux ([\d.]+)/);
|
|
161
|
+
os.version = linux?.[1] ?? "";
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const edg = userAgent.match(/Edg(?:A|iOS)?\/([\d.]+)/);
|
|
168
|
+
const opera = userAgent.match(/OPR\/([\d.]+)/);
|
|
169
|
+
const firefox = userAgent.match(/Firefox\/([\d.]+)/);
|
|
170
|
+
const safari =
|
|
171
|
+
/Version\/([\d.]+)/.test(userAgent) &&
|
|
172
|
+
/Safari/.test(userAgent) &&
|
|
173
|
+
!/Chrome|Chromium|Edg|OPR/.test(userAgent)
|
|
174
|
+
? userAgent.match(/Version\/([\d.]+)/)
|
|
175
|
+
: null;
|
|
176
|
+
const chrome = userAgent.match(/Chrome\/([\d.]+)/);
|
|
177
|
+
|
|
178
|
+
if (edg) {
|
|
179
|
+
browser.name = "Edge";
|
|
180
|
+
browser.version = edg[1];
|
|
181
|
+
} else if (opera) {
|
|
182
|
+
browser.name = "Opera";
|
|
183
|
+
browser.version = opera[1];
|
|
184
|
+
} else if (firefox) {
|
|
185
|
+
browser.name = "Firefox";
|
|
186
|
+
browser.version = firefox[1];
|
|
187
|
+
} else if (safari) {
|
|
188
|
+
browser.name = "Safari";
|
|
189
|
+
browser.version = safari[1];
|
|
190
|
+
} else if (chrome) {
|
|
191
|
+
browser.name = "Chrome";
|
|
192
|
+
browser.version = chrome[1];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { browser, os };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function pickBrowserBrand(
|
|
199
|
+
brands: Array<{ brand: string; version: string }>
|
|
200
|
+
): DeviceBrowserInfo {
|
|
201
|
+
const meaningful = brands.find((b) => !NOT_A_BRAND.test(b.brand));
|
|
202
|
+
const pick = meaningful ?? brands[0];
|
|
203
|
+
if (!pick) return { ...UNKNOWN_BROWSER };
|
|
204
|
+
return { name: pick.brand, version: pick.version };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function mergeBrowserOsFromUaData(
|
|
208
|
+
uaData: NavigatorUAData,
|
|
209
|
+
fallback: { browser: DeviceBrowserInfo; os: DeviceOsInfo }
|
|
210
|
+
): { browser: DeviceBrowserInfo; os: DeviceOsInfo } {
|
|
211
|
+
const browser = uaData.brands?.length
|
|
212
|
+
? pickBrowserBrand(uaData.brands)
|
|
213
|
+
: { ...fallback.browser };
|
|
214
|
+
const os: DeviceOsInfo = {
|
|
215
|
+
name: uaData.platform?.trim() || fallback.os.name,
|
|
216
|
+
version: fallback.os.version,
|
|
217
|
+
};
|
|
218
|
+
return { browser, os };
|
|
219
|
+
}
|
|
220
|
+
|
|
95
221
|
function getColorScheme(): DeviceData["colorScheme"] {
|
|
96
222
|
if (typeof window === "undefined" || !window.matchMedia) {
|
|
97
223
|
return "no-preference";
|
|
@@ -124,11 +250,20 @@ export function getDeviceData(): DeviceData {
|
|
|
124
250
|
const win =
|
|
125
251
|
typeof globalThis.window !== "undefined" ? globalThis.window : null;
|
|
126
252
|
|
|
253
|
+
const ua = nav.userAgent ?? "";
|
|
254
|
+
const parsed = parseUserAgent(ua);
|
|
255
|
+
const uaData = nav.userAgentData;
|
|
256
|
+
const { browser, os } = uaData
|
|
257
|
+
? mergeBrowserOsFromUaData(uaData, parsed)
|
|
258
|
+
: parsed;
|
|
259
|
+
|
|
127
260
|
const data: DeviceData = {
|
|
128
|
-
userAgent:
|
|
261
|
+
userAgent: ua,
|
|
129
262
|
language: nav.language ?? "",
|
|
130
263
|
languages: nav.languages ? [...nav.languages] : [],
|
|
131
264
|
platform: nav.platform ?? "",
|
|
265
|
+
browser,
|
|
266
|
+
os,
|
|
132
267
|
cookieEnabled: Boolean(nav.cookieEnabled),
|
|
133
268
|
online: Boolean(nav.onLine),
|
|
134
269
|
maxTouchPoints: nav.maxTouchPoints ?? 0,
|
|
@@ -157,7 +292,6 @@ export function getDeviceData(): DeviceData {
|
|
|
157
292
|
data.deviceMemory = nav.deviceMemory;
|
|
158
293
|
}
|
|
159
294
|
|
|
160
|
-
const uaData = nav.userAgentData;
|
|
161
295
|
if (uaData) {
|
|
162
296
|
data.userAgentData = {
|
|
163
297
|
mobile: Boolean(uaData.mobile),
|
|
@@ -169,6 +303,29 @@ export function getDeviceData(): DeviceData {
|
|
|
169
303
|
return data;
|
|
170
304
|
}
|
|
171
305
|
|
|
306
|
+
async function readHighEntropyBrowserOs(
|
|
307
|
+
uaData: NavigatorUAData,
|
|
308
|
+
current: { browser: DeviceBrowserInfo; os: DeviceOsInfo }
|
|
309
|
+
): Promise<{ browser: DeviceBrowserInfo; os: DeviceOsInfo } | undefined> {
|
|
310
|
+
if (!uaData.getHighEntropyValues) return undefined;
|
|
311
|
+
try {
|
|
312
|
+
const hints = await uaData.getHighEntropyValues([
|
|
313
|
+
"platformVersion",
|
|
314
|
+
"fullVersionList",
|
|
315
|
+
]);
|
|
316
|
+
const browser = hints.fullVersionList?.length
|
|
317
|
+
? pickBrowserBrand(hints.fullVersionList)
|
|
318
|
+
: { ...current.browser };
|
|
319
|
+
const os: DeviceOsInfo = {
|
|
320
|
+
name: current.os.name,
|
|
321
|
+
version: hints.platformVersion?.trim() || current.os.version,
|
|
322
|
+
};
|
|
323
|
+
return { browser, os };
|
|
324
|
+
} catch {
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
172
329
|
async function readBattery(): Promise<DeviceBatteryInfo | undefined> {
|
|
173
330
|
if (typeof navigator === "undefined") return undefined;
|
|
174
331
|
const getBattery = (
|
|
@@ -203,24 +360,31 @@ async function readBattery(): Promise<DeviceBatteryInfo | undefined> {
|
|
|
203
360
|
* const device = useDeviceData();
|
|
204
361
|
* return (
|
|
205
362
|
* <dl>
|
|
363
|
+
* <dt>Browser</dt><dd>{device.browser.name} {device.browser.version}</dd>
|
|
364
|
+
* <dt>OS</dt><dd>{device.os.name} {device.os.version}</dd>
|
|
206
365
|
* <dt>Language</dt><dd>{device.language}</dd>
|
|
207
|
-
* <dt>CPUs</dt><dd>{device.hardwareConcurrency ?? '—'}</dd>
|
|
208
366
|
* <dt>Viewport</dt><dd>{device.viewport.width}×{device.viewport.height}</dd>
|
|
209
|
-
* <dt>Theme</dt><dd>{device.colorScheme}</dd>
|
|
210
367
|
* </dl>
|
|
211
368
|
* );
|
|
212
369
|
* }
|
|
213
370
|
* ```
|
|
214
371
|
*/
|
|
215
372
|
export function useDeviceData(options: UseDeviceDataOptions = {}): DeviceData {
|
|
216
|
-
const {
|
|
373
|
+
const {
|
|
374
|
+
includeBattery = true,
|
|
375
|
+
batteryPollIntervalMs = 60_000,
|
|
376
|
+
includeHighEntropy = true,
|
|
377
|
+
} = options;
|
|
217
378
|
|
|
218
379
|
const [data, setData] = useState<DeviceData>(() => getDeviceData());
|
|
219
380
|
|
|
220
381
|
const refresh = useCallback(() => {
|
|
221
382
|
setData((prev) => {
|
|
222
383
|
const next = getDeviceData();
|
|
223
|
-
return
|
|
384
|
+
return {
|
|
385
|
+
...next,
|
|
386
|
+
...(prev.battery ? { battery: prev.battery } : {}),
|
|
387
|
+
};
|
|
224
388
|
});
|
|
225
389
|
}, []);
|
|
226
390
|
|
|
@@ -255,6 +419,35 @@ export function useDeviceData(options: UseDeviceDataOptions = {}): DeviceData {
|
|
|
255
419
|
};
|
|
256
420
|
}, [refresh]);
|
|
257
421
|
|
|
422
|
+
useEffect(() => {
|
|
423
|
+
if (!includeHighEntropy || typeof navigator === "undefined") return;
|
|
424
|
+
|
|
425
|
+
const uaData = (navigator as NavigatorWithExtras).userAgentData;
|
|
426
|
+
if (!uaData?.getHighEntropyValues) return;
|
|
427
|
+
|
|
428
|
+
let cancelled = false;
|
|
429
|
+
|
|
430
|
+
const updateHighEntropy = async () => {
|
|
431
|
+
const base = getDeviceData();
|
|
432
|
+
const enriched = await readHighEntropyBrowserOs(uaData, {
|
|
433
|
+
browser: base.browser,
|
|
434
|
+
os: base.os,
|
|
435
|
+
});
|
|
436
|
+
if (cancelled || !enriched) return;
|
|
437
|
+
setData((prev) => ({
|
|
438
|
+
...prev,
|
|
439
|
+
browser: enriched.browser,
|
|
440
|
+
os: enriched.os,
|
|
441
|
+
}));
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
void updateHighEntropy();
|
|
445
|
+
|
|
446
|
+
return () => {
|
|
447
|
+
cancelled = true;
|
|
448
|
+
};
|
|
449
|
+
}, [includeHighEntropy]);
|
|
450
|
+
|
|
258
451
|
useEffect(() => {
|
|
259
452
|
if (!includeBattery || typeof navigator === "undefined") return;
|
|
260
453
|
|