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/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: (_a = nav.userAgent) !== null && _a !== void 0 ? _a : "",
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 prev.battery ? Object.assign(Object.assign({}, next), { battery: prev.battery }) : next;
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;
@@ -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 device/browser snapshot: language, CPUs, memory, viewport, screen, touch, color scheme, reduced motion, Client Hints. |
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: false });
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 || device.userAgentData?.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 (UA-CH)', 'yes']);
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() → Navigator + Screen + matchMedia (+ optional Battery API)',
596
- summary: 'Extracts device/browser data: language, platform, CPUs, memory, screen, viewport, touch, color scheme, reduced motion, Client Hints, and optional battery.',
597
- code: `const device = useDeviceData();\n// device.language, device.hardwareConcurrency, device.viewport, device.colorScheme`,
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.8.0",
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",
@@ -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: nav.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 { includeBattery = true, batteryPollIntervalMs = 60_000 } = options;
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 prev.battery ? { ...next, battery: prev.battery } : next;
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