@cfour-hi/dsh-plugin-devicex 0.0.0-stage → 1.7.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/LICENSE +21 -0
- package/README.md +38 -2
- package/client.js +1008 -0
- package/cordis.patch.yml +3 -0
- package/host/actions-darwin.js +1261 -0
- package/host/actions-win32.js +1333 -0
- package/host/actions.js +45 -0
- package/host/benchmark.js +413 -0
- package/host/cleanup-darwin.js +715 -0
- package/host/cleanup-win32.js +760 -0
- package/host/cleanup.js +62 -0
- package/host/collector-darwin.js +2995 -0
- package/host/collector-win32.js +2060 -0
- package/host/collector.js +71 -0
- package/host/footprint-core.js +399 -0
- package/host/footprint-darwin.js +663 -0
- package/host/footprint-win32.js +411 -0
- package/host/footprint.js +284 -0
- package/host/proc.js +232 -0
- package/host/tool.js +254 -0
- package/host/transport.js +316 -0
- package/host/uninstall.js +244 -0
- package/host/win/hardware.ps1 +501 -0
- package/host/win/metrics.ps1 +535 -0
- package/host/win/network.ps1 +322 -0
- package/host/win/processes.ps1 +169 -0
- package/host/win/security.ps1 +197 -0
- package/host/win/software.ps1 +119 -0
- package/host/win/system.ps1 +279 -0
- package/host/win.js +187 -0
- package/icon.svg +18 -0
- package/index.js +67 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
|
@@ -0,0 +1,2995 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device X — host-side data collectors.
|
|
3
|
+
*
|
|
4
|
+
* Plain Node ESM (runs inside the DSH Host process). No harness imports: only
|
|
5
|
+
* `node:*` and one sibling module (`./actions.js`, for the action catalog that
|
|
6
|
+
* belongs to the inventory payload).
|
|
7
|
+
*
|
|
8
|
+
* Rules honoured here:
|
|
9
|
+
* - every macOS command goes through `execFile` (argv only), never a shell;
|
|
10
|
+
* - a failed command yields null/empty values plus a non-fatal string in the
|
|
11
|
+
* section's `errors[]`; collectors never throw (unknown section names are
|
|
12
|
+
* the one deliberate exception);
|
|
13
|
+
* - everything returned is JSON-safe: no `undefined`, no NaN/Infinity, no
|
|
14
|
+
* circular references;
|
|
15
|
+
* - slow sections are cached with TTLs and can be warmed in the background;
|
|
16
|
+
* - `collectMetrics()` is a fast poll path (<250 ms) built from cheap parallel
|
|
17
|
+
* commands plus deltas against the previous poll.
|
|
18
|
+
*
|
|
19
|
+
* @module @cfour-hi/dsh-plugin-devicex/host/collector
|
|
20
|
+
*/
|
|
21
|
+
import * as fs from 'node:fs';
|
|
22
|
+
import * as fsp from 'node:fs/promises';
|
|
23
|
+
import * as os from 'node:os';
|
|
24
|
+
import * as path from 'node:path';
|
|
25
|
+
|
|
26
|
+
import { actionCatalog, actionHistory } from './actions-darwin.js';
|
|
27
|
+
import { lines, run, runJson, sleep } from './proc.js';
|
|
28
|
+
|
|
29
|
+
/** Host platform (`darwin` on the target machine). */
|
|
30
|
+
export const PLATFORM = process.platform;
|
|
31
|
+
|
|
32
|
+
/** Frozen section identifiers, in UI order. */
|
|
33
|
+
export const SECTIONS = ['hardware', 'system', 'software', 'network', 'security', 'processes'];
|
|
34
|
+
|
|
35
|
+
/* -------------------------------------------------------------------------- */
|
|
36
|
+
/* Command paths (absolute, so a minimal Host PATH cannot break collection) */
|
|
37
|
+
/* -------------------------------------------------------------------------- */
|
|
38
|
+
|
|
39
|
+
const SYSTEM_PROFILER = '/usr/sbin/system_profiler';
|
|
40
|
+
const SYSCTL = '/usr/sbin/sysctl';
|
|
41
|
+
const SW_VERS = '/usr/bin/sw_vers';
|
|
42
|
+
const UNAME = '/usr/bin/uname';
|
|
43
|
+
const SCUTIL = '/usr/sbin/scutil';
|
|
44
|
+
const IOREG = '/usr/sbin/ioreg';
|
|
45
|
+
const VM_STAT = '/usr/bin/vm_stat';
|
|
46
|
+
const PMSET = '/usr/bin/pmset';
|
|
47
|
+
const IF_CONFIG = '/sbin/ifconfig';
|
|
48
|
+
const ROUTE = '/sbin/route';
|
|
49
|
+
const NETSTAT = '/usr/sbin/netstat';
|
|
50
|
+
const PS = '/bin/ps';
|
|
51
|
+
const DF = '/bin/df';
|
|
52
|
+
const NETWORKSETUP = '/usr/sbin/networksetup';
|
|
53
|
+
const LSOF = '/usr/sbin/lsof';
|
|
54
|
+
const DSCL = '/usr/bin/dscl';
|
|
55
|
+
const LAUNCHCTL = '/bin/launchctl';
|
|
56
|
+
const OSASCRIPT = '/usr/bin/osascript';
|
|
57
|
+
const DEFAULTS = '/usr/bin/defaults';
|
|
58
|
+
const PLUTIL = '/usr/bin/plutil';
|
|
59
|
+
const MDLS = '/usr/bin/mdls';
|
|
60
|
+
const FD_SETUP = '/usr/bin/fdesetup';
|
|
61
|
+
const CSRUTIL = '/usr/bin/csrutil';
|
|
62
|
+
const SPCTL = '/usr/sbin/spctl';
|
|
63
|
+
const SOCKETFILTERFW = '/usr/libexec/ApplicationFirewall/socketfilterfw';
|
|
64
|
+
const SYSTEMSETUP = '/usr/sbin/systemsetup';
|
|
65
|
+
const SQLITE3 = '/usr/bin/sqlite3';
|
|
66
|
+
const BREW_CANDIDATES = ['/opt/homebrew/bin/brew', '/usr/local/bin/brew'];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Environment for commands whose text output can contain process names:
|
|
70
|
+
* macOS `ps` meta-escapes non-ASCII under the C locale, so these run with a
|
|
71
|
+
* UTF-8 locale. Everything else keeps the deterministic C locale.
|
|
72
|
+
* @returns {NodeJS.ProcessEnv}
|
|
73
|
+
*/
|
|
74
|
+
function utf8Env() {
|
|
75
|
+
return { ...process.env, LC_ALL: 'en_US.UTF-8', LANG: 'en_US.UTF-8' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const HARDWARE_PROFILER_TYPES = [
|
|
79
|
+
'SPHardwareDataType',
|
|
80
|
+
'SPMemoryDataType',
|
|
81
|
+
'SPStorageDataType',
|
|
82
|
+
'SPUSBDataType',
|
|
83
|
+
'SPBluetoothDataType',
|
|
84
|
+
'SPAudioDataType',
|
|
85
|
+
'SPCameraDataType',
|
|
86
|
+
'SPDisplaysDataType',
|
|
87
|
+
'SPPowerDataType',
|
|
88
|
+
'SPExtensionsDataType',
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
/* -------------------------------------------------------------------------- */
|
|
92
|
+
/* Small utilities */
|
|
93
|
+
/* -------------------------------------------------------------------------- */
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Coerce to a finite number, else null.
|
|
97
|
+
* @param {unknown} value
|
|
98
|
+
* @returns {number|null}
|
|
99
|
+
*/
|
|
100
|
+
function num(value) {
|
|
101
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
102
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
103
|
+
const parsed = Number(value);
|
|
104
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Coerce to a trimmed, non-empty string, else null.
|
|
111
|
+
* @param {unknown} value
|
|
112
|
+
* @returns {string|null}
|
|
113
|
+
*/
|
|
114
|
+
function str(value) {
|
|
115
|
+
if (typeof value === 'string') {
|
|
116
|
+
const trimmed = value.trim();
|
|
117
|
+
return trimmed === '' ? null : trimmed;
|
|
118
|
+
}
|
|
119
|
+
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Coerce a number to a fixed number of decimals (null-safe).
|
|
125
|
+
* @param {unknown} value
|
|
126
|
+
* @param {number} [digits]
|
|
127
|
+
* @returns {number|null}
|
|
128
|
+
*/
|
|
129
|
+
function round(value, digits = 1) {
|
|
130
|
+
const parsed = num(value);
|
|
131
|
+
if (parsed === null) return null;
|
|
132
|
+
const factor = 10 ** digits;
|
|
133
|
+
return Math.round(parsed * factor) / factor;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Clamp a number into [min, max].
|
|
138
|
+
* @param {number} value
|
|
139
|
+
* @param {number} min
|
|
140
|
+
* @param {number} max
|
|
141
|
+
* @returns {number}
|
|
142
|
+
*/
|
|
143
|
+
function clamp(value, min, max) {
|
|
144
|
+
if (!Number.isFinite(value)) return min;
|
|
145
|
+
return Math.min(max, Math.max(min, value));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* First numeric value among the given keys of an object.
|
|
150
|
+
* @param {Record<string, unknown>} object
|
|
151
|
+
* @param {string[]} keys
|
|
152
|
+
* @returns {number|null}
|
|
153
|
+
*/
|
|
154
|
+
function firstNum(object, keys) {
|
|
155
|
+
if (!object || typeof object !== 'object') return null;
|
|
156
|
+
for (const key of keys) {
|
|
157
|
+
const parsed = num(object[key]);
|
|
158
|
+
if (parsed !== null) return parsed;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* First string value among the given keys of an object.
|
|
165
|
+
* @param {Record<string, unknown>} object
|
|
166
|
+
* @param {string[]} keys
|
|
167
|
+
* @returns {string|null}
|
|
168
|
+
*/
|
|
169
|
+
function firstStr(object, keys) {
|
|
170
|
+
if (!object || typeof object !== 'object') return null;
|
|
171
|
+
for (const key of keys) {
|
|
172
|
+
const parsed = str(object[key]);
|
|
173
|
+
if (parsed !== null) return parsed;
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Convert a human byte string ("16 GB", "500.3 GB (500277792768 Bytes)") to
|
|
180
|
+
* bytes. Returns null when nothing parseable is found.
|
|
181
|
+
* @param {unknown} text
|
|
182
|
+
* @returns {number|null}
|
|
183
|
+
*/
|
|
184
|
+
function bytesFromText(text) {
|
|
185
|
+
const source = str(text);
|
|
186
|
+
if (!source) return null;
|
|
187
|
+
// Prefer an explicit exact "(N Bytes)" figure when present.
|
|
188
|
+
const exact = /\((\d+)\s*bytes?\)/i.exec(source);
|
|
189
|
+
if (exact) return Number(exact[1]);
|
|
190
|
+
const match = /([\d.]+)\s*(bytes?|kb|mb|gb|tb)/i.exec(source);
|
|
191
|
+
if (!match) {
|
|
192
|
+
const plain = /^(\d+)$/.exec(source);
|
|
193
|
+
return plain ? Number(plain[1]) : null;
|
|
194
|
+
}
|
|
195
|
+
const value = Number(match[1]);
|
|
196
|
+
if (!Number.isFinite(value)) return null;
|
|
197
|
+
const unit = match[2].toLowerCase();
|
|
198
|
+
const factor =
|
|
199
|
+
unit === 'tb' ? 1024 ** 4 : unit === 'gb' ? 1024 ** 3 : unit === 'mb' ? 1024 ** 2 : unit === 'kb' ? 1024 : 1;
|
|
200
|
+
return Math.round(value * factor);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Shorten an error message for an `errors[]` entry.
|
|
205
|
+
* @param {unknown} value
|
|
206
|
+
* @param {number} [max]
|
|
207
|
+
* @returns {string}
|
|
208
|
+
*/
|
|
209
|
+
function shortErr(value, max = 220) {
|
|
210
|
+
const text = String(value ?? 'unknown error').replace(/\s+/g, ' ').trim();
|
|
211
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Deep JSON-safe conversion: `undefined`, non-finite numbers and functions
|
|
216
|
+
* become `null`; values are cloned so callers cannot mutate a cache entry.
|
|
217
|
+
* Cycles (which should not exist here) collapse to `null`.
|
|
218
|
+
* @param {unknown} value
|
|
219
|
+
* @param {Set<object>} [seen]
|
|
220
|
+
* @returns {any}
|
|
221
|
+
*/
|
|
222
|
+
function jsonSafe(value, seen = new Set()) {
|
|
223
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
224
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
225
|
+
if (typeof value === 'bigint') return Number(value);
|
|
226
|
+
if (typeof value === 'undefined' || typeof value === 'function' || typeof value === 'symbol') return null;
|
|
227
|
+
if (typeof value !== 'object') return null;
|
|
228
|
+
if (seen.has(value)) return null;
|
|
229
|
+
seen.add(value);
|
|
230
|
+
if (Array.isArray(value)) {
|
|
231
|
+
const array = value.map((item) => jsonSafe(item, seen));
|
|
232
|
+
seen.delete(value);
|
|
233
|
+
return array;
|
|
234
|
+
}
|
|
235
|
+
if (value instanceof Date) {
|
|
236
|
+
const time = value.getTime();
|
|
237
|
+
seen.delete(value);
|
|
238
|
+
return Number.isFinite(time) ? time : null;
|
|
239
|
+
}
|
|
240
|
+
/** @type {Record<string, unknown>} */
|
|
241
|
+
const object = {};
|
|
242
|
+
for (const [key, item] of Object.entries(value)) {
|
|
243
|
+
object[key] = jsonSafe(item, seen);
|
|
244
|
+
}
|
|
245
|
+
seen.delete(value);
|
|
246
|
+
return object;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* -------------------------------------------------------------------------- */
|
|
250
|
+
/* Caching */
|
|
251
|
+
/* -------------------------------------------------------------------------- */
|
|
252
|
+
|
|
253
|
+
/** Per-section TTLs (ms). */
|
|
254
|
+
const SECTION_TTL = {
|
|
255
|
+
hardware: 300_000,
|
|
256
|
+
system: 60_000,
|
|
257
|
+
software: 300_000,
|
|
258
|
+
network: 15_000,
|
|
259
|
+
security: 120_000,
|
|
260
|
+
processes: 4_000,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/** @type {Map<string, {value: any, at: number}>} */
|
|
264
|
+
const sectionCache = new Map();
|
|
265
|
+
/** @type {Map<string, Promise<any>>} */
|
|
266
|
+
const sectionInflight = new Map();
|
|
267
|
+
|
|
268
|
+
/** General async memo store: `{value, at, pending}`. @type {Map<string, {value:any, at:number, pending:Promise<any>|null}>} */
|
|
269
|
+
const memoStore = new Map();
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Async memo with TTL and in-flight de-duplication. On failure the previous
|
|
273
|
+
* value (or null) is returned and the timestamp is refreshed.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} key
|
|
276
|
+
* @param {number} ttl
|
|
277
|
+
* @param {() => Promise<any>} loader
|
|
278
|
+
* @returns {Promise<any>}
|
|
279
|
+
*/
|
|
280
|
+
function memo(key, ttl, loader) {
|
|
281
|
+
const now = Date.now();
|
|
282
|
+
let record = memoStore.get(key);
|
|
283
|
+
if (!record) {
|
|
284
|
+
record = { value: null, at: 0, pending: null };
|
|
285
|
+
memoStore.set(key, record);
|
|
286
|
+
}
|
|
287
|
+
if (record.pending) return record.pending;
|
|
288
|
+
if (now - record.at < ttl) return Promise.resolve(record.value);
|
|
289
|
+
const pending = Promise.resolve()
|
|
290
|
+
.then(loader)
|
|
291
|
+
.then((value) => {
|
|
292
|
+
record.value = value;
|
|
293
|
+
return value;
|
|
294
|
+
})
|
|
295
|
+
.catch(() => record.value)
|
|
296
|
+
.finally(() => {
|
|
297
|
+
record.at = Date.now();
|
|
298
|
+
record.pending = null;
|
|
299
|
+
});
|
|
300
|
+
record.pending = pending;
|
|
301
|
+
return pending;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Non-blocking cache: returns the last value immediately and refreshes it in
|
|
306
|
+
* the background when stale. Used by `collectMetrics()` for slow samples
|
|
307
|
+
* (Wi-Fi) so a poll never waits on `system_profiler`.
|
|
308
|
+
*
|
|
309
|
+
* @param {string} key
|
|
310
|
+
* @param {number} ttl
|
|
311
|
+
* @param {() => Promise<any>} loader
|
|
312
|
+
* @returns {any}
|
|
313
|
+
*/
|
|
314
|
+
function fastValue(key, ttl, loader) {
|
|
315
|
+
const now = Date.now();
|
|
316
|
+
let record = memoStore.get(key);
|
|
317
|
+
if (!record) {
|
|
318
|
+
record = { value: null, at: 0, pending: null };
|
|
319
|
+
memoStore.set(key, record);
|
|
320
|
+
}
|
|
321
|
+
if (now - record.at < ttl) return record.value;
|
|
322
|
+
if (!record.pending) {
|
|
323
|
+
record.pending = Promise.resolve()
|
|
324
|
+
.then(loader)
|
|
325
|
+
.then((value) => {
|
|
326
|
+
record.value = value;
|
|
327
|
+
return value;
|
|
328
|
+
})
|
|
329
|
+
.catch(() => record.value)
|
|
330
|
+
.finally(() => {
|
|
331
|
+
record.at = Date.now();
|
|
332
|
+
record.pending = null;
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
return record.value;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/* -------------------------------------------------------------------------- */
|
|
339
|
+
/* Rate tracking (metrics deltas) */
|
|
340
|
+
/* -------------------------------------------------------------------------- */
|
|
341
|
+
|
|
342
|
+
/** @type {Map<string, {at: number, read: number, write: number}>} */
|
|
343
|
+
const rateState = new Map();
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Have two cumulative counters at different times → bytes/second.
|
|
347
|
+
* @param {string} key
|
|
348
|
+
* @param {{read: number, write: number}} current
|
|
349
|
+
* @param {number} at
|
|
350
|
+
* @returns {{readBytesPerSec: number, writeBytesPerSec: number}}
|
|
351
|
+
*/
|
|
352
|
+
function rateOf(key, current, at) {
|
|
353
|
+
const previous = rateState.get(key);
|
|
354
|
+
rateState.set(key, { at, read: current.read, write: current.write });
|
|
355
|
+
if (!previous || at <= previous.at) return { readBytesPerSec: 0, writeBytesPerSec: 0 };
|
|
356
|
+
const dt = (at - previous.at) / 1000;
|
|
357
|
+
if (!(dt > 0)) return { readBytesPerSec: 0, writeBytesPerSec: 0 };
|
|
358
|
+
return {
|
|
359
|
+
readBytesPerSec: Math.max(0, Math.round((current.read - previous.read) / dt)),
|
|
360
|
+
writeBytesPerSec: Math.max(0, Math.round((current.write - previous.write) / dt)),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/* -------------------------------------------------------------------------- */
|
|
365
|
+
/* Parsers */
|
|
366
|
+
/* -------------------------------------------------------------------------- */
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* @typedef {{pageSize: number, free: number, active: number, inactive: number,
|
|
370
|
+
* speculative: number, wired: number, purgeable: number, fileBacked: number,
|
|
371
|
+
* anonymous: number, compressed: number}} VmStat
|
|
372
|
+
*/
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Parse `vm_stat` into page counts.
|
|
376
|
+
* @param {string} text
|
|
377
|
+
* @returns {VmStat}
|
|
378
|
+
*/
|
|
379
|
+
function parseVmStat(text) {
|
|
380
|
+
const result = {
|
|
381
|
+
pageSize: 4096,
|
|
382
|
+
free: 0,
|
|
383
|
+
active: 0,
|
|
384
|
+
inactive: 0,
|
|
385
|
+
speculative: 0,
|
|
386
|
+
wired: 0,
|
|
387
|
+
purgeable: 0,
|
|
388
|
+
fileBacked: 0,
|
|
389
|
+
anonymous: 0,
|
|
390
|
+
compressed: 0,
|
|
391
|
+
};
|
|
392
|
+
const pageSize = /page size of (\d+) bytes/.exec(text);
|
|
393
|
+
if (pageSize) result.pageSize = Number(pageSize[1]);
|
|
394
|
+
const read = (label, key) => {
|
|
395
|
+
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
396
|
+
const match = new RegExp(`^${escaped}:\\s+(\\d+)\\.`, 'm').exec(text);
|
|
397
|
+
if (match) result[key] = Number(match[1]);
|
|
398
|
+
};
|
|
399
|
+
read('Pages free', 'free');
|
|
400
|
+
read('Pages active', 'active');
|
|
401
|
+
read('Pages inactive', 'inactive');
|
|
402
|
+
read('Pages speculative', 'speculative');
|
|
403
|
+
read('Pages wired down', 'wired');
|
|
404
|
+
read('Pages purgeable', 'purgeable');
|
|
405
|
+
read('File-backed pages', 'fileBacked');
|
|
406
|
+
read('Anonymous pages', 'anonymous');
|
|
407
|
+
read('Pages occupied by compressor', 'compressed');
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Parse `sysctl -n vm.swapusage` ("total = 12288.00M used = ...").
|
|
413
|
+
* @param {string} text
|
|
414
|
+
* @returns {{total: number, used: number}}
|
|
415
|
+
*/
|
|
416
|
+
function parseSwap(text) {
|
|
417
|
+
const total = /total\s*=\s*([\d.]+)([MG])/.exec(text);
|
|
418
|
+
const used = /used\s*=\s*([\d.]+)([MG])/.exec(text);
|
|
419
|
+
const toBytes = (match) => {
|
|
420
|
+
if (!match) return 0;
|
|
421
|
+
const value = Number(match[1]);
|
|
422
|
+
if (!Number.isFinite(value)) return 0;
|
|
423
|
+
return Math.round(value * (match[2] === 'G' ? 1024 ** 3 : 1024 ** 2));
|
|
424
|
+
};
|
|
425
|
+
return { total: toBytes(total), used: toBytes(used) };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Parse `pmset -g batt`.
|
|
430
|
+
* @param {string} text
|
|
431
|
+
* @returns {{present: boolean, percent: number|null, charging: boolean,
|
|
432
|
+
* plugged: boolean, powerSource: string|null, timeRemainingMin: number|null,
|
|
433
|
+
* batteryState: string|null}}
|
|
434
|
+
*/
|
|
435
|
+
function parsePmsetBattery(text) {
|
|
436
|
+
const sourceMatch = /Now drawing from '([^']+)'/.exec(text);
|
|
437
|
+
const line = /^\s*-\s*InternalBattery-\d+.*$/m.exec(text);
|
|
438
|
+
/** @type {{present: boolean, percent: number|null, charging: boolean, plugged: boolean, powerSource: string|null, timeRemainingMin: number|null, batteryState: string|null}} */
|
|
439
|
+
const result = {
|
|
440
|
+
present: Boolean(line),
|
|
441
|
+
percent: null,
|
|
442
|
+
charging: false,
|
|
443
|
+
plugged: false,
|
|
444
|
+
powerSource: sourceMatch ? sourceMatch[1] : null,
|
|
445
|
+
timeRemainingMin: null,
|
|
446
|
+
batteryState: null,
|
|
447
|
+
};
|
|
448
|
+
if (!line) return result;
|
|
449
|
+
const percent = /(\d+)%/.exec(line[0]);
|
|
450
|
+
if (percent) result.percent = Number(percent[1]);
|
|
451
|
+
const state = /;\s*([^;]+?)\s*;/.exec(line[0]);
|
|
452
|
+
if (state) result.batteryState = state[1].trim();
|
|
453
|
+
const pmsetState = result.batteryState ?? '';
|
|
454
|
+
// `discharging` contains the substring `charging`; match whole states instead.
|
|
455
|
+
result.charging = (pmsetState === 'charging' || pmsetState === 'finishing charge') && !/discharg/.test(pmsetState);
|
|
456
|
+
result.plugged = (result.powerSource ?? '').toLowerCase().includes('ac');
|
|
457
|
+
const remaining = /(\d+):(\d{2})\s*remaining/.exec(line[0]);
|
|
458
|
+
if (remaining) {
|
|
459
|
+
const minutes = Number(remaining[1]) * 60 + Number(remaining[2]);
|
|
460
|
+
result.timeRemainingMin = minutes > 0 && minutes < 6000 ? minutes : null;
|
|
461
|
+
}
|
|
462
|
+
if (/charged/.test(result.batteryState ?? '')) result.timeRemainingMin = null;
|
|
463
|
+
return result;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Parse the interesting top-level keys of `ioreg -rn AppleSmartBattery`.
|
|
468
|
+
* Only line-anchored matches are used so the nested `BatteryData` dictionary
|
|
469
|
+
* cannot shadow the top-level figures.
|
|
470
|
+
* @param {string} text
|
|
471
|
+
* @returns {{present: boolean, currentCapacity: number|null,
|
|
472
|
+
* maxCapacity: number|null, rawMaxCapacity: number|null,
|
|
473
|
+
* designCapacity: number|null, temperatureC: number|null,
|
|
474
|
+
* cycleCount: number|null, charging: boolean|null, external: boolean|null,
|
|
475
|
+
* timeRemaining: number|null}}
|
|
476
|
+
*/
|
|
477
|
+
function parseIoregBattery(text) {
|
|
478
|
+
const read = (key) => {
|
|
479
|
+
const match = new RegExp(`^\\s+"${key}" = (\\d+)`, 'm').exec(text);
|
|
480
|
+
return match ? Number(match[1]) : null;
|
|
481
|
+
};
|
|
482
|
+
const readBool = (key) => {
|
|
483
|
+
const match = new RegExp(`^\\s+"${key}" = (Yes|No|true|false)`, 'm').exec(text);
|
|
484
|
+
if (!match) return null;
|
|
485
|
+
return match[1] === 'Yes' || match[1] === 'true';
|
|
486
|
+
};
|
|
487
|
+
const temperature = read('Temperature');
|
|
488
|
+
const installed = readBool('BatteryInstalled');
|
|
489
|
+
return {
|
|
490
|
+
present: installed === true,
|
|
491
|
+
currentCapacity: read('CurrentCapacity'),
|
|
492
|
+
maxCapacity: read('MaxCapacity'),
|
|
493
|
+
rawMaxCapacity: read('AppleRawMaxCapacity'),
|
|
494
|
+
designCapacity: read('DesignCapacity'),
|
|
495
|
+
temperatureC: temperature === null ? null : round(temperature / 100, 1),
|
|
496
|
+
cycleCount: read('CycleCount'),
|
|
497
|
+
charging: readBool('IsCharging'),
|
|
498
|
+
external: readBool('ExternalConnected'),
|
|
499
|
+
timeRemaining: read('TimeRemaining'),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Compute battery health percentage from raw max / design capacity.
|
|
505
|
+
* @param {ReturnType<typeof parseIoregBattery>} battery
|
|
506
|
+
* @returns {number|null}
|
|
507
|
+
*/
|
|
508
|
+
function batteryHealth(battery) {
|
|
509
|
+
if (battery.rawMaxCapacity === null || battery.designCapacity === null || battery.designCapacity <= 0) return null;
|
|
510
|
+
return clamp(Math.round((battery.rawMaxCapacity / battery.designCapacity) * 100), 0, 100);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Parse cumulative disk byte counters from `ioreg -c IOBlockStorageDriver -r -w0`.
|
|
515
|
+
* @param {string} text
|
|
516
|
+
* @returns {{read: number, write: number}}
|
|
517
|
+
*/
|
|
518
|
+
function parseIoregDisk(text) {
|
|
519
|
+
let read = 0;
|
|
520
|
+
let write = 0;
|
|
521
|
+
const readMatches = text.matchAll(/"Bytes \(Read\)"=(\d+)/g);
|
|
522
|
+
for (const match of readMatches) read += Number(match[1]) || 0;
|
|
523
|
+
const writeMatches = text.matchAll(/"Bytes \(Write\)"=(\d+)/g);
|
|
524
|
+
for (const match of writeMatches) write += Number(match[1]) || 0;
|
|
525
|
+
return { read, write };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Sum cumulative network bytes from `netstat -ib` link rows (loopback excluded).
|
|
530
|
+
* @param {string} text
|
|
531
|
+
* @returns {{read: number, write: number}}
|
|
532
|
+
*/
|
|
533
|
+
function parseNetstatBytes(text) {
|
|
534
|
+
let read = 0;
|
|
535
|
+
let write = 0;
|
|
536
|
+
for (const line of String(text ?? '').split(/\r?\n/)) {
|
|
537
|
+
if (!line.trim() || /^Name\s/.test(line)) continue;
|
|
538
|
+
const match = /^(\S+)\s+(\d+)\s+(<Link#\d+>)\s*(.*)$/.exec(line);
|
|
539
|
+
if (!match) continue;
|
|
540
|
+
const name = match[1];
|
|
541
|
+
if (name === 'lo0') continue;
|
|
542
|
+
let rest = match[4].trim().split(/\s+/);
|
|
543
|
+
if (rest.length && /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/i.test(rest[0])) rest = rest.slice(1);
|
|
544
|
+
if (rest.length < 7) continue;
|
|
545
|
+
const ibytes = Number(rest[2]);
|
|
546
|
+
const obytes = Number(rest[5]);
|
|
547
|
+
if (Number.isFinite(ibytes)) read += ibytes;
|
|
548
|
+
if (Number.isFinite(obytes)) write += obytes;
|
|
549
|
+
}
|
|
550
|
+
return { read, write };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Parse `df -kP <path>` for one filesystem.
|
|
555
|
+
* @param {string} text
|
|
556
|
+
* @returns {{totalBytes: number, usedBytes: number, freeBytes: number}|null}
|
|
557
|
+
*/
|
|
558
|
+
function parseDf(text) {
|
|
559
|
+
const rows = lines(text);
|
|
560
|
+
if (rows.length < 2) return null;
|
|
561
|
+
const parts = rows[rows.length - 1].trim().split(/\s+/);
|
|
562
|
+
if (parts.length < 4) return null;
|
|
563
|
+
const totalKb = Number(parts[1]);
|
|
564
|
+
const availKb = Number(parts[3]);
|
|
565
|
+
if (!Number.isFinite(totalKb) || !Number.isFinite(availKb)) return null;
|
|
566
|
+
const totalBytes = totalKb * 1024;
|
|
567
|
+
const freeBytes = availKb * 1024;
|
|
568
|
+
return { totalBytes, usedBytes: Math.max(0, totalBytes - freeBytes), freeBytes };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* @typedef {{name: string, up: boolean, mtu: number, mac: string|null,
|
|
573
|
+
* ipv4: string[], ipv6: string[], status: string|null}} Iface
|
|
574
|
+
*/
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Parse `ifconfig -a`.
|
|
578
|
+
* @param {string} text
|
|
579
|
+
* @returns {Iface[]}
|
|
580
|
+
*/
|
|
581
|
+
function parseIfconfig(text) {
|
|
582
|
+
/** @type {Iface[]} */
|
|
583
|
+
const result = [];
|
|
584
|
+
/** @type {Iface|null} */
|
|
585
|
+
let current = null;
|
|
586
|
+
for (const rawLine of String(text ?? '').split(/\r?\n/)) {
|
|
587
|
+
if (rawLine.trim() === '') continue;
|
|
588
|
+
if (!/^\s/.test(rawLine)) {
|
|
589
|
+
const header = /^([A-Za-z0-9_.-]+):\s+flags=([0-9a-f]+)(?:<([^>]*)>)?\s+mtu\s+(\d+)/.exec(rawLine);
|
|
590
|
+
if (header) {
|
|
591
|
+
current = {
|
|
592
|
+
name: header[1],
|
|
593
|
+
up: (header[3] ?? '').split(',').includes('UP'),
|
|
594
|
+
mtu: Number(header[4]),
|
|
595
|
+
mac: null,
|
|
596
|
+
ipv4: [],
|
|
597
|
+
ipv6: [],
|
|
598
|
+
status: null,
|
|
599
|
+
};
|
|
600
|
+
result.push(current);
|
|
601
|
+
} else {
|
|
602
|
+
current = null;
|
|
603
|
+
}
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
if (!current) continue;
|
|
607
|
+
const line = rawLine.trim();
|
|
608
|
+
const ipv4 = /^inet\s+(\d{1,3}(?:\.\d{1,3}){3})(?:\s+netmask\s+\S+)?/.exec(line);
|
|
609
|
+
if (ipv4) {
|
|
610
|
+
current.ipv4.push(ipv4[1]);
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
const ipv6 = /^inet6\s+([0-9a-fA-F:]+)(?:%[A-Za-z0-9]+)?(?:\s+prefixlen\s+\d+)?/.exec(line);
|
|
614
|
+
if (ipv6) {
|
|
615
|
+
const address = ipv6[1];
|
|
616
|
+
if (address !== '::1') current.ipv6.push(address);
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
const mac = /^ether\s+([0-9a-fA-F:]{17})/.exec(line);
|
|
620
|
+
if (mac) {
|
|
621
|
+
current.mac = mac[1].toLowerCase();
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
const status = /^status:\s+(\S+)/.exec(line);
|
|
625
|
+
if (status) current.status = status[1];
|
|
626
|
+
}
|
|
627
|
+
return result;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Parse `networksetup -listallhardwareports` into device → hardware port name.
|
|
632
|
+
* @param {string} text
|
|
633
|
+
* @returns {Map<string, string>}
|
|
634
|
+
*/
|
|
635
|
+
function parseHardwarePorts(text) {
|
|
636
|
+
/** @type {Map<string, string>} */
|
|
637
|
+
const map = new Map();
|
|
638
|
+
let port = null;
|
|
639
|
+
for (const rawLine of String(text ?? '').split(/\r?\n/)) {
|
|
640
|
+
const line = rawLine.trim();
|
|
641
|
+
if (!line) continue;
|
|
642
|
+
const portMatch = /^Hardware Port:\s*(.+)$/.exec(line);
|
|
643
|
+
if (portMatch) {
|
|
644
|
+
port = portMatch[1].trim();
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
const deviceMatch = /^Device:\s*(\S+)$/.exec(line);
|
|
648
|
+
if (deviceMatch && port) {
|
|
649
|
+
map.set(deviceMatch[1], port);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return map;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Classify an interface for the network section.
|
|
657
|
+
* @param {Iface} iface
|
|
658
|
+
* @param {Map<string, string>} hardwarePorts
|
|
659
|
+
* @returns {'wifi'|'ethernet'|'loopback'|'bridge'|'other'}
|
|
660
|
+
*/
|
|
661
|
+
function interfaceType(iface, hardwarePorts) {
|
|
662
|
+
if (iface.name === 'lo0' || iface.name.startsWith('lo')) return 'loopback';
|
|
663
|
+
if (iface.name.startsWith('bridge')) return 'bridge';
|
|
664
|
+
const port = hardwarePorts.get(iface.name);
|
|
665
|
+
if (port) {
|
|
666
|
+
if (/wi-?fi|airport/i.test(port)) return 'wifi';
|
|
667
|
+
if (/ethernet|thunderbolt|lan|usb/i.test(port)) return 'ethernet';
|
|
668
|
+
}
|
|
669
|
+
if (/^en\d+$/.test(iface.name)) return 'ethernet';
|
|
670
|
+
return 'other';
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Parse `route -n get default`.
|
|
675
|
+
* @param {string} text
|
|
676
|
+
* @returns {{gateway: string|null, interface: string|null}|null}
|
|
677
|
+
*/
|
|
678
|
+
function parseDefaultRoute(text) {
|
|
679
|
+
const gateway = /gateway:\s*(\S+)/.exec(text);
|
|
680
|
+
const iface = /interface:\s*(\S+)/.exec(text);
|
|
681
|
+
if (!gateway && !iface) return null;
|
|
682
|
+
return { gateway: gateway ? gateway[1] : null, interface: iface ? iface[1] : null };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Parse `scutil --dns`.
|
|
687
|
+
* @param {string} text
|
|
688
|
+
* @returns {{servers: string[], searchDomains: string[]}}
|
|
689
|
+
*/
|
|
690
|
+
function parseDns(text) {
|
|
691
|
+
/** @type {string[]} */
|
|
692
|
+
const servers = [];
|
|
693
|
+
/** @type {string[]} */
|
|
694
|
+
const searchDomains = [];
|
|
695
|
+
const serverMatches = String(text ?? '').matchAll(/nameserver\[\d+]\s*:\s*(\S+)/g);
|
|
696
|
+
for (const match of serverMatches) {
|
|
697
|
+
if (!servers.includes(match[1])) servers.push(match[1]);
|
|
698
|
+
}
|
|
699
|
+
const domainMatches = String(text ?? '').matchAll(/search domain\[\d+]\s*:\s*(\S+)/g);
|
|
700
|
+
for (const match of domainMatches) {
|
|
701
|
+
if (!searchDomains.includes(match[1])) searchDomains.push(match[1]);
|
|
702
|
+
}
|
|
703
|
+
return { servers: servers.slice(0, 8), searchDomains: searchDomains.slice(0, 8) };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Parse `scutil --proxy`'s dictionary dump.
|
|
708
|
+
* @param {string} text
|
|
709
|
+
* @returns {Record<string, string|number>}
|
|
710
|
+
*/
|
|
711
|
+
function parseScutilDictionary(text) {
|
|
712
|
+
/** @type {Record<string, string|number>} */
|
|
713
|
+
const result = {};
|
|
714
|
+
for (const rawLine of String(text ?? '').split(/\r?\n/)) {
|
|
715
|
+
const match = /^\s*([A-Za-z][A-Za-z0-9]*)\s*:\s*(.*?)\s*$/.exec(rawLine);
|
|
716
|
+
if (!match) continue;
|
|
717
|
+
const value = match[2];
|
|
718
|
+
if (value === '') continue;
|
|
719
|
+
result[match[1]] = /^-?\d+$/.test(value) ? Number(value) : value;
|
|
720
|
+
}
|
|
721
|
+
return result;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Turn raw scutil proxy keys into the contract's three proxy slots.
|
|
726
|
+
* @param {Record<string, string|number>} config
|
|
727
|
+
* @returns {{http: string|null, https: string|null, socks: string|null}}
|
|
728
|
+
*/
|
|
729
|
+
function parseProxy(config) {
|
|
730
|
+
const slot = (enableKey, hostKey, portKey) => {
|
|
731
|
+
const enabled = Number(config[enableKey] ?? 0) === 1;
|
|
732
|
+
const host = str(config[hostKey]);
|
|
733
|
+
if (!enabled || !host) return null;
|
|
734
|
+
const port = num(config[portKey]);
|
|
735
|
+
return port ? `${host}:${port}` : host;
|
|
736
|
+
};
|
|
737
|
+
return {
|
|
738
|
+
http: slot('HTTPEnable', 'HTTPProxy', 'HTTPPort'),
|
|
739
|
+
https: slot('HTTPSEnable', 'HTTPSProxy', 'HTTPSPort'),
|
|
740
|
+
socks: slot('SOCKSEnable', 'SOCKSProxy', 'SOCKSPort'),
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Parse `lsof -nP -iTCP -sTCP:LISTEN` rows.
|
|
746
|
+
* @param {string} text
|
|
747
|
+
* @returns {Array<{proto: string, address: string, port: number,
|
|
748
|
+
* pid: number|null, process: string}>}
|
|
749
|
+
*/
|
|
750
|
+
function parseLsofListeners(text) {
|
|
751
|
+
/** @type {Array<{proto: string, address: string, port: number, pid: number|null, process: string}>} */
|
|
752
|
+
const result = [];
|
|
753
|
+
for (const rawLine of String(text ?? '').split(/\r?\n/)) {
|
|
754
|
+
const line = rawLine.trim();
|
|
755
|
+
if (!line || /^COMMAND\s/.test(line)) continue;
|
|
756
|
+
const parts = line.split(/\s+/);
|
|
757
|
+
if (parts.length < 9) continue;
|
|
758
|
+
const process = parts[0].replace(/\\x([0-9a-fA-F]{2})/g, (_m, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
759
|
+
const pid = num(parts[1]);
|
|
760
|
+
const nameField = parts[8];
|
|
761
|
+
const at = nameField.lastIndexOf(':');
|
|
762
|
+
if (at === -1) continue;
|
|
763
|
+
const address = nameField.slice(0, at).replace(/^\[/, '').replace(/\]$/, '');
|
|
764
|
+
const port = num(nameField.slice(at + 1));
|
|
765
|
+
if (port === null) continue;
|
|
766
|
+
result.push({ proto: 'tcp', address: address || '*', port, pid, process });
|
|
767
|
+
}
|
|
768
|
+
return result;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Parse `ps -axo pid=,ppid=,user=,%cpu=,%mem=,rss=,state=,etime=,command=`.
|
|
773
|
+
* @param {string} text
|
|
774
|
+
* @returns {Array<{pid: number, ppid: number|null, user: string|null,
|
|
775
|
+
* cpu: number, memPercent: number, rssBytes: number|null, state: string|null,
|
|
776
|
+
* elapsed: number|null, command: string}>}
|
|
777
|
+
*/
|
|
778
|
+
function parsePs(text) {
|
|
779
|
+
/** @type {ReturnType<typeof parsePs>} */
|
|
780
|
+
const result = [];
|
|
781
|
+
for (const rawLine of String(text ?? '').split(/\r?\n/)) {
|
|
782
|
+
const line = rawLine.trimEnd();
|
|
783
|
+
if (!line.trim()) continue;
|
|
784
|
+
const match =
|
|
785
|
+
/^\s*(\d+)\s+(\d+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.*)$/.exec(line);
|
|
786
|
+
if (!match) continue;
|
|
787
|
+
const rss = Number(match[6]);
|
|
788
|
+
result.push({
|
|
789
|
+
pid: Number(match[1]),
|
|
790
|
+
ppid: Number(match[2]),
|
|
791
|
+
user: match[3] || null,
|
|
792
|
+
cpu: round(Number(match[4]), 1) ?? 0,
|
|
793
|
+
memPercent: round(Number(match[5]), 1) ?? 0,
|
|
794
|
+
rssBytes: Number.isFinite(rss) ? rss * 1024 : null,
|
|
795
|
+
state: match[7] || null,
|
|
796
|
+
elapsed: parseElapsed(match[8]),
|
|
797
|
+
command: match[9] ? match[9].trim() : '',
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
return result;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Convert a `ps` ELAPSED field ("08-07:05:32", "16:01", "02:03") to seconds.
|
|
805
|
+
* @param {string} value
|
|
806
|
+
* @returns {number|null}
|
|
807
|
+
*/
|
|
808
|
+
function parseElapsed(value) {
|
|
809
|
+
if (!value) return null;
|
|
810
|
+
let days = 0;
|
|
811
|
+
let rest = value;
|
|
812
|
+
const dayMatch = /^(\d+)-(.+)$/.exec(value);
|
|
813
|
+
if (dayMatch) {
|
|
814
|
+
days = Number(dayMatch[1]);
|
|
815
|
+
rest = dayMatch[2];
|
|
816
|
+
}
|
|
817
|
+
const parts = rest.split(':').map(Number);
|
|
818
|
+
if (parts.some((part) => !Number.isFinite(part))) return null;
|
|
819
|
+
let hours = 0;
|
|
820
|
+
let minutes = 0;
|
|
821
|
+
let seconds = 0;
|
|
822
|
+
if (parts.length === 3) [hours, minutes, seconds] = parts;
|
|
823
|
+
else if (parts.length === 2) [minutes, seconds] = parts;
|
|
824
|
+
else [seconds] = parts;
|
|
825
|
+
return days * 86_400 + hours * 3_600 + minutes * 60 + seconds;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Parse `launchctl list` into label → {pid, status}.
|
|
830
|
+
* @param {string} text
|
|
831
|
+
* @returns {Map<string, {pid: number|null, status: number|null}>}
|
|
832
|
+
*/
|
|
833
|
+
function parseLaunchctlList(text) {
|
|
834
|
+
/** @type {Map<string, {pid: number|null, status: number|null}>} */
|
|
835
|
+
const map = new Map();
|
|
836
|
+
for (const rawLine of String(text ?? '').split(/\r?\n/)) {
|
|
837
|
+
const parts = rawLine.trim().split('\t');
|
|
838
|
+
if (parts.length < 3) continue;
|
|
839
|
+
const label = parts.slice(2).join('\t').trim();
|
|
840
|
+
if (!label || label === 'Label') continue;
|
|
841
|
+
map.set(label, { pid: num(parts[0]), status: num(parts[1]) });
|
|
842
|
+
}
|
|
843
|
+
return map;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Decode the five XML entities needed for plist strings.
|
|
848
|
+
* @param {string} value
|
|
849
|
+
* @returns {string}
|
|
850
|
+
*/
|
|
851
|
+
function decodeXml(value) {
|
|
852
|
+
return value
|
|
853
|
+
.replace(/</g, '<')
|
|
854
|
+
.replace(/>/g, '>')
|
|
855
|
+
.replace(/"/g, '"')
|
|
856
|
+
.replace(/'/g, "'")
|
|
857
|
+
.replace(/&/g, '&');
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Extract Label/Program/ProgramArguments/Disabled from an XML plist.
|
|
862
|
+
* @param {string} text
|
|
863
|
+
* @returns {{Label: string|null, Program: string|null,
|
|
864
|
+
* ProgramArguments: string[], Disabled: boolean|null}}
|
|
865
|
+
*/
|
|
866
|
+
function parseXmlPlist(text) {
|
|
867
|
+
const value = (key) => {
|
|
868
|
+
const match = new RegExp(`<key>${key}</key>\\s*<string>([\\s\\S]*?)</string>`).exec(text);
|
|
869
|
+
return match ? decodeXml(match[1]).trim() : null;
|
|
870
|
+
};
|
|
871
|
+
const bool = (key) => {
|
|
872
|
+
const match = new RegExp(`<key>${key}</key>\\s*<(true|false)\\s*/>`).exec(text);
|
|
873
|
+
return match ? match[1] === 'true' : null;
|
|
874
|
+
};
|
|
875
|
+
let programArguments = [];
|
|
876
|
+
const argsBlock = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(text);
|
|
877
|
+
if (argsBlock) {
|
|
878
|
+
programArguments = [...argsBlock[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((match) =>
|
|
879
|
+
decodeXml(match[1]),
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
return { Label: value('Label'), Program: value('Program'), ProgramArguments: programArguments, Disabled: bool('Disabled') };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Read and parse one launchd plist (XML, JSON or binary).
|
|
887
|
+
* @param {string} file
|
|
888
|
+
* @returns {Promise<{Label: string|null, Program: string|null,
|
|
889
|
+
* ProgramArguments: string[], Disabled: boolean|null}|null>}
|
|
890
|
+
*/
|
|
891
|
+
async function parsePlistFile(file) {
|
|
892
|
+
const buffer = await fsp.readFile(file);
|
|
893
|
+
if (buffer.subarray(0, 6).toString('latin1') === 'bplist') {
|
|
894
|
+
const converted = await runJson(PLUTIL, ['-convert', 'json', '-o', '-', file], { timeout: 5_000 });
|
|
895
|
+
if (!converted.ok || !converted.data || typeof converted.data !== 'object') return null;
|
|
896
|
+
const data = converted.data;
|
|
897
|
+
return {
|
|
898
|
+
Label: str(data.Label),
|
|
899
|
+
Program: str(data.Program),
|
|
900
|
+
ProgramArguments: Array.isArray(data.ProgramArguments) ? data.ProgramArguments.map((item) => String(item)) : [],
|
|
901
|
+
Disabled: typeof data.Disabled === 'boolean' ? data.Disabled : null,
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
const text = buffer.toString('utf8');
|
|
905
|
+
if (text.trim().startsWith('{')) {
|
|
906
|
+
try {
|
|
907
|
+
const data = JSON.parse(text);
|
|
908
|
+
return {
|
|
909
|
+
Label: str(data.Label),
|
|
910
|
+
Program: str(data.Program),
|
|
911
|
+
ProgramArguments: Array.isArray(data.ProgramArguments) ? data.ProgramArguments.map((item) => String(item)) : [],
|
|
912
|
+
Disabled: typeof data.Disabled === 'boolean' ? data.Disabled : null,
|
|
913
|
+
};
|
|
914
|
+
} catch {
|
|
915
|
+
return null;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
return parseXmlPlist(text);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Parse the first `system_profiler -json SPAirPortDataType` network info.
|
|
923
|
+
* @param {any} spData parsed system profiler JSON
|
|
924
|
+
* @returns {{ssid: string|null, bssid: string|null, rssi: number|null,
|
|
925
|
+
* noise: number|null, channel: number|null, security: string|null,
|
|
926
|
+
* txRateMbps: number|null}|null}
|
|
927
|
+
*/
|
|
928
|
+
function parseAirportWifi(spData) {
|
|
929
|
+
const interfaces = spData?.SPAirPortDataType?.[0]?.spairport_airport_interfaces;
|
|
930
|
+
if (!Array.isArray(interfaces)) return null;
|
|
931
|
+
for (const iface of interfaces) {
|
|
932
|
+
const info = iface?.spairport_current_network_information;
|
|
933
|
+
if (!info || typeof info !== 'object') continue;
|
|
934
|
+
const channelText = str(info.spairport_network_channel) ?? '';
|
|
935
|
+
const channelMatch = /(\d+)/.exec(channelText);
|
|
936
|
+
const rate = num(info.spairport_network_rate);
|
|
937
|
+
return {
|
|
938
|
+
ssid: str(info._name) ?? str(info.spairport_network_ssid),
|
|
939
|
+
bssid: firstStr(info, ['spairport_network_bssid', 'spairport_network_BSSID']),
|
|
940
|
+
rssi: firstNum(info, ['spairport_network_rssi', 'spairport_rssi']),
|
|
941
|
+
noise: firstNum(info, ['spairport_network_noise', 'spairport_noise']),
|
|
942
|
+
channel: channelMatch ? Number(channelMatch[1]) : null,
|
|
943
|
+
security: firstStr(info, ['spairport_security_mode', 'spairport_network_security']),
|
|
944
|
+
txRateMbps: rate,
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
return null;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/* -------------------------------------------------------------------------- */
|
|
951
|
+
/* Shared command-backed facts */
|
|
952
|
+
/* -------------------------------------------------------------------------- */
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Static OS/hardware facts used by metrics and the system section.
|
|
956
|
+
* @returns {Promise<{hostname: string, os: {name: string, version: string|null,
|
|
957
|
+
* build: string|null, kernel: string|null, arch: string, release: string|null},
|
|
958
|
+
* model: string|null, modelId: string|null, chip: string|null,
|
|
959
|
+
* serial: string|null, hardwareUuid: string|null, physicalCores: number|null,
|
|
960
|
+
* logicalCores: number, totalMemoryBytes: number, cpuModel: string|null}>}
|
|
961
|
+
*/
|
|
962
|
+
function staticFacts() {
|
|
963
|
+
return memo('staticFacts', 300_000, async () => {
|
|
964
|
+
const [swRes, unameRes, sysctlRes, hwRes] = await Promise.all([
|
|
965
|
+
run(SW_VERS),
|
|
966
|
+
run(UNAME, ['-a']),
|
|
967
|
+
run(SYSCTL, ['-n', 'hw.physicalcpu', 'hw.logicalcpu', 'hw.memsize']),
|
|
968
|
+
runJson(SYSTEM_PROFILER, ['-json', 'SPHardwareDataType'], { timeout: 20_000 }),
|
|
969
|
+
]);
|
|
970
|
+
/** @type {Record<string, string>} */
|
|
971
|
+
const sw = {};
|
|
972
|
+
for (const line of lines(swRes.stdout)) {
|
|
973
|
+
const match = /^([^:]+):\s*(.*)$/.exec(line);
|
|
974
|
+
if (match) sw[match[1].trim()] = match[2].trim();
|
|
975
|
+
}
|
|
976
|
+
const unameText = unameRes.stdout.trim();
|
|
977
|
+
const unameParts = unameText.split(/\s+/);
|
|
978
|
+
const kernelRelease = unameParts[2] ?? null;
|
|
979
|
+
const sysctlValues = lines(sysctlRes.stdout).map((line) => Number(line));
|
|
980
|
+
const physicalCores = num(sysctlValues[0]);
|
|
981
|
+
const logicalCores = num(sysctlValues[1]) ?? os.cpus().length;
|
|
982
|
+
const totalMemoryBytes = num(sysctlValues[2]) ?? os.totalmem();
|
|
983
|
+
const hw = hwRes.ok ? hwRes.data?.SPHardwareDataType?.[0] ?? null : null;
|
|
984
|
+
const cpus = os.cpus();
|
|
985
|
+
return {
|
|
986
|
+
hostname: os.hostname(),
|
|
987
|
+
os: {
|
|
988
|
+
name: sw.ProductName ?? 'macOS',
|
|
989
|
+
version: sw.ProductVersion ?? null,
|
|
990
|
+
build: sw.BuildVersion ?? null,
|
|
991
|
+
kernel: kernelRelease ? `Darwin ${kernelRelease}` : null,
|
|
992
|
+
arch: process.arch,
|
|
993
|
+
release: kernelRelease,
|
|
994
|
+
},
|
|
995
|
+
model: hw ? str(hw.machine_name) : null,
|
|
996
|
+
modelId: hw ? str(hw.machine_model) : null,
|
|
997
|
+
chip: hw ? str(hw.chip_type) : null,
|
|
998
|
+
serial: hw ? str(hw.serial_number) : null,
|
|
999
|
+
hardwareUuid: hw ? str(hw.platform_UUID) : null,
|
|
1000
|
+
physicalCores: physicalCores ?? cpus.length,
|
|
1001
|
+
logicalCores: logicalCores ?? cpus.length,
|
|
1002
|
+
totalMemoryBytes: totalMemoryBytes ?? os.totalmem(),
|
|
1003
|
+
cpuModel: cpus.length ? str(cpus[0].model) : null,
|
|
1004
|
+
};
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Real (non-service) local users plus the admin membership set.
|
|
1010
|
+
* @returns {Promise<{users: Array<{name: string, uid: number, gid: number|null,
|
|
1011
|
+
* fullName: string|null, home: string|null, shell: string|null,
|
|
1012
|
+
* admin: boolean, isCurrent: boolean}>, adminNames: string[],
|
|
1013
|
+
* currentName: string}>}
|
|
1014
|
+
*/
|
|
1015
|
+
function userFacts() {
|
|
1016
|
+
return memo('userFacts', 120_000, async () => {
|
|
1017
|
+
const currentName = os.userInfo().username;
|
|
1018
|
+
const [listRes, adminRes] = await Promise.all([
|
|
1019
|
+
run(DSCL, ['.', '-list', '/Users', 'UniqueID']),
|
|
1020
|
+
run(DSCL, ['.', '-read', '/Groups/admin', 'GroupMembership']),
|
|
1021
|
+
]);
|
|
1022
|
+
/** @type {string[]} */
|
|
1023
|
+
const adminNames = [];
|
|
1024
|
+
const adminMatch = /GroupMembership:\s*(.*)/.exec(adminRes.stdout);
|
|
1025
|
+
if (adminMatch) {
|
|
1026
|
+
adminNames.push(...adminMatch[1].trim().split(/\s+/).filter(Boolean));
|
|
1027
|
+
}
|
|
1028
|
+
/** @type {Array<{name: string, uid: number}>} */
|
|
1029
|
+
const candidates = [];
|
|
1030
|
+
for (const line of lines(listRes.stdout)) {
|
|
1031
|
+
const parts = line.trim().split(/\s+/);
|
|
1032
|
+
if (parts.length < 2) continue;
|
|
1033
|
+
const uid = Number(parts[1]);
|
|
1034
|
+
const name = parts[0];
|
|
1035
|
+
if (!Number.isFinite(uid) || uid < 500) continue;
|
|
1036
|
+
if (name.startsWith('_')) continue;
|
|
1037
|
+
candidates.push({ name, uid });
|
|
1038
|
+
}
|
|
1039
|
+
const users = await Promise.all(
|
|
1040
|
+
candidates.map(async (candidate) => {
|
|
1041
|
+
const detail = await run(DSCL, [
|
|
1042
|
+
'.',
|
|
1043
|
+
'-read',
|
|
1044
|
+
`/Users/${candidate.name}`,
|
|
1045
|
+
'RealName',
|
|
1046
|
+
'NFSHomeDirectory',
|
|
1047
|
+
'UserShell',
|
|
1048
|
+
'PrimaryGroupID',
|
|
1049
|
+
]);
|
|
1050
|
+
/** @type {Record<string, string>} */
|
|
1051
|
+
const attributes = {};
|
|
1052
|
+
let pendingKey = null;
|
|
1053
|
+
for (const rawLine of String(detail.stdout ?? '').split(/\r?\n/)) {
|
|
1054
|
+
if (!rawLine.trim()) continue;
|
|
1055
|
+
const keyed = /^([A-Za-z][A-Za-z0-9]*):\s*(.*)$/.exec(rawLine);
|
|
1056
|
+
if (keyed) {
|
|
1057
|
+
if (keyed[2].trim() !== '') {
|
|
1058
|
+
attributes[keyed[1]] = keyed[2].trim();
|
|
1059
|
+
pendingKey = null;
|
|
1060
|
+
} else {
|
|
1061
|
+
pendingKey = keyed[1];
|
|
1062
|
+
}
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
if (pendingKey) {
|
|
1066
|
+
attributes[pendingKey] = rawLine.trim();
|
|
1067
|
+
pendingKey = null;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
return {
|
|
1071
|
+
name: candidate.name,
|
|
1072
|
+
uid: candidate.uid,
|
|
1073
|
+
gid: num(attributes.PrimaryGroupID),
|
|
1074
|
+
fullName: str(attributes.RealName),
|
|
1075
|
+
home: str(attributes.NFSHomeDirectory),
|
|
1076
|
+
shell: str(attributes.UserShell),
|
|
1077
|
+
admin: adminNames.includes(candidate.name),
|
|
1078
|
+
isCurrent: candidate.name === currentName,
|
|
1079
|
+
};
|
|
1080
|
+
}),
|
|
1081
|
+
);
|
|
1082
|
+
return { users, adminNames, currentName };
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* Cached `ifconfig -a` parse.
|
|
1088
|
+
* @returns {Promise<Iface[]>}
|
|
1089
|
+
*/
|
|
1090
|
+
function interfaces() {
|
|
1091
|
+
return memo('interfaces', 5_000, async () => {
|
|
1092
|
+
const result = await run(IF_CONFIG, ['-a']);
|
|
1093
|
+
return parseIfconfig(result.stdout);
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Cached `route -n get default` parse.
|
|
1099
|
+
* @returns {Promise<{gateway: string|null, interface: string|null}|null>}
|
|
1100
|
+
*/
|
|
1101
|
+
function defaultRoute() {
|
|
1102
|
+
return memo('defaultRoute', 5_000, async () => {
|
|
1103
|
+
const result = await run(ROUTE, ['-n', 'get', 'default']);
|
|
1104
|
+
return parseDefaultRoute(result.stdout);
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Cached `df` of the data volume.
|
|
1110
|
+
* @returns {Promise<{totalBytes: number, usedBytes: number, freeBytes: number}|null>}
|
|
1111
|
+
*/
|
|
1112
|
+
function diskUsage() {
|
|
1113
|
+
return memo('diskUsage', 5_000, async () => {
|
|
1114
|
+
let result = await run(DF, ['-kP', '/System/Volumes/Data']);
|
|
1115
|
+
if (!result.ok) result = await run(DF, ['-kP', '/']);
|
|
1116
|
+
return parseDf(result.stdout);
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Cached process table (shared by metrics and the processes section).
|
|
1122
|
+
* @returns {Promise<ReturnType<typeof parsePs>>}
|
|
1123
|
+
*/
|
|
1124
|
+
function processTable() {
|
|
1125
|
+
return memo('processTable', 2_500, async () => {
|
|
1126
|
+
const result = await run(
|
|
1127
|
+
PS,
|
|
1128
|
+
['-axo', 'pid=,ppid=,user=,%cpu=,%mem=,rss=,state=,etime=,command='],
|
|
1129
|
+
{ timeout: 10_000, maxBuffer: 8 * 1024 * 1024, env: utf8Env() },
|
|
1130
|
+
);
|
|
1131
|
+
if (!result.ok) throw new Error(result.error ?? 'ps failed');
|
|
1132
|
+
return parsePs(result.stdout);
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* Cached Wi-Fi sample for the metrics poll (refreshed in the background).
|
|
1138
|
+
* @returns {{ssid: string|null, bssid: string|null, rssi: number|null,
|
|
1139
|
+
* noise: number|null, channel: number|null, security: string|null,
|
|
1140
|
+
* txRateMbps: number|null}|null}
|
|
1141
|
+
*/
|
|
1142
|
+
function wifiMetricSample() {
|
|
1143
|
+
return fastValue('wifiSample', 60_000, async () => {
|
|
1144
|
+
const result = await runJson(SYSTEM_PROFILER, ['-json', 'SPAirPortDataType'], { timeout: 20_000 });
|
|
1145
|
+
if (!result.ok) return null;
|
|
1146
|
+
return parseAirportWifi(result.data);
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Humanise a system_profiler GPU vendor token.
|
|
1152
|
+
* @param {string|null} vendor
|
|
1153
|
+
* @returns {string|null}
|
|
1154
|
+
*/
|
|
1155
|
+
function humaniseGpuVendor(vendor) {
|
|
1156
|
+
if (!vendor) return null;
|
|
1157
|
+
if (/sppci_vendor_Apple/i.test(vendor)) return 'Apple';
|
|
1158
|
+
const known = vendor.replace(/^sppci_vendor_/i, '');
|
|
1159
|
+
return known || null;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Resolve a display connection kind from the profiler fields.
|
|
1164
|
+
* @param {string|null} connectionType
|
|
1165
|
+
* @param {string|null} displayType
|
|
1166
|
+
* @param {any} screen
|
|
1167
|
+
* @returns {string|null}
|
|
1168
|
+
*/
|
|
1169
|
+
function displayConnection(connectionType, displayType, screen) {
|
|
1170
|
+
if (connectionType && /internal|builtin/i.test(connectionType)) return 'built-in';
|
|
1171
|
+
if (connectionType && /(hdmi|displayport|thunderbolt|dvi|vga|external)/i.test(connectionType)) return 'external';
|
|
1172
|
+
if (displayType && /built-?in|retina/i.test(displayType)) return 'built-in';
|
|
1173
|
+
if (screen && (screen.spdisplays_main === 'spdisplays_yes' || screen._spdisplays_pixels)) return 'external';
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/* -------------------------------------------------------------------------- */
|
|
1178
|
+
/* Section: hardware */
|
|
1179
|
+
/* -------------------------------------------------------------------------- */
|
|
1180
|
+
|
|
1181
|
+
/** Contract cap for `hardware.drivers` (both platforms). */
|
|
1182
|
+
const MAX_DRIVERS = 250;
|
|
1183
|
+
/** Contract cap for `hardware.driverProblems` (Windows only; empty on macOS). */
|
|
1184
|
+
const MAX_DRIVER_PROBLEMS = 50;
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Keep only the calendar date part of an ISO timestamp (the driver contract
|
|
1188
|
+
* uses `yyyy-MM-dd`; a full timestamp is not a deviation worth exposing).
|
|
1189
|
+
* @param {unknown} value
|
|
1190
|
+
* @returns {string|null}
|
|
1191
|
+
*/
|
|
1192
|
+
function isoDateOnly(value) {
|
|
1193
|
+
const text = str(value);
|
|
1194
|
+
if (!text) return null;
|
|
1195
|
+
const match = /^(\d{4}-\d{2}-\d{2})/.exec(text);
|
|
1196
|
+
return match ? match[1] : null;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* Vendor shown in `hardware.drivers[].manufacturer`.
|
|
1201
|
+
*
|
|
1202
|
+
* Apple's own extensions are identified by bundle id / install location; a
|
|
1203
|
+
* third-party extension is either the organisation named in its signing
|
|
1204
|
+
* subject or, when that cannot be parsed, the literal `Third-party` (never a
|
|
1205
|
+
* fabricated company name).
|
|
1206
|
+
* @param {string|null} bundleId
|
|
1207
|
+
* @param {string} extensionPath
|
|
1208
|
+
* @param {string|null} signedBy
|
|
1209
|
+
* @returns {string}
|
|
1210
|
+
*/
|
|
1211
|
+
function darwinDriverVendor(bundleId, extensionPath, signedBy) {
|
|
1212
|
+
if (
|
|
1213
|
+
(bundleId !== null && bundleId.startsWith('com.apple.')) ||
|
|
1214
|
+
/\/System\/Library\//.test(extensionPath) ||
|
|
1215
|
+
/TeamID:\s*@apple/.test(signedBy ?? '')
|
|
1216
|
+
) {
|
|
1217
|
+
return 'Apple';
|
|
1218
|
+
}
|
|
1219
|
+
const subject =
|
|
1220
|
+
/Subject:\s*(?:Developer ID Application|Developer ID Installer|Apple Development|Mac Developer):\s*([^,]+)/.exec(
|
|
1221
|
+
signedBy ?? '',
|
|
1222
|
+
);
|
|
1223
|
+
if (subject) {
|
|
1224
|
+
const vendor = subject[1].replace(/\s*\([A-Z0-9]+\)\s*$/, '').trim();
|
|
1225
|
+
if (vendor) return vendor;
|
|
1226
|
+
}
|
|
1227
|
+
return 'Third-party';
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* Descending version comparison for the driver ordering (`31.0.15` before
|
|
1232
|
+
* `2.4.0`; purely numeric segments so Apple's `1` and `2.4.0` sort sanely).
|
|
1233
|
+
* @param {string|null} left
|
|
1234
|
+
* @param {string|null} right
|
|
1235
|
+
* @returns {number}
|
|
1236
|
+
*/
|
|
1237
|
+
function compareDriverVersions(left, right) {
|
|
1238
|
+
const segments = (value) =>
|
|
1239
|
+
str(value) === null
|
|
1240
|
+
? []
|
|
1241
|
+
: String(value)
|
|
1242
|
+
.split(/[^0-9]+/)
|
|
1243
|
+
.filter((part) => part !== '')
|
|
1244
|
+
.map((part) => Number(part));
|
|
1245
|
+
const a = segments(left);
|
|
1246
|
+
const b = segments(right);
|
|
1247
|
+
const length = Math.max(a.length, b.length);
|
|
1248
|
+
for (let index = 0; index < length; index += 1) {
|
|
1249
|
+
const diff = (b[index] ?? 0) - (a[index] ?? 0);
|
|
1250
|
+
if (diff !== 0) return diff;
|
|
1251
|
+
}
|
|
1252
|
+
return 0;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* Map `SPExtensionsDataType` records to the frozen `hardware.drivers` shape.
|
|
1257
|
+
* `kind` is `driverkit` for DriverKit/system extensions and `kext` otherwise.
|
|
1258
|
+
*
|
|
1259
|
+
* The ordering keeps the most useful rows when the 250-entry cap bites:
|
|
1260
|
+
* third-party extensions first, then loaded ones, then version descending.
|
|
1261
|
+
* `truncated` reports whether the raw list exceeded {@link MAX_DRIVERS}.
|
|
1262
|
+
* @param {unknown} entries
|
|
1263
|
+
* @returns {{drivers: Array<{name: string, class: string|null,
|
|
1264
|
+
* manufacturer: string|null, version: string|null, date: string|null,
|
|
1265
|
+
* signer: string|null, inf: string|null, deviceId: string|null,
|
|
1266
|
+
* kind: 'kext'|'driverkit'|'pnp', problem: string|null}>,
|
|
1267
|
+
* truncated: boolean}}
|
|
1268
|
+
*/
|
|
1269
|
+
function parseDarwinDrivers(entries) {
|
|
1270
|
+
/** @type {Array<{thirdParty: boolean, loaded: boolean, driver: any}>} */
|
|
1271
|
+
const records = [];
|
|
1272
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
1273
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
1274
|
+
const bundleId = str(entry.spext_bundleid);
|
|
1275
|
+
const name = str(entry._name) ?? bundleId;
|
|
1276
|
+
if (name === null) continue;
|
|
1277
|
+
const extensionPath = str(entry.spext_path) ?? '';
|
|
1278
|
+
const signedBy = str(entry.spext_signed_by);
|
|
1279
|
+
const manufacturer = darwinDriverVendor(bundleId, extensionPath, signedBy);
|
|
1280
|
+
records.push({
|
|
1281
|
+
thirdParty: manufacturer !== 'Apple',
|
|
1282
|
+
loaded: str(entry.spext_loaded) === 'spext_yes',
|
|
1283
|
+
driver: {
|
|
1284
|
+
name,
|
|
1285
|
+
class: null,
|
|
1286
|
+
manufacturer,
|
|
1287
|
+
version: str(entry.spext_version) ?? str(entry.version),
|
|
1288
|
+
date: isoDateOnly(entry.spext_lastModified),
|
|
1289
|
+
signer: signedBy,
|
|
1290
|
+
inf: null,
|
|
1291
|
+
// macOS has no PnP instance id; the extension's bundle identifier is
|
|
1292
|
+
// the stable identity we can report honestly.
|
|
1293
|
+
deviceId: bundleId,
|
|
1294
|
+
kind: /\.dext\/?$|DriverExtensions|SystemExtensions/i.test(extensionPath) ? 'driverkit' : 'kext',
|
|
1295
|
+
problem: null,
|
|
1296
|
+
},
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
records.sort((left, right) => {
|
|
1300
|
+
if (left.thirdParty !== right.thirdParty) return left.thirdParty ? -1 : 1;
|
|
1301
|
+
if (left.loaded !== right.loaded) return left.loaded ? -1 : 1;
|
|
1302
|
+
const versionDiff = compareDriverVersions(left.driver.version, right.driver.version);
|
|
1303
|
+
if (versionDiff !== 0) return versionDiff;
|
|
1304
|
+
return (left.driver.name ?? '').localeCompare(right.driver.name ?? '');
|
|
1305
|
+
});
|
|
1306
|
+
const truncated = records.length > MAX_DRIVERS;
|
|
1307
|
+
return { drivers: records.slice(0, MAX_DRIVERS).map((record) => record.driver), truncated };
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* Derive the driver summary from the returned (capped) arrays.
|
|
1312
|
+
* @param {Array<{manufacturer: string|null}>} drivers
|
|
1313
|
+
* @param {Array<object>} problems
|
|
1314
|
+
* @param {boolean} [truncated]
|
|
1315
|
+
* @returns {{total: number, thirdParty: number, problems: number, truncated: boolean}}
|
|
1316
|
+
*/
|
|
1317
|
+
function driverSummaryOf(drivers, problems, truncated = false) {
|
|
1318
|
+
return {
|
|
1319
|
+
total: drivers.length,
|
|
1320
|
+
thirdParty: drivers.filter((driver) => driver.manufacturer !== 'Apple').length,
|
|
1321
|
+
problems: problems.length,
|
|
1322
|
+
truncated,
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/**
|
|
1327
|
+
* @returns {Promise<object>} the frozen hardware section shape.
|
|
1328
|
+
*/
|
|
1329
|
+
async function buildHardware() {
|
|
1330
|
+
const errors = [];
|
|
1331
|
+
const [profiler, sysctlRes, ioregBattery] = await Promise.all([
|
|
1332
|
+
runJson(SYSTEM_PROFILER, ['-json', ...HARDWARE_PROFILER_TYPES], { timeout: 30_000, maxBuffer: 24 * 1024 * 1024 }),
|
|
1333
|
+
run(SYSCTL, ['-n', 'hw.physicalcpu', 'hw.logicalcpu', 'hw.memsize']),
|
|
1334
|
+
run(IOREG, ['-rn', 'AppleSmartBattery'], { timeout: 5_000 }),
|
|
1335
|
+
]);
|
|
1336
|
+
const sp = profiler.ok && profiler.data && typeof profiler.data === 'object' ? profiler.data : {};
|
|
1337
|
+
if (!profiler.ok) errors.push(`system_profiler hardware: ${shortErr(profiler.error)}`);
|
|
1338
|
+
|
|
1339
|
+
const sysctlValues = lines(sysctlRes.stdout).map((line) => Number(line));
|
|
1340
|
+
const cpus = os.cpus();
|
|
1341
|
+
const physicalCores = num(sysctlValues[0]) ?? cpus.length;
|
|
1342
|
+
const logicalCores = num(sysctlValues[1]) ?? cpus.length;
|
|
1343
|
+
const totalMemoryBytes = num(sysctlValues[2]) ?? os.totalmem();
|
|
1344
|
+
const hw = sp.SPHardwareDataType?.[0] ?? {};
|
|
1345
|
+
const memoryInfo = sp.SPMemoryDataType?.[0] ?? {};
|
|
1346
|
+
|
|
1347
|
+
const cpu = {
|
|
1348
|
+
model: str(hw.chip_type) ?? str(cpus.length ? cpus[0].model : null),
|
|
1349
|
+
vendor: 'Apple',
|
|
1350
|
+
physicalCores,
|
|
1351
|
+
logicalCores,
|
|
1352
|
+
speedGHz: cpus.length && cpus[0].speed ? round(cpus[0].speed / 1000, 1) : null,
|
|
1353
|
+
architecture: process.arch,
|
|
1354
|
+
features: [],
|
|
1355
|
+
};
|
|
1356
|
+
|
|
1357
|
+
const modules = [];
|
|
1358
|
+
const memoryType = str(memoryInfo.dimm_type);
|
|
1359
|
+
modules.push({
|
|
1360
|
+
slot: memoryType ? 'Unified Memory' : 'Memory',
|
|
1361
|
+
sizeBytes: totalMemoryBytes,
|
|
1362
|
+
type: memoryType,
|
|
1363
|
+
speedMHz: num(memoryInfo.dimm_speed) ? Number(memoryInfo.dimm_speed) : null,
|
|
1364
|
+
manufacturer: str(memoryInfo.dimm_manufacturer),
|
|
1365
|
+
partNumber: str(memoryInfo.dimm_part_number),
|
|
1366
|
+
});
|
|
1367
|
+
const memory = {
|
|
1368
|
+
totalBytes: totalMemoryBytes,
|
|
1369
|
+
type: memoryType,
|
|
1370
|
+
speedMHz: modules[0].speedMHz,
|
|
1371
|
+
modules,
|
|
1372
|
+
};
|
|
1373
|
+
|
|
1374
|
+
/** @type {Array<{name: string|null, cores: number|null, vramBytes: number|null, vendor: string|null, metal: string|null}>} */
|
|
1375
|
+
const gpu = [];
|
|
1376
|
+
/** @type {Array<{name: string|null, resolution: string|null, refreshHz: number|null, main: boolean, connection: string|null}>} */
|
|
1377
|
+
const displays = [];
|
|
1378
|
+
for (const card of Array.isArray(sp.SPDisplaysDataType) ? sp.SPDisplaysDataType : []) {
|
|
1379
|
+
if (!card || typeof card !== 'object') continue;
|
|
1380
|
+
gpu.push({
|
|
1381
|
+
name: firstStr(card, ['sppci_model', '_name']),
|
|
1382
|
+
cores: num(card.sppci_cores),
|
|
1383
|
+
vramBytes: bytesFromText(card.sppci_vram) ?? bytesFromText(card.spdisplays_vram),
|
|
1384
|
+
vendor: humaniseGpuVendor(firstStr(card, ['spdisplays_vendor', 'sppci_vendor'])),
|
|
1385
|
+
metal: str(card.spdisplays_mtlgpufamilysupport),
|
|
1386
|
+
});
|
|
1387
|
+
const screens = Array.isArray(card.spdisplays_ndrvs) ? card.spdisplays_ndrvs : [];
|
|
1388
|
+
for (const screen of screens) {
|
|
1389
|
+
if (!screen || typeof screen !== 'object') continue;
|
|
1390
|
+
const resolutionRaw = firstStr(screen, ['_spdisplays_pixels', 'spdisplays_resolution']);
|
|
1391
|
+
const resolution = resolutionRaw ? resolutionRaw.replace(/\s*@.*$/, '').replace(/\s*[x×]\s*/i, 'x') : null;
|
|
1392
|
+
const refresh = /@\s*([\d.]+)\s*Hz/i.exec(firstStr(screen, ['spdisplays_resolution']) ?? '');
|
|
1393
|
+
const connectionType = str(screen.spdisplays_connection_type);
|
|
1394
|
+
displays.push({
|
|
1395
|
+
name: str(screen._name),
|
|
1396
|
+
resolution,
|
|
1397
|
+
refreshHz: refresh ? Number(refresh[1]) : null,
|
|
1398
|
+
main: screen.spdisplays_main === 'spdisplays_yes',
|
|
1399
|
+
connection: displayConnection(connectionType, str(screen.spdisplays_display_type), screen),
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/** @type {Map<string, {name: string|null, model: string|null, sizeBytes: number|null, mediaType: string|null, smartStatus: string|null}>} */
|
|
1405
|
+
const physicalDrives = new Map();
|
|
1406
|
+
/** @type {Array<{name: string|null, mount: string|null, fs: string|null, totalBytes: number|null, usedBytes: number|null, freeBytes: number|null, removable: boolean|null, internal: boolean|null}>} */
|
|
1407
|
+
const volumes = [];
|
|
1408
|
+
for (const volume of Array.isArray(sp.SPStorageDataType) ? sp.SPStorageDataType : []) {
|
|
1409
|
+
if (!volume || typeof volume !== 'object') continue;
|
|
1410
|
+
const drive = volume.physical_drive ?? {};
|
|
1411
|
+
const deviceName = str(drive.device_name);
|
|
1412
|
+
const sizeBytes = num(volume.size_in_bytes);
|
|
1413
|
+
const freeBytes = num(volume.free_space_in_bytes);
|
|
1414
|
+
const isInternal = drive.is_internal_disk === 'yes' ? true : drive.is_internal_disk === 'no' ? false : null;
|
|
1415
|
+
volumes.push({
|
|
1416
|
+
name: str(volume._name),
|
|
1417
|
+
mount: str(volume.mount_point),
|
|
1418
|
+
fs: str(volume.file_system),
|
|
1419
|
+
totalBytes: sizeBytes,
|
|
1420
|
+
usedBytes: sizeBytes !== null && freeBytes !== null ? Math.max(0, sizeBytes - freeBytes) : null,
|
|
1421
|
+
freeBytes,
|
|
1422
|
+
removable: isInternal === null ? null : !isInternal,
|
|
1423
|
+
internal: isInternal,
|
|
1424
|
+
});
|
|
1425
|
+
if (deviceName) {
|
|
1426
|
+
const existing = physicalDrives.get(deviceName);
|
|
1427
|
+
const candidate = {
|
|
1428
|
+
name: str(drive.device_name),
|
|
1429
|
+
model: str(drive.media_name) ?? deviceName,
|
|
1430
|
+
sizeBytes,
|
|
1431
|
+
mediaType: str(drive.medium_type),
|
|
1432
|
+
smartStatus: str(drive.smart_status),
|
|
1433
|
+
};
|
|
1434
|
+
if (!existing || (sizeBytes ?? 0) > (existing.sizeBytes ?? 0)) physicalDrives.set(deviceName, candidate);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
let battery = null;
|
|
1439
|
+
const powerInfo = Array.isArray(sp.SPPowerDataType)
|
|
1440
|
+
? sp.SPPowerDataType.find((entry) => entry && entry._name === 'spbattery_information')
|
|
1441
|
+
: null;
|
|
1442
|
+
const ioreg = parseIoregBattery(ioregBattery.stdout);
|
|
1443
|
+
if (powerInfo || hasBattery(ioreg)) {
|
|
1444
|
+
const charge = powerInfo?.sppower_battery_charge_info ?? {};
|
|
1445
|
+
const health = powerInfo?.sppower_battery_health_info ?? {};
|
|
1446
|
+
const percent = num(charge.sppower_battery_state_of_charge) ?? ioreg.currentCapacity;
|
|
1447
|
+
battery = {
|
|
1448
|
+
percent,
|
|
1449
|
+
condition: str(health.sppower_battery_health),
|
|
1450
|
+
cycleCount: num(health.sppower_battery_cycle_count) ?? ioreg.cycleCount,
|
|
1451
|
+
designCapacityMah: ioreg.designCapacity,
|
|
1452
|
+
maxCapacityMah: ioreg.rawMaxCapacity,
|
|
1453
|
+
temperatureC: ioreg.temperatureC,
|
|
1454
|
+
healthPercent: batteryHealth(ioreg),
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/** @type {Array<{name: string|null, vendor: string|null, productId: string|null, serial: string|null, speed: string|null, bus: string|null}>} */
|
|
1459
|
+
const usb = [];
|
|
1460
|
+
/** @type {Array<{name: string|null, address: string|null, connected: boolean, batteryPercent: number|null, type: string|null}>} */
|
|
1461
|
+
const bluetoothDevices = [];
|
|
1462
|
+
let bluetoothController = null;
|
|
1463
|
+
for (const root of Array.isArray(sp.SPUSBDataType) ? sp.SPUSBDataType : []) {
|
|
1464
|
+
if (!root || typeof root !== 'object') continue;
|
|
1465
|
+
const busName = str(root.host_controller) ? str(root._name) : null;
|
|
1466
|
+
flattenUsb([root], busName, usb);
|
|
1467
|
+
}
|
|
1468
|
+
const btEntry = Array.isArray(sp.SPBluetoothDataType) ? sp.SPBluetoothDataType[0] : null;
|
|
1469
|
+
if (btEntry && typeof btEntry === 'object') {
|
|
1470
|
+
const controller = btEntry.controller_properties ?? {};
|
|
1471
|
+
bluetoothController = {
|
|
1472
|
+
address: str(controller.controller_address),
|
|
1473
|
+
chipset: str(controller.controller_chipset),
|
|
1474
|
+
firmware: str(controller.controller_firmwareVersion),
|
|
1475
|
+
state: mapBluetoothState(str(controller.controller_state)),
|
|
1476
|
+
};
|
|
1477
|
+
bluetoothDevices.push(...parseBluetoothGroup(btEntry.device_connected, true));
|
|
1478
|
+
bluetoothDevices.push(...parseBluetoothGroup(btEntry.device_not_connected, false));
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/** @type {Array<{name: string|null, kind: 'output'|'input', manufacturer: string|null, default: boolean}>} */
|
|
1482
|
+
const audio = [];
|
|
1483
|
+
const audioItems = Array.isArray(sp.SPAudioDataType) ? sp.SPAudioDataType[0]?._items ?? [] : [];
|
|
1484
|
+
for (const item of Array.isArray(audioItems) ? audioItems : []) {
|
|
1485
|
+
if (!item || typeof item !== 'object') continue;
|
|
1486
|
+
const isOutput = item.coreaudio_device_output !== undefined || item.coreaudio_output_source !== undefined;
|
|
1487
|
+
const isInput = item.coreaudio_device_input !== undefined || item.coreaudio_input_source !== undefined;
|
|
1488
|
+
const isDefault =
|
|
1489
|
+
item.coreaudio_default_audio_output_device === 'spaudio_yes' ||
|
|
1490
|
+
item.coreaudio_default_audio_system_device === 'spaudio_yes' ||
|
|
1491
|
+
item.coreaudio_default_audio_input_device === 'spaudio_yes';
|
|
1492
|
+
audio.push({
|
|
1493
|
+
name: str(item._name),
|
|
1494
|
+
kind: isOutput ? 'output' : 'input',
|
|
1495
|
+
manufacturer: str(item.coreaudio_device_manufacturer),
|
|
1496
|
+
default: isDefault && (isOutput || isInput),
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/** @type {Array<{name: string|null, modelId: string|null, uniqueId: string|null}>} */
|
|
1501
|
+
const cameras = [];
|
|
1502
|
+
for (const camera of Array.isArray(sp.SPCameraDataType) ? sp.SPCameraDataType : []) {
|
|
1503
|
+
if (!camera || typeof camera !== 'object') continue;
|
|
1504
|
+
cameras.push({
|
|
1505
|
+
name: str(camera._name),
|
|
1506
|
+
modelId: str(camera['spcamera_model-id']),
|
|
1507
|
+
uniqueId: str(camera['spcamera_unique-id']),
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// Installed kernel- and DriverKit extensions (SPExtensionsDataType). macOS
|
|
1512
|
+
// does not expose per-device ConfigManagerErrorCode, so `problem` stays null
|
|
1513
|
+
// and `driverProblems` is empty; matching Windows keeps the section shape.
|
|
1514
|
+
const parsedDrivers = parseDarwinDrivers(sp.SPExtensionsDataType);
|
|
1515
|
+
const drivers = parsedDrivers.drivers;
|
|
1516
|
+
/** @type {Array<{name: string, code: string, class: string|null}>} */
|
|
1517
|
+
const driverProblems = [];
|
|
1518
|
+
|
|
1519
|
+
return {
|
|
1520
|
+
cpu,
|
|
1521
|
+
memory,
|
|
1522
|
+
gpu,
|
|
1523
|
+
displays,
|
|
1524
|
+
storage: { physical: [...physicalDrives.values()], volumes },
|
|
1525
|
+
battery,
|
|
1526
|
+
usb,
|
|
1527
|
+
bluetooth: { controller: bluetoothController, devices: bluetoothDevices },
|
|
1528
|
+
audio,
|
|
1529
|
+
cameras,
|
|
1530
|
+
drivers,
|
|
1531
|
+
driverSummary: driverSummaryOf(drivers, driverProblems, parsedDrivers.truncated),
|
|
1532
|
+
driverProblems,
|
|
1533
|
+
errors,
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/**
|
|
1538
|
+
* True when a battery is physically present.
|
|
1539
|
+
* @param {ReturnType<typeof parseIoregBattery>} battery
|
|
1540
|
+
* @returns {boolean}
|
|
1541
|
+
*/
|
|
1542
|
+
function hasBattery(battery) {
|
|
1543
|
+
return battery.present === true;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
/**
|
|
1547
|
+
* Flatten the nested `SPUSBDataType` tree.
|
|
1548
|
+
* @param {any[]} nodes
|
|
1549
|
+
* @param {string|null} bus
|
|
1550
|
+
* @param {any[]} out
|
|
1551
|
+
* @returns {void}
|
|
1552
|
+
*/
|
|
1553
|
+
function flattenUsb(nodes, bus, out) {
|
|
1554
|
+
for (const node of Array.isArray(nodes) ? nodes : []) {
|
|
1555
|
+
if (!node || typeof node !== 'object') continue;
|
|
1556
|
+
const children = node._items;
|
|
1557
|
+
const childBus = bus ?? (typeof node._name === 'string' ? node._name : null);
|
|
1558
|
+
const hasIdentity = Boolean(node.product_id ?? node.vendor_id ?? node.serial_num);
|
|
1559
|
+
if (hasIdentity) {
|
|
1560
|
+
out.push({
|
|
1561
|
+
name: str(node._name),
|
|
1562
|
+
vendor: str(node.manufacturer) ?? str(node.vendor_id),
|
|
1563
|
+
productId: str(node.product_id),
|
|
1564
|
+
serial: str(node.serial_num),
|
|
1565
|
+
speed: mapUsbSpeed(str(node.device_speed)),
|
|
1566
|
+
bus,
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
if (Array.isArray(children)) flattenUsb(children, childBus, out);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
/**
|
|
1574
|
+
* @param {string|null} speed
|
|
1575
|
+
* @returns {string|null}
|
|
1576
|
+
*/
|
|
1577
|
+
function mapUsbSpeed(speed) {
|
|
1578
|
+
switch (speed) {
|
|
1579
|
+
case 'low_speed':
|
|
1580
|
+
return '1.5 Mbps';
|
|
1581
|
+
case 'full_speed':
|
|
1582
|
+
return '12 Mbps';
|
|
1583
|
+
case 'high_speed':
|
|
1584
|
+
return '480 Mbps';
|
|
1585
|
+
case 'super_speed':
|
|
1586
|
+
return '5 Gbps';
|
|
1587
|
+
case 'super_speed_plus':
|
|
1588
|
+
return '10 Gbps';
|
|
1589
|
+
default:
|
|
1590
|
+
return speed;
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
/**
|
|
1595
|
+
* @param {string|null} state
|
|
1596
|
+
* @returns {string|null}
|
|
1597
|
+
*/
|
|
1598
|
+
function mapBluetoothState(state) {
|
|
1599
|
+
if (!state) return null;
|
|
1600
|
+
if (/attrib_on|on$/i.test(state)) return 'on';
|
|
1601
|
+
if (/attrib_off|off$/i.test(state)) return 'off';
|
|
1602
|
+
return state;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
/**
|
|
1606
|
+
* @param {any} group
|
|
1607
|
+
* @param {boolean} connected
|
|
1608
|
+
* @returns {Array<{name: string|null, address: string|null, connected: boolean,
|
|
1609
|
+
* batteryPercent: number|null, type: string|null}>}
|
|
1610
|
+
*/
|
|
1611
|
+
function parseBluetoothGroup(group, connected) {
|
|
1612
|
+
/** @type {Array<{name: string|null, address: string|null, connected: boolean, batteryPercent: number|null, type: string|null}>} */
|
|
1613
|
+
const out = [];
|
|
1614
|
+
for (const entry of Array.isArray(group) ? group : []) {
|
|
1615
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
1616
|
+
for (const [name, info] of Object.entries(entry)) {
|
|
1617
|
+
if (!info || typeof info !== 'object') continue;
|
|
1618
|
+
out.push({
|
|
1619
|
+
name: str(name),
|
|
1620
|
+
address: str(info.device_address),
|
|
1621
|
+
connected,
|
|
1622
|
+
batteryPercent: firstNum(info, [
|
|
1623
|
+
'device_batteryLevel',
|
|
1624
|
+
'device_batteryPercent',
|
|
1625
|
+
'device_batteryLevelCase',
|
|
1626
|
+
'device_batteryLevelLeft',
|
|
1627
|
+
'device_batteryLevelRight',
|
|
1628
|
+
]),
|
|
1629
|
+
type: firstStr(info, ['device_minorType', 'device_type', 'device_majorType']),
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
return out;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
/* -------------------------------------------------------------------------- */
|
|
1637
|
+
/* Section: system */
|
|
1638
|
+
/* -------------------------------------------------------------------------- */
|
|
1639
|
+
|
|
1640
|
+
/**
|
|
1641
|
+
* @returns {Promise<object>} the frozen system section shape.
|
|
1642
|
+
*/
|
|
1643
|
+
async function buildSystem() {
|
|
1644
|
+
const errors = [];
|
|
1645
|
+
const [facts, users, launchAgents, loginItems, localeInfo, envInfo, bootRes] = await Promise.all([
|
|
1646
|
+
staticFacts(),
|
|
1647
|
+
userFacts(),
|
|
1648
|
+
collectLaunchAgents(errors),
|
|
1649
|
+
collectLoginItems(errors),
|
|
1650
|
+
collectLocale(errors),
|
|
1651
|
+
Promise.resolve(collectEnvironment()),
|
|
1652
|
+
run(SYSCTL, ['-n', 'kern.boottime'], { timeout: 5_000 }),
|
|
1653
|
+
]);
|
|
1654
|
+
|
|
1655
|
+
const [computerRes, localHostRes, hostRes] = await Promise.all([
|
|
1656
|
+
run(SCUTIL, ['--get', 'ComputerName'], { timeout: 5_000 }),
|
|
1657
|
+
run(SCUTIL, ['--get', 'LocalHostName'], { timeout: 5_000 }),
|
|
1658
|
+
run(SCUTIL, ['--get', 'HostName'], { timeout: 5_000 }),
|
|
1659
|
+
]);
|
|
1660
|
+
|
|
1661
|
+
const bootMatch = /sec = (\d+)/.exec(bootRes.stdout);
|
|
1662
|
+
const bootTime = bootMatch ? Number(bootMatch[1]) * 1000 : null;
|
|
1663
|
+
const currentUser = users.users.find((user) => user.isCurrent) ?? null;
|
|
1664
|
+
|
|
1665
|
+
return {
|
|
1666
|
+
os: {
|
|
1667
|
+
name: facts.os.name,
|
|
1668
|
+
version: facts.os.version,
|
|
1669
|
+
build: facts.os.build,
|
|
1670
|
+
kernel: facts.os.kernel,
|
|
1671
|
+
arch: facts.os.arch,
|
|
1672
|
+
release: facts.os.release,
|
|
1673
|
+
},
|
|
1674
|
+
device: {
|
|
1675
|
+
hostname: facts.hostname,
|
|
1676
|
+
computerName: str(computerRes.stdout) ?? facts.model,
|
|
1677
|
+
localHostname: str(localHostRes.stdout),
|
|
1678
|
+
model: facts.model,
|
|
1679
|
+
modelId: facts.modelId,
|
|
1680
|
+
chip: facts.chip,
|
|
1681
|
+
serial: facts.serial,
|
|
1682
|
+
hardwareUuid: facts.hardwareUuid,
|
|
1683
|
+
},
|
|
1684
|
+
boot: {
|
|
1685
|
+
bootTime,
|
|
1686
|
+
uptimeSec: Math.floor(os.uptime()),
|
|
1687
|
+
},
|
|
1688
|
+
user: currentUser
|
|
1689
|
+
? {
|
|
1690
|
+
name: currentUser.name,
|
|
1691
|
+
fullName: currentUser.fullName,
|
|
1692
|
+
uid: currentUser.uid,
|
|
1693
|
+
gid: currentUser.gid,
|
|
1694
|
+
home: currentUser.home,
|
|
1695
|
+
shell: currentUser.shell,
|
|
1696
|
+
admin: currentUser.admin,
|
|
1697
|
+
}
|
|
1698
|
+
: null,
|
|
1699
|
+
users: users.users,
|
|
1700
|
+
launchAgents,
|
|
1701
|
+
loginItems,
|
|
1702
|
+
environment: envInfo,
|
|
1703
|
+
locale: localeInfo,
|
|
1704
|
+
errors,
|
|
1705
|
+
};
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
/**
|
|
1709
|
+
* Enumerate launch agents/daemons from the user and system domains.
|
|
1710
|
+
* @param {string[]} errors
|
|
1711
|
+
* @returns {Promise<Array<{label: string|null, path: string, scope: 'user'|'system',
|
|
1712
|
+
* loaded: boolean, pid: number|null, lastExitStatus: number|null,
|
|
1713
|
+
* program: string}>>}
|
|
1714
|
+
*/
|
|
1715
|
+
async function collectLaunchAgents(errors) {
|
|
1716
|
+
const home = os.homedir();
|
|
1717
|
+
const directories = [
|
|
1718
|
+
{ dir: path.join(home, 'Library', 'LaunchAgents'), scope: 'user' },
|
|
1719
|
+
{ dir: '/Library/LaunchAgents', scope: 'system' },
|
|
1720
|
+
{ dir: '/Library/LaunchDaemons', scope: 'system' },
|
|
1721
|
+
];
|
|
1722
|
+
|
|
1723
|
+
/** @type {string[]} */
|
|
1724
|
+
const plistPaths = [];
|
|
1725
|
+
for (const { dir } of directories) {
|
|
1726
|
+
try {
|
|
1727
|
+
const entries = await fsp.readdir(dir);
|
|
1728
|
+
for (const entry of entries) {
|
|
1729
|
+
if (entry.endsWith('.plist')) plistPaths.push(path.join(dir, entry));
|
|
1730
|
+
}
|
|
1731
|
+
} catch {
|
|
1732
|
+
/* missing directory is normal */
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
const [listRes] = await Promise.all([run(LAUNCHCTL, ['list'], { timeout: 10_000 })]);
|
|
1737
|
+
const loadedMap = listRes.ok ? parseLaunchctlList(listRes.stdout) : new Map();
|
|
1738
|
+
if (!listRes.ok) errors.push(`launchctl list: ${shortErr(listRes.error)}`);
|
|
1739
|
+
|
|
1740
|
+
const agents = await Promise.all(
|
|
1741
|
+
plistPaths.map(async (plistPath) => {
|
|
1742
|
+
const scope = /** @type {'user'|'system'} */ (plistPath.startsWith(home) ? 'user' : 'system');
|
|
1743
|
+
let parsed = null;
|
|
1744
|
+
try {
|
|
1745
|
+
parsed = await parsePlistFile(plistPath);
|
|
1746
|
+
} catch (err) {
|
|
1747
|
+
errors.push(`launch agent ${path.basename(plistPath)}: ${shortErr(err instanceof Error ? err.message : err)}`);
|
|
1748
|
+
}
|
|
1749
|
+
const label = parsed?.Label ?? null;
|
|
1750
|
+
const program =
|
|
1751
|
+
parsed?.Program ?? (parsed?.ProgramArguments?.length ? String(parsed.ProgramArguments[0]) : null);
|
|
1752
|
+
const entry = label ? loadedMap.get(label) : undefined;
|
|
1753
|
+
let loaded = Boolean(entry);
|
|
1754
|
+
let pid = entry?.pid ?? null;
|
|
1755
|
+
let lastExitStatus = entry?.status ?? null;
|
|
1756
|
+
|
|
1757
|
+
if (scope === 'system' && label && plistPath.startsWith('/Library/LaunchDaemons')) {
|
|
1758
|
+
const printed = await run(LAUNCHCTL, ['print', `system/${label}`], { timeout: 5_000 });
|
|
1759
|
+
if (printed.ok) {
|
|
1760
|
+
loaded = true;
|
|
1761
|
+
const pidMatch = /^\s*pid = (\d+)/m.exec(printed.stdout);
|
|
1762
|
+
if (pidMatch) pid = Number(pidMatch[1]);
|
|
1763
|
+
const exitMatch = /last exit code = (\d+)/.exec(printed.stdout);
|
|
1764
|
+
if (exitMatch) lastExitStatus = Number(exitMatch[1]);
|
|
1765
|
+
} else if (!entry) {
|
|
1766
|
+
loaded = false;
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
return {
|
|
1771
|
+
label,
|
|
1772
|
+
path: plistPath,
|
|
1773
|
+
scope,
|
|
1774
|
+
loaded,
|
|
1775
|
+
pid,
|
|
1776
|
+
lastExitStatus,
|
|
1777
|
+
program: program ?? '',
|
|
1778
|
+
};
|
|
1779
|
+
}),
|
|
1780
|
+
);
|
|
1781
|
+
|
|
1782
|
+
agents.sort((left, right) => {
|
|
1783
|
+
if (left.scope !== right.scope) return left.scope === 'user' ? -1 : 1;
|
|
1784
|
+
return (left.label ?? left.path).localeCompare(right.label ?? right.path);
|
|
1785
|
+
});
|
|
1786
|
+
return agents;
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/**
|
|
1790
|
+
* Login items through System Events (best effort; needs automation permission).
|
|
1791
|
+
* @param {string[]} errors
|
|
1792
|
+
* @returns {Promise<Array<{name: string}>>}
|
|
1793
|
+
*/
|
|
1794
|
+
async function collectLoginItems(errors) {
|
|
1795
|
+
const result = await run(
|
|
1796
|
+
OSASCRIPT,
|
|
1797
|
+
['-e', 'tell application "System Events" to get the name of every login item'],
|
|
1798
|
+
{ timeout: 5_000 },
|
|
1799
|
+
);
|
|
1800
|
+
if (!result.ok) {
|
|
1801
|
+
errors.push(`login items: ${shortErr(result.stderr || result.error)}`);
|
|
1802
|
+
return [];
|
|
1803
|
+
}
|
|
1804
|
+
const text = result.stdout.trim();
|
|
1805
|
+
if (!text) return [];
|
|
1806
|
+
return text
|
|
1807
|
+
.split(/,\s*/)
|
|
1808
|
+
.map((name) => name.trim())
|
|
1809
|
+
.filter(Boolean)
|
|
1810
|
+
.map((name) => ({ name }));
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* Locale/timezone facts.
|
|
1815
|
+
* @param {string[]} errors
|
|
1816
|
+
* @returns {Promise<{timezone: string|null, locale: string|null, language: string|null}>}
|
|
1817
|
+
*/
|
|
1818
|
+
async function collectLocale(errors) {
|
|
1819
|
+
const [localeRes, languagesRes] = await Promise.all([
|
|
1820
|
+
run(DEFAULTS, ['read', '-g', 'AppleLocale'], { timeout: 5_000 }),
|
|
1821
|
+
run(DEFAULTS, ['read', '-g', 'AppleLanguages'], { timeout: 5_000 }),
|
|
1822
|
+
]);
|
|
1823
|
+
const locale = str(localeRes.stdout);
|
|
1824
|
+
if (!localeRes.ok) errors.push(`AppleLocale: ${shortErr(localeRes.error)}`);
|
|
1825
|
+
let language = null;
|
|
1826
|
+
const quoted = /"([^"]+)"/.exec(languagesRes.stdout);
|
|
1827
|
+
if (quoted) language = quoted[1];
|
|
1828
|
+
if (!language) {
|
|
1829
|
+
const envLang = str(process.env.LANG);
|
|
1830
|
+
if (envLang) language = envLang.split('.')[0];
|
|
1831
|
+
}
|
|
1832
|
+
let timezone = null;
|
|
1833
|
+
try {
|
|
1834
|
+
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? null;
|
|
1835
|
+
} catch {
|
|
1836
|
+
timezone = null;
|
|
1837
|
+
}
|
|
1838
|
+
return { timezone, locale, language };
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
/**
|
|
1842
|
+
* The whitelisted environment table (only these keys leave the process).
|
|
1843
|
+
* @returns {{PATH: string|null, SHELL: string|null, HOME: string|null,
|
|
1844
|
+
* USER: string|null, LANG: string|null, TERM: string|null}}
|
|
1845
|
+
*/
|
|
1846
|
+
function collectEnvironment() {
|
|
1847
|
+
return {
|
|
1848
|
+
PATH: str(process.env.PATH),
|
|
1849
|
+
SHELL: str(process.env.SHELL),
|
|
1850
|
+
HOME: str(process.env.HOME),
|
|
1851
|
+
USER: str(process.env.USER),
|
|
1852
|
+
LANG: str(process.env.LANG),
|
|
1853
|
+
TERM: str(process.env.TERM),
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/* -------------------------------------------------------------------------- */
|
|
1858
|
+
/* Section: software */
|
|
1859
|
+
/* -------------------------------------------------------------------------- */
|
|
1860
|
+
|
|
1861
|
+
/**
|
|
1862
|
+
* Parse one `mdls` batch output and attach facts to the matching paths.
|
|
1863
|
+
* @param {string} stdout
|
|
1864
|
+
* @param {string[]} paths
|
|
1865
|
+
* @param {Map<string, {bundleId: string|null, sizeBytes: number|null, lastUsed: number|null}>} target
|
|
1866
|
+
* @returns {void}
|
|
1867
|
+
*/
|
|
1868
|
+
function mergeMdlsBatch(stdout, paths, target) {
|
|
1869
|
+
/** @type {Array<{bundleId: string|null, sizeBytes: number|null, lastUsed: number|null}>} */
|
|
1870
|
+
const records = [];
|
|
1871
|
+
/** @type {{bundleId: string|null, sizeBytes: number|null, lastUsed: number|null}|null} */
|
|
1872
|
+
let current = null;
|
|
1873
|
+
for (const rawLine of String(stdout ?? '').split(/\r?\n/)) {
|
|
1874
|
+
const line = rawLine.trim();
|
|
1875
|
+
if (!line) continue;
|
|
1876
|
+
const match = /^kMDItem([A-Za-z]+)\s*=\s*(.*)$/.exec(line);
|
|
1877
|
+
if (!match) continue;
|
|
1878
|
+
const attribute = match[1];
|
|
1879
|
+
const rawValue = match[2].trim();
|
|
1880
|
+
const quoted = /^"(.*)"$/.exec(rawValue);
|
|
1881
|
+
const value = quoted ? quoted[1] : rawValue;
|
|
1882
|
+
const isNull = value === '(null)' || value === 'null' || value === '';
|
|
1883
|
+
if (attribute === 'CFBundleIdentifier') {
|
|
1884
|
+
current = { bundleId: isNull ? null : value, sizeBytes: null, lastUsed: null };
|
|
1885
|
+
records.push(current);
|
|
1886
|
+
continue;
|
|
1887
|
+
}
|
|
1888
|
+
if (!current) continue;
|
|
1889
|
+
if (attribute === 'FSSize') {
|
|
1890
|
+
const size = num(value);
|
|
1891
|
+
current.sizeBytes = size !== null ? size : null;
|
|
1892
|
+
continue;
|
|
1893
|
+
}
|
|
1894
|
+
if (attribute === 'LastUsedDate') {
|
|
1895
|
+
current.lastUsed = isNull ? null : parseMdlsDate(value);
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
for (let index = 0; index < paths.length; index += 1) {
|
|
1899
|
+
const record = records[index];
|
|
1900
|
+
if (record) target.set(paths[index], record);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* Parse the Spotlight date format ("2026-10-16 10:17:05 +0000").
|
|
1906
|
+
* @param {string} value
|
|
1907
|
+
* @returns {number|null}
|
|
1908
|
+
*/
|
|
1909
|
+
function parseMdlsDate(value) {
|
|
1910
|
+
const match = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) ([+-])(\d{2})(\d{2})$/.exec(value.trim());
|
|
1911
|
+
if (!match) {
|
|
1912
|
+
const parsed = Date.parse(value);
|
|
1913
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
1914
|
+
}
|
|
1915
|
+
const iso = `${match[1]}T${match[2]}${match[3]}${match[4]}:${match[5]}`;
|
|
1916
|
+
const parsed = Date.parse(iso);
|
|
1917
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
/**
|
|
1921
|
+
* Map a Spotlight/`system_profiler` architecture kind to a readable string.
|
|
1922
|
+
* @param {unknown} kind
|
|
1923
|
+
* @returns {string|null}
|
|
1924
|
+
*/
|
|
1925
|
+
function mapArchitecture(kind) {
|
|
1926
|
+
switch (kind) {
|
|
1927
|
+
case 'arch_arm_i64':
|
|
1928
|
+
return 'universal';
|
|
1929
|
+
case 'arch_arm':
|
|
1930
|
+
return 'arm64';
|
|
1931
|
+
case 'arch_i64':
|
|
1932
|
+
return 'x86_64';
|
|
1933
|
+
case 'arch_ios':
|
|
1934
|
+
return 'ios';
|
|
1935
|
+
case 'arch_other':
|
|
1936
|
+
return 'other';
|
|
1937
|
+
default:
|
|
1938
|
+
return str(kind);
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
/**
|
|
1943
|
+
* Classify an app install location.
|
|
1944
|
+
* @param {string|null} appPath
|
|
1945
|
+
* @returns {'system'|'user'|'other'}
|
|
1946
|
+
*/
|
|
1947
|
+
function appSource(appPath) {
|
|
1948
|
+
if (!appPath) return 'other';
|
|
1949
|
+
const home = os.homedir();
|
|
1950
|
+
if (appPath.startsWith('/System/') || appPath.startsWith('/Library/Apple/')) return 'system';
|
|
1951
|
+
if (appPath.startsWith('/Applications/') || appPath.startsWith(`${home}/Applications/`)) return 'user';
|
|
1952
|
+
return 'other';
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
/**
|
|
1956
|
+
* @returns {Promise<object>} the frozen software section shape.
|
|
1957
|
+
*/
|
|
1958
|
+
async function buildSoftware() {
|
|
1959
|
+
const errors = [];
|
|
1960
|
+
const homebrewPromise = collectHomebrew(errors);
|
|
1961
|
+
const profiler = await runJson(SYSTEM_PROFILER, ['-json', 'SPApplicationsDataType'], {
|
|
1962
|
+
timeout: 60_000,
|
|
1963
|
+
maxBuffer: 24 * 1024 * 1024,
|
|
1964
|
+
});
|
|
1965
|
+
/** @type {Array<{name: string|null, path: string|null, version: string|null, bundleId: string|null, source: 'system'|'user'|'other', arch: string|null, lastUsed: number|null, sizeBytes: number|null}>} */
|
|
1966
|
+
const applications = [];
|
|
1967
|
+
/** @type {Array<any>} */
|
|
1968
|
+
const rawApps = profiler.ok && Array.isArray(profiler.data?.SPApplicationsDataType) ? profiler.data.SPApplicationsDataType : [];
|
|
1969
|
+
if (!profiler.ok) errors.push(`system_profiler applications: ${shortErr(profiler.error)}`);
|
|
1970
|
+
|
|
1971
|
+
/** @type {Map<string, {bundleId: string|null, sizeBytes: number|null, lastUsed: number|null}>} */
|
|
1972
|
+
const metadata = new Map();
|
|
1973
|
+
const appPaths = rawApps.map((app) => str(app?.path)).filter((value) => typeof value === 'string');
|
|
1974
|
+
const chunks = [];
|
|
1975
|
+
for (let index = 0; index < appPaths.length; index += 60) chunks.push(appPaths.slice(index, index + 60));
|
|
1976
|
+
const mdlsResults = await Promise.all(
|
|
1977
|
+
chunks.map((chunk) =>
|
|
1978
|
+
run(
|
|
1979
|
+
MDLS,
|
|
1980
|
+
['-name', 'kMDItemCFBundleIdentifier', '-name', 'kMDItemFSSize', '-name', 'kMDItemLastUsedDate', ...chunk],
|
|
1981
|
+
{ timeout: 20_000, maxBuffer: 8 * 1024 * 1024 },
|
|
1982
|
+
),
|
|
1983
|
+
),
|
|
1984
|
+
);
|
|
1985
|
+
mdlsResults.forEach((result, index) => {
|
|
1986
|
+
if (!result.ok) {
|
|
1987
|
+
errors.push(`mdls applications (batch ${index + 1}): ${shortErr(result.error)}`);
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
mergeMdlsBatch(result.stdout, chunks[index], metadata);
|
|
1991
|
+
});
|
|
1992
|
+
|
|
1993
|
+
for (const app of rawApps) {
|
|
1994
|
+
if (!app || typeof app !== 'object') continue;
|
|
1995
|
+
const appPath = str(app.path);
|
|
1996
|
+
const lookup = appPath ? metadata.get(appPath) : undefined;
|
|
1997
|
+
applications.push({
|
|
1998
|
+
name: str(app._name),
|
|
1999
|
+
path: appPath,
|
|
2000
|
+
version: str(app.version),
|
|
2001
|
+
bundleId: lookup?.bundleId ?? null,
|
|
2002
|
+
source: appSource(appPath),
|
|
2003
|
+
arch: mapArchitecture(app.arch_kind),
|
|
2004
|
+
lastUsed: lookup?.lastUsed ?? null,
|
|
2005
|
+
sizeBytes: lookup?.sizeBytes ?? null,
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
2008
|
+
applications.sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
|
|
2009
|
+
|
|
2010
|
+
const homebrew = await homebrewPromise;
|
|
2011
|
+
|
|
2012
|
+
return {
|
|
2013
|
+
applications,
|
|
2014
|
+
totalApplications: applications.length,
|
|
2015
|
+
homebrew,
|
|
2016
|
+
errors,
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
/**
|
|
2021
|
+
* Homebrew installed / outdated facts (cached by the software section TTL).
|
|
2022
|
+
* @param {string[]} errors
|
|
2023
|
+
* @returns {Promise<{available: boolean, prefix: string|null,
|
|
2024
|
+
* installed: Array<{name: string, version: string|null}>, outdated: string[]}>}
|
|
2025
|
+
*/
|
|
2026
|
+
async function collectHomebrew(errors) {
|
|
2027
|
+
let brewPath = null;
|
|
2028
|
+
for (const candidate of BREW_CANDIDATES) {
|
|
2029
|
+
try {
|
|
2030
|
+
if (fs.existsSync(candidate)) {
|
|
2031
|
+
brewPath = candidate;
|
|
2032
|
+
break;
|
|
2033
|
+
}
|
|
2034
|
+
} catch {
|
|
2035
|
+
/* keep looking */
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
if (!brewPath) {
|
|
2039
|
+
return { available: false, prefix: null, installed: [], outdated: [] };
|
|
2040
|
+
}
|
|
2041
|
+
const prefix = path.dirname(path.dirname(brewPath));
|
|
2042
|
+
const [listRes, outdatedRes] = await Promise.all([
|
|
2043
|
+
run(brewPath, ['list', '--versions'], { timeout: 60_000, maxBuffer: 8 * 1024 * 1024 }),
|
|
2044
|
+
runJson(brewPath, ['outdated', '--json=v2'], { timeout: 60_000, maxBuffer: 8 * 1024 * 1024 }),
|
|
2045
|
+
]);
|
|
2046
|
+
/** @type {Array<{name: string, version: string|null}>} */
|
|
2047
|
+
const installed = [];
|
|
2048
|
+
if (!listRes.ok) {
|
|
2049
|
+
errors.push(`brew list: ${shortErr(listRes.error)}`);
|
|
2050
|
+
} else {
|
|
2051
|
+
for (const line of lines(listRes.stdout)) {
|
|
2052
|
+
const parts = line.trim().split(/\s+/);
|
|
2053
|
+
if (!parts.length) continue;
|
|
2054
|
+
const name = parts[0];
|
|
2055
|
+
const version = parts.length > 1 ? parts.slice(1).join(', ') : null;
|
|
2056
|
+
installed.push({ name, version });
|
|
2057
|
+
}
|
|
2058
|
+
installed.sort((left, right) => left.name.localeCompare(right.name));
|
|
2059
|
+
}
|
|
2060
|
+
/** @type {string[]} */
|
|
2061
|
+
const outdated = [];
|
|
2062
|
+
if (!outdatedRes.ok) {
|
|
2063
|
+
errors.push(`brew outdated: ${shortErr(outdatedRes.error)}`);
|
|
2064
|
+
} else if (outdatedRes.data && Array.isArray(outdatedRes.data.formulae)) {
|
|
2065
|
+
for (const formula of outdatedRes.data.formulae) {
|
|
2066
|
+
const name = str(formula?.name);
|
|
2067
|
+
if (name) outdated.push(name);
|
|
2068
|
+
}
|
|
2069
|
+
outdated.sort((left, right) => left.localeCompare(right));
|
|
2070
|
+
}
|
|
2071
|
+
return { available: true, prefix, installed, outdated };
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
/* -------------------------------------------------------------------------- */
|
|
2075
|
+
/* Section: network */
|
|
2076
|
+
/* -------------------------------------------------------------------------- */
|
|
2077
|
+
|
|
2078
|
+
/**
|
|
2079
|
+
* @returns {Promise<object>} the frozen network section shape.
|
|
2080
|
+
*/
|
|
2081
|
+
async function buildNetwork() {
|
|
2082
|
+
const errors = [];
|
|
2083
|
+
const [ifaceListRaw, portsRes, routeValue, dnsRes, proxyRes, lsofRes, netstatRes, airportRes] = await Promise.all([
|
|
2084
|
+
interfaces(),
|
|
2085
|
+
run(NETWORKSETUP, ['-listallhardwareports'], { timeout: 10_000 }),
|
|
2086
|
+
defaultRoute(),
|
|
2087
|
+
run(SCUTIL, ['--dns'], { timeout: 10_000 }),
|
|
2088
|
+
run(SCUTIL, ['--proxy'], { timeout: 10_000 }),
|
|
2089
|
+
run(LSOF, ['-nP', '-iTCP', '-sTCP:LISTEN'], { timeout: 15_000, maxBuffer: 8 * 1024 * 1024 }),
|
|
2090
|
+
run(NETSTAT, ['-an', '-p', 'tcp'], { timeout: 10_000, maxBuffer: 8 * 1024 * 1024 }),
|
|
2091
|
+
runJson(SYSTEM_PROFILER, ['-json', 'SPAirPortDataType'], { timeout: 20_000 }),
|
|
2092
|
+
]);
|
|
2093
|
+
if (!portsRes.ok) errors.push(`networksetup hardware ports: ${shortErr(portsRes.error)}`);
|
|
2094
|
+
const hardwarePorts = parseHardwarePorts(portsRes.stdout);
|
|
2095
|
+
const ifaceList = Array.isArray(ifaceListRaw) ? ifaceListRaw : [];
|
|
2096
|
+
|
|
2097
|
+
const defaultInterface = routeValue?.interface ?? null;
|
|
2098
|
+
const selected = ifaceList.filter((iface) => {
|
|
2099
|
+
if (iface.name === 'lo0') return true;
|
|
2100
|
+
if (iface.name === defaultInterface) return true;
|
|
2101
|
+
if (!iface.up) return false;
|
|
2102
|
+
if (iface.ipv4.length) return true;
|
|
2103
|
+
if (iface.ipv6.some((address) => !/^fe80:/i.test(address))) return true;
|
|
2104
|
+
if (iface.status === 'active') return true;
|
|
2105
|
+
// Keep the Wi-Fi port when it is turned off: "Wi-Fi off" is a state, not
|
|
2106
|
+
// a missing interface. Other dead Thunderbolt stubs are dropped.
|
|
2107
|
+
const port = hardwarePorts.get(iface.name);
|
|
2108
|
+
return Boolean(port) && /wi-?fi|airport/i.test(String(port));
|
|
2109
|
+
});
|
|
2110
|
+
|
|
2111
|
+
const interfacesSection = selected.map((iface) => ({
|
|
2112
|
+
name: iface.name,
|
|
2113
|
+
type: interfaceType(iface, hardwarePorts),
|
|
2114
|
+
up: iface.up,
|
|
2115
|
+
ipv4: iface.ipv4,
|
|
2116
|
+
ipv6: iface.ipv6,
|
|
2117
|
+
mac: iface.mac,
|
|
2118
|
+
mtu: iface.mtu,
|
|
2119
|
+
primary: iface.name === defaultInterface,
|
|
2120
|
+
}));
|
|
2121
|
+
|
|
2122
|
+
if (!airportRes.ok) errors.push(`system_profiler airport: ${shortErr(airportRes.error)}`);
|
|
2123
|
+
const wifi = airportRes.ok ? parseAirportWifi(airportRes.data) : null;
|
|
2124
|
+
|
|
2125
|
+
/** @type {{servers: string[], searchDomains: string[]}} */
|
|
2126
|
+
let dns = { servers: [], searchDomains: [] };
|
|
2127
|
+
if (dnsRes.ok) {
|
|
2128
|
+
dns = parseDns(dnsRes.stdout);
|
|
2129
|
+
} else {
|
|
2130
|
+
errors.push(`scutil --dns: ${shortErr(dnsRes.error)}`);
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
/** @type {{http: string|null, https: string|null, socks: string|null}} */
|
|
2134
|
+
let proxy = { http: null, https: null, socks: null };
|
|
2135
|
+
if (proxyRes.ok) {
|
|
2136
|
+
proxy = parseProxy(parseScutilDictionary(proxyRes.stdout));
|
|
2137
|
+
} else {
|
|
2138
|
+
errors.push(`scutil --proxy: ${shortErr(proxyRes.error)}`);
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
/** @type {Array<{proto: string, address: string, port: number, pid: number|null, process: string}>} */
|
|
2142
|
+
let listeningPorts = [];
|
|
2143
|
+
if (lsofRes.ok) {
|
|
2144
|
+
listeningPorts = parseLsofListeners(lsofRes.stdout);
|
|
2145
|
+
const uniquePids = [...new Set(listeningPorts.map((entry) => entry.pid).filter((pid) => pid !== null))];
|
|
2146
|
+
if (uniquePids.length) {
|
|
2147
|
+
const nameRes = await run(PS, ['-p', uniquePids.join(','), '-o', 'pid=,command='], {
|
|
2148
|
+
timeout: 10_000,
|
|
2149
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
2150
|
+
env: utf8Env(),
|
|
2151
|
+
});
|
|
2152
|
+
if (nameRes.ok) {
|
|
2153
|
+
/** @type {Map<number, string>} */
|
|
2154
|
+
const names = new Map();
|
|
2155
|
+
for (const line of lines(nameRes.stdout)) {
|
|
2156
|
+
const match = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
2157
|
+
if (match) {
|
|
2158
|
+
const procName = commandName(String(match[2] ?? '').trim());
|
|
2159
|
+
if (procName !== null) names.set(Number(match[1]), procName);
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
listeningPorts = listeningPorts.map((entry) => ({
|
|
2163
|
+
...entry,
|
|
2164
|
+
process: entry.pid !== null ? names.get(entry.pid) ?? entry.process : entry.process,
|
|
2165
|
+
}));
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
listeningPorts.sort((left, right) => left.port - right.port);
|
|
2169
|
+
} else {
|
|
2170
|
+
errors.push(`lsof listening ports: ${shortErr(lsofRes.error)}`);
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
let establishedCount = 0;
|
|
2174
|
+
if (netstatRes.ok) {
|
|
2175
|
+
establishedCount = String(netstatRes.stdout)
|
|
2176
|
+
.split(/\r?\n/)
|
|
2177
|
+
.filter((line) => /\bESTABLISHED\b/.test(line)).length;
|
|
2178
|
+
} else {
|
|
2179
|
+
errors.push(`netstat connections: ${shortErr(netstatRes.error)}`);
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
return {
|
|
2183
|
+
interfaces: interfacesSection,
|
|
2184
|
+
wifi,
|
|
2185
|
+
defaultRoute: routeValue,
|
|
2186
|
+
dns,
|
|
2187
|
+
proxy,
|
|
2188
|
+
listeningPorts,
|
|
2189
|
+
establishedCount,
|
|
2190
|
+
errors,
|
|
2191
|
+
};
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
/* -------------------------------------------------------------------------- */
|
|
2195
|
+
/* Section: security */
|
|
2196
|
+
/* -------------------------------------------------------------------------- */
|
|
2197
|
+
|
|
2198
|
+
/**
|
|
2199
|
+
* @returns {Promise<object>} the frozen security section shape.
|
|
2200
|
+
*/
|
|
2201
|
+
async function buildSecurity() {
|
|
2202
|
+
const errors = [];
|
|
2203
|
+
const [vaultRes, sipRes, gatekeeperRes, firewallRes, stealthRes, remoteRes, autoLoginRes, users] = await Promise.all([
|
|
2204
|
+
run(FD_SETUP, ['status'], { timeout: 10_000 }),
|
|
2205
|
+
run(CSRUTIL, ['status'], { timeout: 10_000 }),
|
|
2206
|
+
run(SPCTL, ['--status'], { timeout: 10_000 }),
|
|
2207
|
+
run(SOCKETFILTERFW, ['--getglobalstate'], { timeout: 10_000 }),
|
|
2208
|
+
run(SOCKETFILTERFW, ['--getstealthmode'], { timeout: 10_000 }),
|
|
2209
|
+
run(SYSTEMSETUP, ['-getremotelogin'], { timeout: 10_000 }),
|
|
2210
|
+
run(DEFAULTS, ['read', '/Library/Preferences/com.apple.loginwindow', 'autoLoginUser'], { timeout: 5_000 }),
|
|
2211
|
+
userFacts(),
|
|
2212
|
+
]);
|
|
2213
|
+
|
|
2214
|
+
const fileVault = parseStatusFlag(vaultRes, errors, 'FileVault', /FileVault is (On|Off)\./i);
|
|
2215
|
+
const sip = parseStatusFlag(sipRes, errors, 'SIP', /System Integrity Protection status:\s*(enabled|disabled)/i);
|
|
2216
|
+
const gatekeeper = parseStatusFlag(gatekeeperRes, errors, 'Gatekeeper', /assessments (enabled|disabled)/i);
|
|
2217
|
+
const firewall = parseStatusFlag(firewallRes, errors, 'Firewall', /Firewall is (enabled|disabled)/i);
|
|
2218
|
+
let stealthMode = null;
|
|
2219
|
+
if (stealthRes.ok) {
|
|
2220
|
+
const match = /stealth mode is (on|off)/i.exec(stealthRes.stdout);
|
|
2221
|
+
if (match) stealthMode = match[1].toLowerCase() === 'on';
|
|
2222
|
+
} else {
|
|
2223
|
+
errors.push(`firewall stealth: ${shortErr(stealthRes.error)}`);
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
let remoteLogin = null;
|
|
2227
|
+
if (remoteRes.ok) {
|
|
2228
|
+
const match = /Remote Login:\s*(On|Off)/i.exec(remoteRes.stdout);
|
|
2229
|
+
if (match) remoteLogin = match[1].toLowerCase() === 'on';
|
|
2230
|
+
} else {
|
|
2231
|
+
errors.push(`remote login: ${shortErr(remoteRes.stderr || remoteRes.error)}`);
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
const autoLogin = autoLoginRes.ok ? str(autoLoginRes.stdout) : null;
|
|
2235
|
+
|
|
2236
|
+
const tcc = await readTccGrants(errors);
|
|
2237
|
+
|
|
2238
|
+
const currentName = os.userInfo().username;
|
|
2239
|
+
const realUserNames = new Set(users.users.map((user) => user.name));
|
|
2240
|
+
const admins = users.adminNames.filter((name) => realUserNames.has(name));
|
|
2241
|
+
|
|
2242
|
+
return {
|
|
2243
|
+
fileVault,
|
|
2244
|
+
sip,
|
|
2245
|
+
gatekeeper,
|
|
2246
|
+
firewall: { ...firewall, stealthMode },
|
|
2247
|
+
tcc,
|
|
2248
|
+
users: { current: currentName, admins, total: users.users.length },
|
|
2249
|
+
remoteLogin,
|
|
2250
|
+
autoLogin,
|
|
2251
|
+
errors,
|
|
2252
|
+
};
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
/**
|
|
2256
|
+
* Parse one "feature is on/off" command result.
|
|
2257
|
+
* @param {Awaited<ReturnType<typeof run>>} result
|
|
2258
|
+
* @param {string[]} errors
|
|
2259
|
+
* @param {string} label
|
|
2260
|
+
* @param {RegExp} pattern
|
|
2261
|
+
* @returns {{enabled: boolean|null, raw: string|null}}
|
|
2262
|
+
*/
|
|
2263
|
+
function parseStatusFlag(result, errors, label, pattern) {
|
|
2264
|
+
const raw = str(result.stdout);
|
|
2265
|
+
if (!result.ok) {
|
|
2266
|
+
errors.push(`${label}: ${shortErr(result.stderr || result.error)}`);
|
|
2267
|
+
return { enabled: null, raw };
|
|
2268
|
+
}
|
|
2269
|
+
const match = pattern.exec(result.stdout);
|
|
2270
|
+
if (!match) return { enabled: null, raw };
|
|
2271
|
+
return { enabled: match[1].toLowerCase() === 'enabled' || match[1].toLowerCase() === 'on', raw };
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
/**
|
|
2275
|
+
* Read TCC grants when the user's TCC database happens to be readable.
|
|
2276
|
+
* @param {string[]} errors
|
|
2277
|
+
* @returns {Promise<{readable: boolean, reason: string|null,
|
|
2278
|
+
* grants: Array<{service: string, client: string}>}>}
|
|
2279
|
+
*/
|
|
2280
|
+
async function readTccGrants(errors) {
|
|
2281
|
+
const database = path.join(os.homedir(), 'Library', 'Application Support', 'com.apple.TCC', 'TCC.db');
|
|
2282
|
+
let readable = false;
|
|
2283
|
+
try {
|
|
2284
|
+
await fsp.access(database, fs.constants.R_OK);
|
|
2285
|
+
readable = true;
|
|
2286
|
+
} catch {
|
|
2287
|
+
readable = false;
|
|
2288
|
+
}
|
|
2289
|
+
if (!readable) {
|
|
2290
|
+
return {
|
|
2291
|
+
readable: false,
|
|
2292
|
+
reason:
|
|
2293
|
+
'macOS protects the TCC privacy database; reading it requires Full Disk Access for the Host process.',
|
|
2294
|
+
grants: [],
|
|
2295
|
+
};
|
|
2296
|
+
}
|
|
2297
|
+
const query = await run(SQLITE3, ['-readonly', database, 'SELECT service, client FROM access WHERE auth_value = 2 LIMIT 500'], {
|
|
2298
|
+
timeout: 10_000,
|
|
2299
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
2300
|
+
});
|
|
2301
|
+
if (!query.ok) {
|
|
2302
|
+
errors.push(`TCC query: ${shortErr(query.stderr || query.error)}`);
|
|
2303
|
+
return { readable: true, reason: null, grants: [] };
|
|
2304
|
+
}
|
|
2305
|
+
/** @type {Array<{service: string, client: string}>} */
|
|
2306
|
+
const grants = [];
|
|
2307
|
+
for (const line of lines(query.stdout)) {
|
|
2308
|
+
const separator = line.indexOf('|');
|
|
2309
|
+
if (separator === -1) continue;
|
|
2310
|
+
grants.push({
|
|
2311
|
+
service: line.slice(0, separator),
|
|
2312
|
+
client: line.slice(separator + 1),
|
|
2313
|
+
});
|
|
2314
|
+
}
|
|
2315
|
+
return { readable: true, reason: null, grants };
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
/* -------------------------------------------------------------------------- */
|
|
2319
|
+
/* Section: processes */
|
|
2320
|
+
/* -------------------------------------------------------------------------- */
|
|
2321
|
+
|
|
2322
|
+
/**
|
|
2323
|
+
* @returns {Promise<object>} the frozen processes section shape.
|
|
2324
|
+
*/
|
|
2325
|
+
async function buildProcesses() {
|
|
2326
|
+
const errors = [];
|
|
2327
|
+
let table = [];
|
|
2328
|
+
try {
|
|
2329
|
+
const rows = await processTable();
|
|
2330
|
+
table = Array.isArray(rows) ? rows : [];
|
|
2331
|
+
} catch (err) {
|
|
2332
|
+
errors.push(`ps: ${shortErr(err instanceof Error ? err.message : err)}`);
|
|
2333
|
+
}
|
|
2334
|
+
const sorted = [...table].sort((left, right) => {
|
|
2335
|
+
if (right.cpu !== left.cpu) return right.cpu - left.cpu;
|
|
2336
|
+
return right.memPercent - left.memPercent;
|
|
2337
|
+
});
|
|
2338
|
+
const list = sorted.slice(0, 200);
|
|
2339
|
+
/** @type {Map<string, number>} */
|
|
2340
|
+
const counts = new Map();
|
|
2341
|
+
for (const entry of table) {
|
|
2342
|
+
const user = entry.user ?? 'unknown';
|
|
2343
|
+
counts.set(user, (counts.get(user) ?? 0) + 1);
|
|
2344
|
+
}
|
|
2345
|
+
const byUser = [...counts.entries()]
|
|
2346
|
+
.map(([user, count]) => ({ user, count }))
|
|
2347
|
+
.sort((left, right) => right.count - left.count);
|
|
2348
|
+
return { total: table.length, list, byUser, errors };
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
/* -------------------------------------------------------------------------- */
|
|
2352
|
+
/* Metrics */
|
|
2353
|
+
/* -------------------------------------------------------------------------- */
|
|
2354
|
+
|
|
2355
|
+
/** Previous `os.cpus()` snapshot for delta-based CPU usage. */
|
|
2356
|
+
let cpuSnapshot = null;
|
|
2357
|
+
/** Last computed CPU usage, used when two polls land within the same tick. */
|
|
2358
|
+
/** @type {{usagePercent: number, perCore: number[]}} */
|
|
2359
|
+
let lastCpuUsage = { usagePercent: 0, perCore: [] };
|
|
2360
|
+
|
|
2361
|
+
/**
|
|
2362
|
+
* Whole-machine and per-core CPU usage from successive `os.cpus()` snapshots.
|
|
2363
|
+
* @returns {Promise<{usagePercent: number, perCore: number[]}>}
|
|
2364
|
+
*/
|
|
2365
|
+
async function cpuUsage() {
|
|
2366
|
+
const takeSnapshot = () =>
|
|
2367
|
+
os.cpus().map((cpu) => ({
|
|
2368
|
+
idle: cpu.times.idle,
|
|
2369
|
+
total: cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq,
|
|
2370
|
+
}));
|
|
2371
|
+
const compute = (previous, current) => {
|
|
2372
|
+
let idleDelta = 0;
|
|
2373
|
+
let totalDelta = 0;
|
|
2374
|
+
const perCore = current.map((core, index) => {
|
|
2375
|
+
const before = previous[index] ?? core;
|
|
2376
|
+
const idle = Math.max(0, core.idle - before.idle);
|
|
2377
|
+
const total = Math.max(0, core.total - before.total);
|
|
2378
|
+
idleDelta += idle;
|
|
2379
|
+
totalDelta += total;
|
|
2380
|
+
if (total <= 0) return lastCpuUsage.perCore[index] ?? 0;
|
|
2381
|
+
return round(clamp(100 * (1 - idle / total), 0, 100), 1);
|
|
2382
|
+
});
|
|
2383
|
+
const usagePercent =
|
|
2384
|
+
totalDelta > 0 ? round(clamp(100 * (1 - idleDelta / totalDelta), 0, 100), 1) ?? 0 : lastCpuUsage.usagePercent;
|
|
2385
|
+
lastCpuUsage = { usagePercent, perCore };
|
|
2386
|
+
return lastCpuUsage;
|
|
2387
|
+
};
|
|
2388
|
+
const current = takeSnapshot();
|
|
2389
|
+
if (!cpuSnapshot) {
|
|
2390
|
+
await sleep(80);
|
|
2391
|
+
const sampled = takeSnapshot();
|
|
2392
|
+
cpuSnapshot = sampled;
|
|
2393
|
+
return compute(current, sampled);
|
|
2394
|
+
}
|
|
2395
|
+
const previous = cpuSnapshot;
|
|
2396
|
+
cpuSnapshot = current;
|
|
2397
|
+
return compute(previous, current);
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
/* -------------------------------------------------------------------------- */
|
|
2401
|
+
/* Metrics: thermal sensors */
|
|
2402
|
+
/* -------------------------------------------------------------------------- */
|
|
2403
|
+
|
|
2404
|
+
/**
|
|
2405
|
+
* TTL for the whole thermal probe. The UI polls metrics every 2500 ms; the
|
|
2406
|
+
* pressure probe spawns `osascript`, so it must not run on every poll. A 5 s
|
|
2407
|
+
* cache refreshes roughly every other poll.
|
|
2408
|
+
*/
|
|
2409
|
+
const THERMAL_TTL = 5_000;
|
|
2410
|
+
|
|
2411
|
+
/**
|
|
2412
|
+
* Optional third-party CPU-temperature helpers, in preference order. They are
|
|
2413
|
+
* invoked argv-only with a short timeout; when none is installed the CPU value
|
|
2414
|
+
* stays null and the note explains why (`cpu-needs-admin`).
|
|
2415
|
+
*/
|
|
2416
|
+
const SMC_HELPERS = [
|
|
2417
|
+
{ name: 'osx-cpu-temp', args: [] },
|
|
2418
|
+
{ name: 'smctemp', args: ['-c'] },
|
|
2419
|
+
{ name: 'istats', args: ['cpu', 'temp', '--value-only'] },
|
|
2420
|
+
];
|
|
2421
|
+
|
|
2422
|
+
/** Extra install locations searched besides `$PATH`. */
|
|
2423
|
+
const HELPER_DIRS = ['/opt/homebrew/bin', '/usr/local/bin'];
|
|
2424
|
+
const POWERMETRICS = '/usr/bin/powermetrics';
|
|
2425
|
+
/** `NSProcessInfo.thermalState` enum, in order. */
|
|
2426
|
+
const THERMAL_PRESSURE_STATES = ['nominal', 'fair', 'serious', 'critical'];
|
|
2427
|
+
|
|
2428
|
+
/**
|
|
2429
|
+
* Resolve an installed helper to an absolute path (execFile argv only).
|
|
2430
|
+
* @param {string} name
|
|
2431
|
+
* @returns {string|null}
|
|
2432
|
+
*/
|
|
2433
|
+
function resolveExecutable(name) {
|
|
2434
|
+
const dirs = [...String(process.env.PATH ?? '').split(path.delimiter), ...HELPER_DIRS].filter(Boolean);
|
|
2435
|
+
for (const dir of new Set(dirs)) {
|
|
2436
|
+
const candidate = path.join(dir, name);
|
|
2437
|
+
try {
|
|
2438
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
2439
|
+
} catch {
|
|
2440
|
+
/* keep looking */
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
return null;
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
/**
|
|
2447
|
+
* Accept only a physically plausible temperature (guards a garbage sample).
|
|
2448
|
+
* @param {unknown} value
|
|
2449
|
+
* @returns {number|null}
|
|
2450
|
+
*/
|
|
2451
|
+
function plausibleCelsius(value) {
|
|
2452
|
+
const parsed = num(value);
|
|
2453
|
+
if (parsed === null || parsed < -20 || parsed > 150) return null;
|
|
2454
|
+
return round(parsed, 1);
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
/**
|
|
2458
|
+
* The contract's null-filled thermal object (used by fallbackMetrics and when
|
|
2459
|
+
* the probe cannot produce anything at all).
|
|
2460
|
+
* @param {'cpu-needs-admin'|'wmi-unsupported'|'unavailable'|null} [note]
|
|
2461
|
+
* @returns {{available: false, cpuCelsius: null, gpuCelsius: null,
|
|
2462
|
+
* batteryCelsius: null, nvmeCelsius: null, pressure: null, fans: never[],
|
|
2463
|
+
* sources: never[], note: 'cpu-needs-admin'|'wmi-unsupported'|'unavailable'|null}}
|
|
2464
|
+
*/
|
|
2465
|
+
function emptyThermal(note = null) {
|
|
2466
|
+
return {
|
|
2467
|
+
available: false,
|
|
2468
|
+
cpuCelsius: null,
|
|
2469
|
+
gpuCelsius: null,
|
|
2470
|
+
batteryCelsius: null,
|
|
2471
|
+
nvmeCelsius: null,
|
|
2472
|
+
pressure: null,
|
|
2473
|
+
fans: [],
|
|
2474
|
+
sources: [],
|
|
2475
|
+
note,
|
|
2476
|
+
};
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
/**
|
|
2480
|
+
* True when the Host process runs as root (needed for `powermetrics`).
|
|
2481
|
+
* @returns {boolean}
|
|
2482
|
+
*/
|
|
2483
|
+
function isRootUser() {
|
|
2484
|
+
try {
|
|
2485
|
+
return typeof process.getuid === 'function' && process.getuid() === 0;
|
|
2486
|
+
} catch {
|
|
2487
|
+
return false;
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
/**
|
|
2492
|
+
* Thermal pressure from `NSProcessInfo.thermalState` via JavaScript for
|
|
2493
|
+
* Automation (no admin, no helper). Maps 0/1/2/3 → nominal/fair/serious/
|
|
2494
|
+
* critical; any failure or out-of-range value is null.
|
|
2495
|
+
* @returns {Promise<'nominal'|'fair'|'serious'|'critical'|null>}
|
|
2496
|
+
*/
|
|
2497
|
+
async function readThermalPressure() {
|
|
2498
|
+
const script = 'ObjC.import("Foundation"); ObjC.unwrap($.NSProcessInfo.processInfo.thermalState)';
|
|
2499
|
+
const result = await run(OSASCRIPT, ['-l', 'JavaScript', '-e', script], { timeout: 1_500 });
|
|
2500
|
+
if (!result.ok) return null;
|
|
2501
|
+
const level = num(lines(result.stdout)[0]);
|
|
2502
|
+
if (level === null || !Number.isInteger(level) || level < 0 || level >= THERMAL_PRESSURE_STATES.length) return null;
|
|
2503
|
+
return /** @type {'nominal'|'fair'|'serious'|'critical'} */ (THERMAL_PRESSURE_STATES[level]);
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
/**
|
|
2507
|
+
* Run one optional temperature helper (bounded) and parse its first number.
|
|
2508
|
+
* @param {string} executable absolute path
|
|
2509
|
+
* @param {string[]} args
|
|
2510
|
+
* @returns {Promise<number|null>}
|
|
2511
|
+
*/
|
|
2512
|
+
async function readHelperCelsius(executable, args) {
|
|
2513
|
+
const result = await run(executable, args, { timeout: 2_000, maxBuffer: 256 * 1024 });
|
|
2514
|
+
if (!result.ok) return null;
|
|
2515
|
+
const match = /(-?\d+(?:\.\d+)?)/.exec(result.stdout);
|
|
2516
|
+
return match ? plausibleCelsius(match[1]) : null;
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
/**
|
|
2520
|
+
* CPU/GPU die temperatures from `powermetrics` — only when the Host is root;
|
|
2521
|
+
* `sudo` would need a password and cannot be used from a service. Never throws.
|
|
2522
|
+
* @returns {Promise<{cpuCelsius: number|null, gpuCelsius: number|null}|null>}
|
|
2523
|
+
*/
|
|
2524
|
+
async function readPowermetricsTemps() {
|
|
2525
|
+
if (!isRootUser()) return null;
|
|
2526
|
+
const attempts = [
|
|
2527
|
+
['--samplers', 'smc', '-i', '1', '-n', '1'],
|
|
2528
|
+
['--samplers', 'thermal', '-i', '1', '-n', '1'],
|
|
2529
|
+
];
|
|
2530
|
+
for (const args of attempts) {
|
|
2531
|
+
const result = await run(POWERMETRICS, args, { timeout: 4_000, maxBuffer: 1024 * 1024 });
|
|
2532
|
+
if (!result.ok) continue;
|
|
2533
|
+
const cpuMatch = /(?:CPU(?:\s+die)?\s+temperature)\s*:\s*(-?\d+(?:\.\d+)?)/i.exec(result.stdout);
|
|
2534
|
+
const gpuMatch = /(?:GPU(?:\s+die)?\s+temperature)\s*:\s*(-?\d+(?:\.\d+)?)/i.exec(result.stdout);
|
|
2535
|
+
const cpuCelsius = cpuMatch ? plausibleCelsius(cpuMatch[1]) : null;
|
|
2536
|
+
const gpuCelsius = gpuMatch ? plausibleCelsius(gpuMatch[1]) : null;
|
|
2537
|
+
if (cpuCelsius !== null || gpuCelsius !== null) return { cpuCelsius, gpuCelsius };
|
|
2538
|
+
}
|
|
2539
|
+
return null;
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
/**
|
|
2543
|
+
* The full `metrics.thermal` probe, cached for {@link THERMAL_TTL}.
|
|
2544
|
+
*
|
|
2545
|
+
* `batteryCelsius` reuses the AppleSmartBattery sample the metrics path already
|
|
2546
|
+
* reads (the parser returns the top-level `Temperature` value scaled to °C).
|
|
2547
|
+
* `nvmeCelsius` stays null: on macOS the NVMe temperature is not readable
|
|
2548
|
+
* without root and `ioreg -c AppleEmbeddedNVMeTemperatureSensor` does not expose
|
|
2549
|
+
* a usable temperature, so no source is claimed. `fans` is empty on macOS: the
|
|
2550
|
+
* only fan source (`powermetrics`) is root-only and reports RPM unreliably.
|
|
2551
|
+
*
|
|
2552
|
+
* @param {ReturnType<typeof parseIoregBattery>|null} battery
|
|
2553
|
+
* @returns {Promise<object>} frozen thermal shape
|
|
2554
|
+
*/
|
|
2555
|
+
async function thermalMetrics(battery) {
|
|
2556
|
+
const cached = await memo('thermal', THERMAL_TTL, async () => {
|
|
2557
|
+
/** @type {string[]} */
|
|
2558
|
+
const sources = [];
|
|
2559
|
+
/** @type {Array<{name: string|null, rpm: number|null}>} */
|
|
2560
|
+
const fans = [];
|
|
2561
|
+
const pressure = await readThermalPressure().catch(() => null);
|
|
2562
|
+
if (pressure !== null) sources.push('nsprocessinfo');
|
|
2563
|
+
|
|
2564
|
+
const batteryCelsius =
|
|
2565
|
+
battery && typeof battery.temperatureC === 'number' ? plausibleCelsius(battery.temperatureC) : null;
|
|
2566
|
+
if (batteryCelsius !== null) sources.push('ioreg-applesmartbattery');
|
|
2567
|
+
|
|
2568
|
+
let cpuCelsius = null;
|
|
2569
|
+
let gpuCelsius = null;
|
|
2570
|
+
for (const helper of SMC_HELPERS) {
|
|
2571
|
+
const executable = resolveExecutable(helper.name);
|
|
2572
|
+
if (executable === null) continue;
|
|
2573
|
+
const value = await readHelperCelsius(executable, helper.args).catch(() => null);
|
|
2574
|
+
if (value !== null) {
|
|
2575
|
+
cpuCelsius = value;
|
|
2576
|
+
sources.push(helper.name);
|
|
2577
|
+
break;
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
const rootTemps = await readPowermetricsTemps().catch(() => null);
|
|
2582
|
+
if (rootTemps !== null) {
|
|
2583
|
+
if (cpuCelsius === null && rootTemps.cpuCelsius !== null) cpuCelsius = rootTemps.cpuCelsius;
|
|
2584
|
+
if (rootTemps.gpuCelsius !== null) gpuCelsius = rootTemps.gpuCelsius;
|
|
2585
|
+
if (cpuCelsius !== null || gpuCelsius !== null) sources.push('powermetrics');
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
const note = cpuCelsius === null && gpuCelsius === null ? (isRootUser() ? 'unavailable' : 'cpu-needs-admin') : null;
|
|
2589
|
+
const available = [cpuCelsius, gpuCelsius, batteryCelsius].some(
|
|
2590
|
+
(value) => typeof value === 'number' && Number.isFinite(value),
|
|
2591
|
+
);
|
|
2592
|
+
return {
|
|
2593
|
+
available,
|
|
2594
|
+
cpuCelsius,
|
|
2595
|
+
gpuCelsius,
|
|
2596
|
+
batteryCelsius,
|
|
2597
|
+
nvmeCelsius: null,
|
|
2598
|
+
pressure,
|
|
2599
|
+
fans,
|
|
2600
|
+
sources,
|
|
2601
|
+
note,
|
|
2602
|
+
};
|
|
2603
|
+
});
|
|
2604
|
+
if (cached === null || typeof cached !== 'object') return emptyThermal('unavailable');
|
|
2605
|
+
const safe = jsonSafe(cached);
|
|
2606
|
+
return safe && typeof safe === 'object' ? safe : emptyThermal('unavailable');
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
/**
|
|
2610
|
+
* Build one metrics snapshot. Cheap commands only; slow values come from
|
|
2611
|
+
* caches and are refreshed in the background.
|
|
2612
|
+
* @returns {Promise<object>}
|
|
2613
|
+
*/
|
|
2614
|
+
async function buildMetrics() {
|
|
2615
|
+
const [vmRes, swapRes, pressureRes, batteryRes, ioregBatteryRes, netstatRes, ioregDiskRes, usage, facts, processes, disk, routeValue, ifaceList] =
|
|
2616
|
+
await Promise.all([
|
|
2617
|
+
run(VM_STAT, [], { timeout: 5_000 }),
|
|
2618
|
+
run(SYSCTL, ['-n', 'vm.swapusage'], { timeout: 5_000 }),
|
|
2619
|
+
run(SYSCTL, ['-n', 'kern.memorystatus_vm_pressure_level'], { timeout: 5_000 }),
|
|
2620
|
+
run(PMSET, ['-g', 'batt'], { timeout: 5_000 }),
|
|
2621
|
+
run(IOREG, ['-rn', 'AppleSmartBattery'], { timeout: 5_000 }),
|
|
2622
|
+
run(NETSTAT, ['-inb'], { timeout: 5_000, maxBuffer: 4 * 1024 * 1024 }),
|
|
2623
|
+
run(IOREG, ['-c', 'IOBlockStorageDriver', '-r', '-w0'], { timeout: 5_000, maxBuffer: 4 * 1024 * 1024 }),
|
|
2624
|
+
cpuUsage(),
|
|
2625
|
+
staticFacts(),
|
|
2626
|
+
processTable().then((rows) => (Array.isArray(rows) ? rows : [])).catch(() => []),
|
|
2627
|
+
diskUsage(),
|
|
2628
|
+
defaultRoute(),
|
|
2629
|
+
interfaces().then((rows) => (Array.isArray(rows) ? rows : [])).catch(() => []),
|
|
2630
|
+
]);
|
|
2631
|
+
|
|
2632
|
+
const ts = Date.now();
|
|
2633
|
+
const vm = parseVmStat(vmRes.stdout);
|
|
2634
|
+
const pageSize = vm.pageSize;
|
|
2635
|
+
const memoryTotal = facts.totalMemoryBytes ?? os.totalmem();
|
|
2636
|
+
const freeBytes = vm.free * pageSize;
|
|
2637
|
+
const wiredBytes = vm.wired * pageSize;
|
|
2638
|
+
const compressedBytes = vm.compressed * pageSize;
|
|
2639
|
+
const purgeableBytes = vm.purgeable * pageSize;
|
|
2640
|
+
const cachedBytes = Math.min(memoryTotal, vm.fileBacked * pageSize + purgeableBytes);
|
|
2641
|
+
const appBytes = Math.max(0, vm.anonymous * pageSize - purgeableBytes);
|
|
2642
|
+
const usedBytes = clamp(memoryTotal - freeBytes - cachedBytes, 0, memoryTotal);
|
|
2643
|
+
const swap = parseSwap(swapRes.stdout);
|
|
2644
|
+
const pressureLevel = num(lines(pressureRes.stdout)[0]);
|
|
2645
|
+
const memoryPressure =
|
|
2646
|
+
pressureLevel === null ? null : pressureLevel >= 4 ? 'critical' : pressureLevel >= 2 ? 'warning' : 'normal';
|
|
2647
|
+
|
|
2648
|
+
const batteryPmset = parsePmsetBattery(batteryRes.stdout);
|
|
2649
|
+
const batteryIoreg = parseIoregBattery(ioregBatteryRes.stdout);
|
|
2650
|
+
let battery = null;
|
|
2651
|
+
if (batteryPmset.present || hasBattery(batteryIoreg)) {
|
|
2652
|
+
const timeRemaining = batteryIoreg.timeRemaining;
|
|
2653
|
+
battery = {
|
|
2654
|
+
present: true,
|
|
2655
|
+
percent: batteryPmset.percent ?? batteryIoreg.currentCapacity,
|
|
2656
|
+
charging: batteryIoreg.charging ?? batteryPmset.charging,
|
|
2657
|
+
plugged: batteryIoreg.external ?? batteryPmset.plugged,
|
|
2658
|
+
powerSource: batteryPmset.powerSource,
|
|
2659
|
+
timeRemainingMin: timeRemaining === null || timeRemaining <= 0 || timeRemaining > 6000 ? null : Math.round(timeRemaining),
|
|
2660
|
+
temperatureC: batteryIoreg.temperatureC,
|
|
2661
|
+
cycleCount: batteryIoreg.cycleCount,
|
|
2662
|
+
healthPercent: batteryHealth(batteryIoreg),
|
|
2663
|
+
};
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
const thermal = await thermalMetrics(batteryIoreg);
|
|
2667
|
+
|
|
2668
|
+
const netBytes = parseNetstatBytes(netstatRes.stdout);
|
|
2669
|
+
const netRates = rateOf('network', netBytes, ts);
|
|
2670
|
+
const diskBytes = parseIoregDisk(ioregDiskRes.stdout);
|
|
2671
|
+
const diskRates = rateOf('disk', diskBytes, ts);
|
|
2672
|
+
|
|
2673
|
+
const defaultInterface = routeValue?.interface ?? null;
|
|
2674
|
+
const primaryIface = defaultInterface ? ifaceList.find((iface) => iface.name === defaultInterface) : null;
|
|
2675
|
+
const wifi = wifiMetricSample();
|
|
2676
|
+
|
|
2677
|
+
const topProcesses = [...processes]
|
|
2678
|
+
.sort((left, right) => {
|
|
2679
|
+
if (right.cpu !== left.cpu) return right.cpu - left.cpu;
|
|
2680
|
+
return right.memPercent - left.memPercent;
|
|
2681
|
+
})
|
|
2682
|
+
.slice(0, 5)
|
|
2683
|
+
.map((entry) => ({
|
|
2684
|
+
pid: entry.pid,
|
|
2685
|
+
name: commandName(entry.command),
|
|
2686
|
+
cpu: entry.cpu,
|
|
2687
|
+
memPercent: entry.memPercent,
|
|
2688
|
+
user: entry.user,
|
|
2689
|
+
}));
|
|
2690
|
+
|
|
2691
|
+
return {
|
|
2692
|
+
ts,
|
|
2693
|
+
hostname: facts.hostname,
|
|
2694
|
+
osVersion: facts.os.version
|
|
2695
|
+
? `${facts.os.name} ${facts.os.version}${facts.os.build ? ` (${facts.os.build})` : ''}`
|
|
2696
|
+
: facts.os.name,
|
|
2697
|
+
uptimeSec: Math.floor(os.uptime()),
|
|
2698
|
+
cpu: {
|
|
2699
|
+
model: facts.cpuModel,
|
|
2700
|
+
usagePercent: usage.usagePercent,
|
|
2701
|
+
perCore: usage.perCore,
|
|
2702
|
+
loadAvg: os.loadavg().map((value) => round(value, 2)),
|
|
2703
|
+
logicalCores: facts.logicalCores,
|
|
2704
|
+
physicalCores: facts.physicalCores,
|
|
2705
|
+
},
|
|
2706
|
+
memory: {
|
|
2707
|
+
totalBytes: memoryTotal,
|
|
2708
|
+
usedBytes,
|
|
2709
|
+
appBytes,
|
|
2710
|
+
wiredBytes,
|
|
2711
|
+
compressedBytes,
|
|
2712
|
+
cachedBytes,
|
|
2713
|
+
freeBytes,
|
|
2714
|
+
swapTotalBytes: swap.total,
|
|
2715
|
+
swapUsedBytes: swap.used,
|
|
2716
|
+
pressure: memoryPressure,
|
|
2717
|
+
},
|
|
2718
|
+
disk: {
|
|
2719
|
+
totalBytes: disk?.totalBytes ?? null,
|
|
2720
|
+
usedBytes: disk?.usedBytes ?? null,
|
|
2721
|
+
freeBytes: disk?.freeBytes ?? null,
|
|
2722
|
+
readBytesPerSec: diskRates.readBytesPerSec,
|
|
2723
|
+
writeBytesPerSec: diskRates.writeBytesPerSec,
|
|
2724
|
+
},
|
|
2725
|
+
network: {
|
|
2726
|
+
rxBytesPerSec: netRates.readBytesPerSec,
|
|
2727
|
+
txBytesPerSec: netRates.writeBytesPerSec,
|
|
2728
|
+
totalRxBytes: netBytes.read,
|
|
2729
|
+
totalTxBytes: netBytes.write,
|
|
2730
|
+
wifi,
|
|
2731
|
+
primaryIpv4: primaryIface ? [...primaryIface.ipv4] : [],
|
|
2732
|
+
defaultInterface,
|
|
2733
|
+
},
|
|
2734
|
+
battery,
|
|
2735
|
+
thermal,
|
|
2736
|
+
processes: {
|
|
2737
|
+
total: processes.length,
|
|
2738
|
+
top: topProcesses,
|
|
2739
|
+
},
|
|
2740
|
+
};
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
/**
|
|
2744
|
+
* Display name for a process command line.
|
|
2745
|
+
* @param {string} command
|
|
2746
|
+
* @returns {string|null}
|
|
2747
|
+
*/
|
|
2748
|
+
function commandName(command) {
|
|
2749
|
+
const text = str(command);
|
|
2750
|
+
if (!text) return null;
|
|
2751
|
+
// App bundles put the real binary after `.../Contents/MacOS/`, and both the
|
|
2752
|
+
// bundle name and the binary name may contain spaces.
|
|
2753
|
+
const macOsIndex = text.lastIndexOf('/MacOS/');
|
|
2754
|
+
if (macOsIndex !== -1) {
|
|
2755
|
+
const rest = text.slice(macOsIndex + '/MacOS/'.length);
|
|
2756
|
+
const flagIndex = rest.indexOf(' --');
|
|
2757
|
+
const binary = flagIndex === -1 ? rest : rest.slice(0, flagIndex);
|
|
2758
|
+
return binary.trim() || null;
|
|
2759
|
+
}
|
|
2760
|
+
const first = text.split(/\s+/)[0];
|
|
2761
|
+
return path.basename(first);
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
/**
|
|
2765
|
+
* Best-effort zeroed metrics object (the tick path must never throw).
|
|
2766
|
+
* @returns {object}
|
|
2767
|
+
*/
|
|
2768
|
+
function fallbackMetrics() {
|
|
2769
|
+
return {
|
|
2770
|
+
ts: Date.now(),
|
|
2771
|
+
hostname: safeHostname(),
|
|
2772
|
+
osVersion: null,
|
|
2773
|
+
uptimeSec: Math.floor(os.uptime()),
|
|
2774
|
+
cpu: {
|
|
2775
|
+
model: null,
|
|
2776
|
+
usagePercent: 0,
|
|
2777
|
+
perCore: [],
|
|
2778
|
+
loadAvg: os.loadavg().map((value) => round(value, 2)),
|
|
2779
|
+
logicalCores: os.cpus().length,
|
|
2780
|
+
physicalCores: os.cpus().length,
|
|
2781
|
+
},
|
|
2782
|
+
memory: {
|
|
2783
|
+
totalBytes: os.totalmem(),
|
|
2784
|
+
usedBytes: null,
|
|
2785
|
+
appBytes: null,
|
|
2786
|
+
wiredBytes: null,
|
|
2787
|
+
compressedBytes: null,
|
|
2788
|
+
cachedBytes: null,
|
|
2789
|
+
freeBytes: os.freemem(),
|
|
2790
|
+
swapTotalBytes: null,
|
|
2791
|
+
swapUsedBytes: null,
|
|
2792
|
+
pressure: null,
|
|
2793
|
+
},
|
|
2794
|
+
disk: { totalBytes: null, usedBytes: null, freeBytes: null, readBytesPerSec: 0, writeBytesPerSec: 0 },
|
|
2795
|
+
network: {
|
|
2796
|
+
rxBytesPerSec: 0,
|
|
2797
|
+
txBytesPerSec: 0,
|
|
2798
|
+
totalRxBytes: null,
|
|
2799
|
+
totalTxBytes: null,
|
|
2800
|
+
wifi: null,
|
|
2801
|
+
primaryIpv4: [],
|
|
2802
|
+
defaultInterface: null,
|
|
2803
|
+
},
|
|
2804
|
+
battery: null,
|
|
2805
|
+
thermal: emptyThermal(),
|
|
2806
|
+
processes: { total: 0, top: [] },
|
|
2807
|
+
};
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
/**
|
|
2811
|
+
* os.hostname() that cannot throw.
|
|
2812
|
+
* @returns {string|null}
|
|
2813
|
+
*/
|
|
2814
|
+
function safeHostname() {
|
|
2815
|
+
try {
|
|
2816
|
+
return os.hostname();
|
|
2817
|
+
} catch {
|
|
2818
|
+
return null;
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
/* -------------------------------------------------------------------------- */
|
|
2823
|
+
/* Public collectors */
|
|
2824
|
+
/* -------------------------------------------------------------------------- */
|
|
2825
|
+
|
|
2826
|
+
/**
|
|
2827
|
+
* Start slow section collection in the background. Fire-and-forget by design:
|
|
2828
|
+
* the Host calls this once at plugin start so the first panel view is warm.
|
|
2829
|
+
* @returns {void}
|
|
2830
|
+
*/
|
|
2831
|
+
export function warmup() {
|
|
2832
|
+
for (const section of SECTIONS) {
|
|
2833
|
+
collectSection(section).catch(() => {
|
|
2834
|
+
/* cache prime only; failures are returned by collectSection later */
|
|
2835
|
+
});
|
|
2836
|
+
}
|
|
2837
|
+
// Prime the metrics-side Wi-Fi sample and shared facts too.
|
|
2838
|
+
Promise.resolve()
|
|
2839
|
+
.then(() => wifiMetricSample())
|
|
2840
|
+
.catch(() => {});
|
|
2841
|
+
Promise.resolve()
|
|
2842
|
+
.then(() => staticFacts())
|
|
2843
|
+
.catch(() => {});
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
/**
|
|
2847
|
+
* One metrics snapshot for the 2500 ms UI poll. Never throws.
|
|
2848
|
+
* @returns {Promise<object>}
|
|
2849
|
+
*/
|
|
2850
|
+
export async function collectMetrics() {
|
|
2851
|
+
try {
|
|
2852
|
+
return jsonSafe(await buildMetrics());
|
|
2853
|
+
} catch {
|
|
2854
|
+
return jsonSafe(fallbackMetrics());
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
/**
|
|
2859
|
+
* Build (or serve from cache) one section.
|
|
2860
|
+
*
|
|
2861
|
+
* Unknown section names reject; every command failure is captured in the
|
|
2862
|
+
* returned section's `errors[]` instead of throwing.
|
|
2863
|
+
*
|
|
2864
|
+
* @param {string} section
|
|
2865
|
+
* @param {{fresh?: boolean}} [options]
|
|
2866
|
+
* @returns {Promise<object>}
|
|
2867
|
+
*/
|
|
2868
|
+
export async function collectSection(section, options = {}) {
|
|
2869
|
+
if (!SECTIONS.includes(section)) {
|
|
2870
|
+
throw new Error(`Unknown section: ${String(section)}`);
|
|
2871
|
+
}
|
|
2872
|
+
const fresh = options?.fresh === true;
|
|
2873
|
+
const ttl = SECTION_TTL[section] ?? 30_000;
|
|
2874
|
+
const now = Date.now();
|
|
2875
|
+
const cached = sectionCache.get(section);
|
|
2876
|
+
if (!fresh && cached && now - cached.at < ttl) return cached.value;
|
|
2877
|
+
const pending = sectionInflight.get(section);
|
|
2878
|
+
if (pending) return pending;
|
|
2879
|
+
|
|
2880
|
+
const task = (async () => {
|
|
2881
|
+
const builders = {
|
|
2882
|
+
hardware: buildHardware,
|
|
2883
|
+
system: buildSystem,
|
|
2884
|
+
software: buildSoftware,
|
|
2885
|
+
network: buildNetwork,
|
|
2886
|
+
security: buildSecurity,
|
|
2887
|
+
processes: buildProcesses,
|
|
2888
|
+
};
|
|
2889
|
+
const builder = builders[section];
|
|
2890
|
+
let value;
|
|
2891
|
+
try {
|
|
2892
|
+
value = await builder();
|
|
2893
|
+
} catch (err) {
|
|
2894
|
+
value = { errors: [shortErr(err instanceof Error ? err.message : err)] };
|
|
2895
|
+
}
|
|
2896
|
+
const safe = jsonSafe(value);
|
|
2897
|
+
sectionCache.set(section, { value: safe, at: Date.now() });
|
|
2898
|
+
return safe;
|
|
2899
|
+
})().finally(() => {
|
|
2900
|
+
if (sectionInflight.get(section) === task) sectionInflight.delete(section);
|
|
2901
|
+
});
|
|
2902
|
+
|
|
2903
|
+
sectionInflight.set(section, task);
|
|
2904
|
+
return task;
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
/**
|
|
2908
|
+
* Full inventory for the panel's initial load / `device_info` overview.
|
|
2909
|
+
* @param {{sections?: string[], fresh?: boolean}} [options]
|
|
2910
|
+
* @returns {Promise<{generatedAt: number, platform: string,
|
|
2911
|
+
* hostname: string|null, metrics: object, sections: Record<string, object>,
|
|
2912
|
+
* actions: {catalog: any[], history: any[]}, errors: string[]}>}
|
|
2913
|
+
*/
|
|
2914
|
+
export async function collectInventory(options = {}) {
|
|
2915
|
+
const sectionFilter = Array.isArray(options?.sections) ? options.sections : null;
|
|
2916
|
+
const requested = sectionFilter !== null
|
|
2917
|
+
? SECTIONS.filter((section) => sectionFilter.includes(section))
|
|
2918
|
+
: [...SECTIONS];
|
|
2919
|
+
const fresh = options?.fresh === true;
|
|
2920
|
+
const errors = [];
|
|
2921
|
+
|
|
2922
|
+
const [metrics, ...sectionValues] = await Promise.all([
|
|
2923
|
+
collectMetrics(),
|
|
2924
|
+
...requested.map((section) => collectSection(section, { fresh })),
|
|
2925
|
+
]);
|
|
2926
|
+
/** @type {Record<string, object>} */
|
|
2927
|
+
const sections = {};
|
|
2928
|
+
requested.forEach((section, index) => {
|
|
2929
|
+
sections[section] = sectionValues[index];
|
|
2930
|
+
});
|
|
2931
|
+
if (!metrics || typeof metrics !== 'object') errors.push('metrics unavailable');
|
|
2932
|
+
|
|
2933
|
+
return jsonSafe({
|
|
2934
|
+
generatedAt: Date.now(),
|
|
2935
|
+
platform: PLATFORM,
|
|
2936
|
+
hostname: safeHostname(),
|
|
2937
|
+
metrics,
|
|
2938
|
+
sections,
|
|
2939
|
+
actions: { catalog: actionCatalog(), history: actionHistory() },
|
|
2940
|
+
errors,
|
|
2941
|
+
});
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
/**
|
|
2945
|
+
* Drop cached section data (one section or all of them).
|
|
2946
|
+
* @param {string} [section]
|
|
2947
|
+
* @returns {void}
|
|
2948
|
+
*/
|
|
2949
|
+
export function clearCache(section) {
|
|
2950
|
+
if (section) {
|
|
2951
|
+
sectionCache.delete(section);
|
|
2952
|
+
return;
|
|
2953
|
+
}
|
|
2954
|
+
sectionCache.clear();
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
/* -------------------------------------------------------------------------- */
|
|
2958
|
+
/* Test seam (node:test) */
|
|
2959
|
+
/* -------------------------------------------------------------------------- */
|
|
2960
|
+
|
|
2961
|
+
/**
|
|
2962
|
+
* Pure parser/helper surface for `test/` and the offline harnesses: no
|
|
2963
|
+
* process spawn, no cache mutation, no state changes.
|
|
2964
|
+
*/
|
|
2965
|
+
export const __test = {
|
|
2966
|
+
num,
|
|
2967
|
+
str,
|
|
2968
|
+
parseVmStat,
|
|
2969
|
+
parseSwap,
|
|
2970
|
+
parsePmsetBattery,
|
|
2971
|
+
parseIoregBattery,
|
|
2972
|
+
batteryHealth,
|
|
2973
|
+
parseIoregDisk,
|
|
2974
|
+
parseNetstatBytes,
|
|
2975
|
+
parseDf,
|
|
2976
|
+
parseIfconfig,
|
|
2977
|
+
parseHardwarePorts,
|
|
2978
|
+
interfaceType,
|
|
2979
|
+
parseDefaultRoute,
|
|
2980
|
+
parseDns,
|
|
2981
|
+
parseScutilDictionary,
|
|
2982
|
+
parseProxy,
|
|
2983
|
+
parseLsofListeners,
|
|
2984
|
+
parsePs,
|
|
2985
|
+
parseElapsed,
|
|
2986
|
+
parseLaunchctlList,
|
|
2987
|
+
decodeXml,
|
|
2988
|
+
parseXmlPlist,
|
|
2989
|
+
parseAirportWifi,
|
|
2990
|
+
mergeMdlsBatch,
|
|
2991
|
+
parseMdlsDate,
|
|
2992
|
+
mapArchitecture,
|
|
2993
|
+
parseDarwinDrivers,
|
|
2994
|
+
driverSummaryOf,
|
|
2995
|
+
};
|