dsh-adb 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/README.zh-CN.md +7 -4
- package/client.js +559 -137
- package/lib/index.d.ts +2 -0
- package/lib/index.js +8 -2
- package/lib/parsers/sysinfo.d.ts +18 -0
- package/lib/parsers/sysinfo.js +56 -0
- package/lib/report-store.d.ts +18 -0
- package/lib/report-store.js +55 -0
- package/lib/report.d.ts +103 -0
- package/lib/report.js +192 -0
- package/lib/rpc.d.ts +2 -7
- package/lib/rpc.js +114 -27
- package/lib/skill.d.ts +9 -0
- package/lib/skill.js +40 -0
- package/lib/tools/device-report.d.ts +10 -0
- package/lib/tools/device-report.js +52 -0
- package/package.json +2 -2
package/lib/index.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface Config {
|
|
|
13
13
|
timeoutMs?: number;
|
|
14
14
|
/** Directory for adb_perf_baseline storage. */
|
|
15
15
|
baselineDir?: string;
|
|
16
|
+
/** Directory for adb_device_report storage; defaults to <baselineDir>/reports. */
|
|
17
|
+
reportDir?: string;
|
|
16
18
|
}
|
|
17
19
|
export declare const Config: Schema<Config>;
|
|
18
20
|
export declare function apply(ctx: Context, config: Config): void;
|
package/lib/index.js
CHANGED
|
@@ -2,12 +2,14 @@ import Schema from 'schemastery';
|
|
|
2
2
|
import { DEFAULT_BASELINE_DIR } from './baseline.js';
|
|
3
3
|
import { registerDeviceTools } from './tools/devices.js';
|
|
4
4
|
import { registerCrashReportTool } from './tools/crash-report.js';
|
|
5
|
+
import { registerDeviceReportTool } from './tools/device-report.js';
|
|
5
6
|
import { registerFileTool } from './tools/file.js';
|
|
6
7
|
import { registerInstallTool } from './tools/install.js';
|
|
7
8
|
import { registerLogcatTool } from './tools/logcat.js';
|
|
8
9
|
import { registerPerfTool } from './tools/perf.js';
|
|
9
10
|
import { registerPerfBaselineTool } from './tools/perf-baseline.js';
|
|
10
11
|
import { registerRpc } from './rpc.js';
|
|
12
|
+
import { registerSkills } from './skill.js';
|
|
11
13
|
export const name = 'dsh-adb';
|
|
12
14
|
/** The tool registry is a hard dependency: every tool registers through it. */
|
|
13
15
|
export const inject = ['tools'];
|
|
@@ -16,9 +18,11 @@ export const Config = Schema.object({
|
|
|
16
18
|
defaultSerial: Schema.string().description('默认目标设备 serial'),
|
|
17
19
|
timeoutMs: Schema.number().default(30000).description('adb 命令超时(毫秒)'),
|
|
18
20
|
baselineDir: Schema.string().description('adb_perf_baseline 基线存储目录;缺省 ~/.dsh/storages/dsh-adb'),
|
|
21
|
+
reportDir: Schema.string().description('adb_device_report 报告存储目录;缺省 ~/.dsh/storages/dsh-adb/reports'),
|
|
19
22
|
});
|
|
20
23
|
export function apply(ctx, config) {
|
|
21
24
|
const cfg = config;
|
|
25
|
+
const reportDir = config.reportDir ?? `${(config.baselineDir ?? DEFAULT_BASELINE_DIR).replace(/\\/g, '/')}/reports`;
|
|
22
26
|
registerDeviceTools(ctx, cfg);
|
|
23
27
|
registerInstallTool(ctx, cfg);
|
|
24
28
|
registerFileTool(ctx, cfg);
|
|
@@ -26,9 +30,11 @@ export function apply(ctx, config) {
|
|
|
26
30
|
registerPerfTool(ctx, cfg);
|
|
27
31
|
registerPerfBaselineTool(ctx, cfg, config.baselineDir ?? DEFAULT_BASELINE_DIR);
|
|
28
32
|
registerCrashReportTool(ctx, cfg);
|
|
33
|
+
registerDeviceReportTool(ctx, cfg, reportDir);
|
|
34
|
+
registerSkills(ctx);
|
|
29
35
|
// The RPC channel needs the client connection, which mounts after this
|
|
30
36
|
// plugin starts in web compositions; register lazily so headless profiles
|
|
31
37
|
// (no connection) stay unaffected.
|
|
32
|
-
ctx.inject(['connection'], (readyCtx) => registerRpc(readyCtx, cfg));
|
|
33
|
-
ctx.logger.info('[dsh-adb] loaded:
|
|
38
|
+
ctx.inject(['connection'], (readyCtx) => registerRpc(readyCtx, cfg, reportDir));
|
|
39
|
+
ctx.logger.info('[dsh-adb] loaded: 10 tools + web device panel rpc');
|
|
34
40
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Pure parsers for device-system info (packages, getprop, processes, meminfo, wm). */
|
|
2
|
+
export declare function parsePackageList(text: string): string[];
|
|
3
|
+
export declare function parseGetprop(text: string): Record<string, string>;
|
|
4
|
+
export interface ProcessEntry {
|
|
5
|
+
pid: string;
|
|
6
|
+
name: string;
|
|
7
|
+
/** Resident set size in KB (Android `ps -A` column 4), when numeric. */
|
|
8
|
+
rss?: number;
|
|
9
|
+
}
|
|
10
|
+
/** Parse `ps -A` output (Android toybox: USER PID PPID VSZ RSS WCHAN ADDR S NAME). */
|
|
11
|
+
export declare function parseProcessList(text: string): ProcessEntry[];
|
|
12
|
+
/** Parse /proc/meminfo "MemTotal: 123456 kB" into KB. */
|
|
13
|
+
export declare function parseMemTotal(text: string): number | undefined;
|
|
14
|
+
/** Parse `wm size` -> "Physical size: 1080x2400". */
|
|
15
|
+
export declare function parseWmSize(text: string): {
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
} | undefined;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Pure parsers for device-system info (packages, getprop, processes, meminfo, wm). */
|
|
2
|
+
export function parsePackageList(text) {
|
|
3
|
+
const names = [];
|
|
4
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
5
|
+
const line = rawLine.trim();
|
|
6
|
+
if (!line.startsWith('package:'))
|
|
7
|
+
continue;
|
|
8
|
+
const name = line.slice('package:'.length);
|
|
9
|
+
if (name !== '')
|
|
10
|
+
names.push(name);
|
|
11
|
+
}
|
|
12
|
+
return names;
|
|
13
|
+
}
|
|
14
|
+
export function parseGetprop(text) {
|
|
15
|
+
const props = {};
|
|
16
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
17
|
+
const match = /^\[([^\]]+)\]:\s*\[([^\]]*)\]$/.exec(rawLine.trim());
|
|
18
|
+
if (match === null)
|
|
19
|
+
continue;
|
|
20
|
+
props[match[1]] = match[2];
|
|
21
|
+
}
|
|
22
|
+
return props;
|
|
23
|
+
}
|
|
24
|
+
/** Parse `ps -A` output (Android toybox: USER PID PPID VSZ RSS WCHAN ADDR S NAME). */
|
|
25
|
+
export function parseProcessList(text) {
|
|
26
|
+
const entries = [];
|
|
27
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
28
|
+
const line = rawLine.trim();
|
|
29
|
+
if (line === '' || /^USER\s+PID/.test(line))
|
|
30
|
+
continue;
|
|
31
|
+
const parts = line.split(/\s+/);
|
|
32
|
+
// Android ps: index 1 = PID, index 4 = RSS, last = NAME (comm may be in brackets)
|
|
33
|
+
if (parts.length < 2)
|
|
34
|
+
continue;
|
|
35
|
+
const pid = parts[1];
|
|
36
|
+
const name = parts[parts.length - 1];
|
|
37
|
+
if (pid !== undefined && name !== undefined && /^\d+$/.test(pid)) {
|
|
38
|
+
const rssRaw = parts[4];
|
|
39
|
+
const rss = rssRaw !== undefined && /^\d+$/.test(rssRaw) ? Number(rssRaw) : undefined;
|
|
40
|
+
entries.push({ pid, name, ...(rss !== undefined ? { rss } : {}) });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return entries;
|
|
44
|
+
}
|
|
45
|
+
/** Parse /proc/meminfo "MemTotal: 123456 kB" into KB. */
|
|
46
|
+
export function parseMemTotal(text) {
|
|
47
|
+
const match = /^MemTotal:\s+(\d+)\s*kB/im.exec(text);
|
|
48
|
+
return match === null ? undefined : Number(match[1]);
|
|
49
|
+
}
|
|
50
|
+
/** Parse `wm size` -> "Physical size: 1080x2400". */
|
|
51
|
+
export function parseWmSize(text) {
|
|
52
|
+
const match = /Physical size:\s+(\d+)x(\d+)/.exec(text);
|
|
53
|
+
if (match === null)
|
|
54
|
+
return undefined;
|
|
55
|
+
return { width: Number(match[1]), height: Number(match[2]) };
|
|
56
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { DeviceReport } from './report.js';
|
|
2
|
+
/**
|
|
3
|
+
* Device health report store: one JSON file per report under a configurable
|
|
4
|
+
* directory (`<baselineDir>/reports` by default). Read/write helpers are pure
|
|
5
|
+
* fs functions so the store is unit-testable like the baseline store.
|
|
6
|
+
* Filename: `<serial>--<epoch-ms>.json`; the id is the epoch prefix.
|
|
7
|
+
*/
|
|
8
|
+
export interface StoredReportMeta {
|
|
9
|
+
id: string;
|
|
10
|
+
collectedAt: string;
|
|
11
|
+
serial: string;
|
|
12
|
+
file: string;
|
|
13
|
+
}
|
|
14
|
+
export declare const DEFAULT_REPORT_DIR: string;
|
|
15
|
+
export declare function reportFileFor(serial: string): string;
|
|
16
|
+
export declare function listReports(dir: string): StoredReportMeta[];
|
|
17
|
+
export declare function saveReport(dir: string, report: DeviceReport): StoredReportMeta;
|
|
18
|
+
export declare function loadReport(dir: string, file: string): DeviceReport;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
export const DEFAULT_REPORT_DIR = `${homedir().replace(/\\/g, '/')}/.dsh/storages/dsh-adb/reports`;
|
|
5
|
+
const FILE_PATTERN = /^(.+)--(\d+)\.json$/;
|
|
6
|
+
/** Parse a report filename back into its meta; undefined for foreign files. */
|
|
7
|
+
function parseMeta(file) {
|
|
8
|
+
const match = FILE_PATTERN.exec(file);
|
|
9
|
+
if (match === null)
|
|
10
|
+
return undefined;
|
|
11
|
+
return { id: match[2], collectedAt: '', serial: match[1], file };
|
|
12
|
+
}
|
|
13
|
+
export function reportFileFor(serial) {
|
|
14
|
+
const safeSerial = serial.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
15
|
+
return `${safeSerial}--${Date.now()}.json`;
|
|
16
|
+
}
|
|
17
|
+
export function listReports(dir) {
|
|
18
|
+
if (!existsSync(dir))
|
|
19
|
+
return [];
|
|
20
|
+
const metas = [];
|
|
21
|
+
for (const name of readdirSync(dir)) {
|
|
22
|
+
const meta = parseMeta(name);
|
|
23
|
+
if (meta !== undefined)
|
|
24
|
+
metas.push(meta);
|
|
25
|
+
}
|
|
26
|
+
// Newest first by epoch id.
|
|
27
|
+
return metas.sort((a, b) => (Number(a.id) < Number(b.id) ? 1 : -1));
|
|
28
|
+
}
|
|
29
|
+
export function saveReport(dir, report) {
|
|
30
|
+
try {
|
|
31
|
+
mkdirSync(dir, { recursive: true });
|
|
32
|
+
const file = reportFileFor(report.serial);
|
|
33
|
+
writeFileSync(join(dir, file), JSON.stringify(report, null, 2), 'utf8');
|
|
34
|
+
return { id: String(Date.now()), collectedAt: report.collectedAt, serial: report.serial, file };
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
throw new Error(`report store unwritable at ${dir}: ${describe(error)}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function loadReport(dir, file) {
|
|
41
|
+
try {
|
|
42
|
+
const raw = readFileSync(join(dir, file), 'utf8');
|
|
43
|
+
const parsed = JSON.parse(raw);
|
|
44
|
+
if (parsed === null || typeof parsed !== 'object' || !Array.isArray(parsed.errors) || typeof parsed.collectedAt !== 'string') {
|
|
45
|
+
throw new Error('unexpected shape');
|
|
46
|
+
}
|
|
47
|
+
return parsed;
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
throw new Error(`report store unreadable at ${join(dir, file)}: ${describe(error)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function describe(error) {
|
|
54
|
+
return error instanceof Error ? error.message : String(error);
|
|
55
|
+
}
|
package/lib/report.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import { type AdbConfig } from './adb.js';
|
|
3
|
+
import { type LogcatEntry, type LogLevel } from './parsers/logcat.js';
|
|
4
|
+
import { type ProcessEntry } from './parsers/sysinfo.js';
|
|
5
|
+
/**
|
|
6
|
+
* One-click device health report ("体检"): collect device identity, top
|
|
7
|
+
* processes, crash buffer, the worrying logcat window (W/E/F), and storage
|
|
8
|
+
* usage into one structured snapshot. Every section degrades independently —
|
|
9
|
+
* a failing section lands in `errors` instead of killing the whole report,
|
|
10
|
+
* so a half-alive device still yields whatever could be read.
|
|
11
|
+
*
|
|
12
|
+
* Evidence → signal: raw crash/logcat counts are noisy (boot markers, one
|
|
13
|
+
* mDNS error repeating thousands of times), so the report classifies crashes
|
|
14
|
+
* into real crashes vs. startup markers, aggregates logcat by tag, and emits a
|
|
15
|
+
* compact health summary the agent can reason from instead of 17k raw lines.
|
|
16
|
+
*/
|
|
17
|
+
export type ReportSection = 'device' | 'processes' | 'crash' | 'logcat' | 'storage';
|
|
18
|
+
export declare const REPORT_SECTIONS: readonly ReportSection[];
|
|
19
|
+
export interface ReportDeviceInfo {
|
|
20
|
+
model?: string;
|
|
21
|
+
manufacturer?: string;
|
|
22
|
+
release?: string;
|
|
23
|
+
sdk?: string;
|
|
24
|
+
fingerprint?: string;
|
|
25
|
+
resolution?: string;
|
|
26
|
+
memTotalKb?: number;
|
|
27
|
+
}
|
|
28
|
+
/** A real crash with the following same-pid lines (its stack trace) attached. */
|
|
29
|
+
export interface CrashChain {
|
|
30
|
+
signature: LogcatEntry;
|
|
31
|
+
following: LogcatEntry[];
|
|
32
|
+
}
|
|
33
|
+
export interface CrashSummary {
|
|
34
|
+
total: number;
|
|
35
|
+
realCrashCount: number;
|
|
36
|
+
bootMarkerCount: number;
|
|
37
|
+
otherCount: number;
|
|
38
|
+
/** Real crashes, each with its contiguous same-pid tail. */
|
|
39
|
+
chains: CrashChain[];
|
|
40
|
+
}
|
|
41
|
+
export interface TagAggregate {
|
|
42
|
+
tag: string;
|
|
43
|
+
level: LogLevel;
|
|
44
|
+
count: number;
|
|
45
|
+
sample: LogcatEntry;
|
|
46
|
+
}
|
|
47
|
+
export interface LogcatSummary {
|
|
48
|
+
total: number;
|
|
49
|
+
byTag: TagAggregate[];
|
|
50
|
+
}
|
|
51
|
+
export type HealthVerdict = 'ok' | 'attention';
|
|
52
|
+
export interface HealthSummary {
|
|
53
|
+
verdict: HealthVerdict;
|
|
54
|
+
/** Human/agent-readable lines: device, memory hogs, notable signals. */
|
|
55
|
+
lines: string[];
|
|
56
|
+
/** Concrete issues that drove the verdict (empty when ok). */
|
|
57
|
+
issues: string[];
|
|
58
|
+
}
|
|
59
|
+
export interface DeviceReport {
|
|
60
|
+
collectedAt: string;
|
|
61
|
+
serial: string;
|
|
62
|
+
device?: ReportDeviceInfo;
|
|
63
|
+
processes?: ProcessEntry[];
|
|
64
|
+
crashBuffer?: CrashSummary;
|
|
65
|
+
logcat?: LogcatSummary;
|
|
66
|
+
storage?: {
|
|
67
|
+
lines: number;
|
|
68
|
+
truncated: boolean;
|
|
69
|
+
excerpt: string;
|
|
70
|
+
};
|
|
71
|
+
health?: HealthSummary;
|
|
72
|
+
errors: Array<{
|
|
73
|
+
section: string;
|
|
74
|
+
message: string;
|
|
75
|
+
}>;
|
|
76
|
+
}
|
|
77
|
+
export interface CollectDeviceReportArgs {
|
|
78
|
+
serial?: string;
|
|
79
|
+
include?: ReportSection[];
|
|
80
|
+
/** Cap for crash chains, logcat tag aggregates, and the process/df lists; defaults to 10. */
|
|
81
|
+
tail?: number;
|
|
82
|
+
}
|
|
83
|
+
export declare function isRealCrash(entry: LogcatEntry): boolean;
|
|
84
|
+
export declare function isBootMarker(entry: LogcatEntry): boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Classify crash-buffer entries into real crashes vs. MediaTek boot markers
|
|
87
|
+
* vs. everything else, grouping each real crash with the contiguous same-pid
|
|
88
|
+
* lines that follow it (the stack trace).
|
|
89
|
+
*/
|
|
90
|
+
export declare function classifyCrashBuffer(entries: LogcatEntry[]): CrashSummary;
|
|
91
|
+
/**
|
|
92
|
+
* Aggregate logcat entries by tag+level, keeping one sample line per group and
|
|
93
|
+
* sorting by count descending. Turns "16987 lines" into "AOSP-MdnsDiscovery ×16200".
|
|
94
|
+
*/
|
|
95
|
+
export declare function aggregateByTag(entries: LogcatEntry[], topN?: number): LogcatSummary;
|
|
96
|
+
/**
|
|
97
|
+
* Compact health summary: verdict + a few lines the agent (or user) can reason
|
|
98
|
+
* from directly, instead of raw counts. Signals are intentionally coarse —
|
|
99
|
+
* the report is a triage surface, not a root-cause tool.
|
|
100
|
+
*/
|
|
101
|
+
export declare function buildHealthSummary(report: Pick<DeviceReport, 'device' | 'crashBuffer' | 'logcat' | 'processes'>): HealthSummary;
|
|
102
|
+
/** Collect the one-click device health report with per-section degradation. */
|
|
103
|
+
export declare function collectDeviceReport(ctx: Context, cfg: AdbConfig, signal: AbortSignal, args?: CollectDeviceReportArgs): Promise<DeviceReport>;
|
package/lib/report.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { classifyFailure, runAdb } from './adb.js';
|
|
2
|
+
import { matchesLevel, parseLogcat } from './parsers/logcat.js';
|
|
3
|
+
import { parseGetprop, parseMemTotal, parseProcessList, parseWmSize } from './parsers/sysinfo.js';
|
|
4
|
+
export const REPORT_SECTIONS = ['device', 'processes', 'crash', 'logcat', 'storage'];
|
|
5
|
+
function excerpt(text, maxLines) {
|
|
6
|
+
const lines = text.split(/\r?\n/).filter((line) => line !== '');
|
|
7
|
+
return {
|
|
8
|
+
lines: lines.length,
|
|
9
|
+
truncated: lines.length > maxLines,
|
|
10
|
+
excerpt: lines.slice(-maxLines).join('\n'),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
// ---- Evidence → signal pure helpers (unit-tested) ----
|
|
14
|
+
const REAL_CRASH = /FATAL EXCEPTION|Fatal signal|beginning of crash|SIGSEGV|SIGABRT|SIGBUS|SIGFPE|SIGILL|SIGTRAP/i;
|
|
15
|
+
const BOOT_MARKER = /mtk-brm-(?:commit|change|merge)-id|libimsma_adapt|SmartRatSwitch.*mtk-brm/i;
|
|
16
|
+
export function isRealCrash(entry) {
|
|
17
|
+
return REAL_CRASH.test(entry.message) || REAL_CRASH.test(entry.tag);
|
|
18
|
+
}
|
|
19
|
+
export function isBootMarker(entry) {
|
|
20
|
+
return BOOT_MARKER.test(entry.message) || BOOT_MARKER.test(entry.tag);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Classify crash-buffer entries into real crashes vs. MediaTek boot markers
|
|
24
|
+
* vs. everything else, grouping each real crash with the contiguous same-pid
|
|
25
|
+
* lines that follow it (the stack trace).
|
|
26
|
+
*/
|
|
27
|
+
export function classifyCrashBuffer(entries) {
|
|
28
|
+
const chains = [];
|
|
29
|
+
for (let i = 0; i < entries.length; i++) {
|
|
30
|
+
if (!isRealCrash(entries[i]))
|
|
31
|
+
continue;
|
|
32
|
+
const following = [];
|
|
33
|
+
const pid = entries[i].pid;
|
|
34
|
+
for (let j = i + 1; j < entries.length && entries[j].pid === pid; j++) {
|
|
35
|
+
following.push(entries[j]);
|
|
36
|
+
}
|
|
37
|
+
chains.push({ signature: entries[i], following });
|
|
38
|
+
}
|
|
39
|
+
const bootMarkerCount = entries.filter(isBootMarker).length;
|
|
40
|
+
return {
|
|
41
|
+
total: entries.length,
|
|
42
|
+
realCrashCount: chains.length,
|
|
43
|
+
bootMarkerCount,
|
|
44
|
+
otherCount: entries.length - chains.length - bootMarkerCount,
|
|
45
|
+
chains,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Aggregate logcat entries by tag+level, keeping one sample line per group and
|
|
50
|
+
* sorting by count descending. Turns "16987 lines" into "AOSP-MdnsDiscovery ×16200".
|
|
51
|
+
*/
|
|
52
|
+
export function aggregateByTag(entries, topN = 10) {
|
|
53
|
+
const counts = new Map();
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
const key = `${entry.tag}\u0000${entry.level}`;
|
|
56
|
+
const existing = counts.get(key);
|
|
57
|
+
if (existing !== undefined) {
|
|
58
|
+
existing.count++;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
counts.set(key, { tag: entry.tag, level: entry.level, count: 1, sample: entry });
|
|
62
|
+
}
|
|
63
|
+
const byTag = [...counts.values()].sort((a, b) => b.count - a.count).slice(0, topN);
|
|
64
|
+
return { total: entries.length, byTag };
|
|
65
|
+
}
|
|
66
|
+
const NETWORK_SIGNAL = /WifiHAL|NETWORK_ABNORMAL|MdnsDiscovery|multicast mDNS|sendto failed: EPERM/i;
|
|
67
|
+
const THERMAL_NOISE = /PowerKeeper\.Thermal/i;
|
|
68
|
+
/**
|
|
69
|
+
* Compact health summary: verdict + a few lines the agent (or user) can reason
|
|
70
|
+
* from directly, instead of raw counts. Signals are intentionally coarse —
|
|
71
|
+
* the report is a triage surface, not a root-cause tool.
|
|
72
|
+
*/
|
|
73
|
+
export function buildHealthSummary(report) {
|
|
74
|
+
const lines = [];
|
|
75
|
+
const issues = [];
|
|
76
|
+
const device = report.device;
|
|
77
|
+
if (device) {
|
|
78
|
+
const parts = [device.model, `Android ${device.release ?? '?'}`].filter(Boolean);
|
|
79
|
+
if (device.memTotalKb !== undefined)
|
|
80
|
+
parts.push(`内存 ${Math.round(device.memTotalKb / 1024)}MB`);
|
|
81
|
+
lines.push(`设备:${parts.join(' · ')}`);
|
|
82
|
+
}
|
|
83
|
+
const crash = report.crashBuffer;
|
|
84
|
+
if (crash && crash.total > 0) {
|
|
85
|
+
if (crash.realCrashCount > 0) {
|
|
86
|
+
const tags = [...new Set(crash.chains.map((chain) => chain.signature.tag))].join(', ');
|
|
87
|
+
issues.push(`真实崩溃 ${crash.realCrashCount} 起(${tags})`);
|
|
88
|
+
lines.push(`崩溃:${crash.realCrashCount} 真实崩溃 + ${crash.bootMarkerCount} 启动标记 + ${crash.otherCount} 其他(共 ${crash.total})`);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
lines.push(`崩溃:无真实崩溃(${crash.bootMarkerCount} 启动标记 + ${crash.otherCount} 其他,共 ${crash.total})`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const logcat = report.logcat;
|
|
95
|
+
if (logcat && logcat.total > 0) {
|
|
96
|
+
const top = logcat.byTag[0];
|
|
97
|
+
if (top)
|
|
98
|
+
lines.push(`W/E/F 日志:共 ${logcat.total} 条,主要来源 ${top.tag}(${top.level}) ×${top.count}${logcat.byTag.length > 1 ? ` 等 ${logcat.byTag.length} 个来源` : ''}`);
|
|
99
|
+
const net = logcat.byTag.find((agg) => NETWORK_SIGNAL.test(agg.tag) || NETWORK_SIGNAL.test(agg.sample.message));
|
|
100
|
+
if (net)
|
|
101
|
+
issues.push(`网络异常信号(${net.tag}: ${net.sample.message.slice(0, 80)})`);
|
|
102
|
+
const thermal = logcat.byTag.find((agg) => THERMAL_NOISE.test(agg.tag));
|
|
103
|
+
if (thermal)
|
|
104
|
+
lines.push('注:PowerKeeper.Thermal 为 MIUI 解析噪音,可忽略');
|
|
105
|
+
}
|
|
106
|
+
if (report.processes && report.processes.length > 0) {
|
|
107
|
+
const top = report.processes.slice(0, 3);
|
|
108
|
+
lines.push(`内存大户:${top.map((p) => `${p.name}(${p.rss}KB)`).join(', ')}`);
|
|
109
|
+
}
|
|
110
|
+
const verdict = issues.length > 0 ? 'attention' : 'ok';
|
|
111
|
+
return { verdict, lines, issues };
|
|
112
|
+
}
|
|
113
|
+
// ---- Section collectors ----
|
|
114
|
+
/** Device identity block: getprop + wm size + /proc/meminfo. */
|
|
115
|
+
async function collectDevice(ctx, cfg, signal, serial) {
|
|
116
|
+
const [getpropOut, sizeOut, memOut] = await Promise.all([
|
|
117
|
+
runAdb(ctx, cfg, ['shell', 'getprop'], { signal, serial, maxBytes: 2 * 1024 * 1024 }),
|
|
118
|
+
runAdb(ctx, cfg, ['shell', 'wm', 'size'], { signal, serial }),
|
|
119
|
+
runAdb(ctx, cfg, ['shell', 'cat', '/proc/meminfo'], { signal, serial }),
|
|
120
|
+
]);
|
|
121
|
+
for (const result of [getpropOut, sizeOut, memOut]) {
|
|
122
|
+
if (result.exitCode !== 0)
|
|
123
|
+
throw classifyFailure(result);
|
|
124
|
+
}
|
|
125
|
+
const props = parseGetprop(getpropOut.stdout);
|
|
126
|
+
const size = parseWmSize(sizeOut.stdout);
|
|
127
|
+
return {
|
|
128
|
+
model: props['ro.product.model'],
|
|
129
|
+
manufacturer: props['ro.product.manufacturer'],
|
|
130
|
+
release: props['ro.build.version.release'],
|
|
131
|
+
sdk: props['ro.build.version.sdk'],
|
|
132
|
+
fingerprint: props['ro.build.fingerprint'],
|
|
133
|
+
resolution: size === undefined ? undefined : `${size.width}x${size.height}`,
|
|
134
|
+
memTotalKb: parseMemTotal(memOut.stdout),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/** Top processes by RSS (memory hogs first). */
|
|
138
|
+
async function collectProcesses(ctx, cfg, signal, serial, tail) {
|
|
139
|
+
const result = await runAdb(ctx, cfg, ['shell', 'ps', '-A'], { signal, serial, maxBytes: 2 * 1024 * 1024 });
|
|
140
|
+
if (result.exitCode !== 0)
|
|
141
|
+
throw classifyFailure(result);
|
|
142
|
+
return parseProcessList(result.stdout)
|
|
143
|
+
.filter((entry) => entry.rss !== undefined)
|
|
144
|
+
.sort((a, b) => (b.rss ?? 0) - (a.rss ?? 0))
|
|
145
|
+
.slice(0, tail);
|
|
146
|
+
}
|
|
147
|
+
/** Crash buffer classified into real crashes vs. boot markers. */
|
|
148
|
+
async function collectCrash(ctx, cfg, signal, serial) {
|
|
149
|
+
const result = await runAdb(ctx, cfg, ['logcat', '-b', 'crash', '-v', 'threadtime', '-d'], { signal, serial, maxBytes: 8 * 1024 * 1024 });
|
|
150
|
+
if (result.exitCode !== 0)
|
|
151
|
+
throw classifyFailure(result);
|
|
152
|
+
return classifyCrashBuffer(parseLogcat(result.stdout));
|
|
153
|
+
}
|
|
154
|
+
/** The worrying logcat window: W/E/F entries from the main buffer, aggregated by tag. */
|
|
155
|
+
async function collectLogcat(ctx, cfg, signal, serial, tail) {
|
|
156
|
+
const result = await runAdb(ctx, cfg, ['logcat', '-v', 'threadtime', '-d'], { signal, serial, maxBytes: 8 * 1024 * 1024 });
|
|
157
|
+
if (result.exitCode !== 0)
|
|
158
|
+
throw classifyFailure(result);
|
|
159
|
+
const entries = parseLogcat(result.stdout).filter((entry) => matchesLevel(entry, 'W'));
|
|
160
|
+
return aggregateByTag(entries, tail);
|
|
161
|
+
}
|
|
162
|
+
/** Storage usage excerpt (`df`). */
|
|
163
|
+
async function collectStorage(ctx, cfg, signal, serial, tail) {
|
|
164
|
+
const result = await runAdb(ctx, cfg, ['shell', 'df'], { signal, serial, maxBytes: 1024 * 1024 });
|
|
165
|
+
if (result.exitCode !== 0)
|
|
166
|
+
throw classifyFailure(result);
|
|
167
|
+
return excerpt(result.stdout, tail);
|
|
168
|
+
}
|
|
169
|
+
/** Collect the one-click device health report with per-section degradation. */
|
|
170
|
+
export async function collectDeviceReport(ctx, cfg, signal, args = {}) {
|
|
171
|
+
const serial = args.serial ?? cfg.defaultSerial;
|
|
172
|
+
const tail = args.tail !== undefined && args.tail > 0 ? Math.floor(args.tail) : 10;
|
|
173
|
+
const include = args.include ?? [...REPORT_SECTIONS];
|
|
174
|
+
const report = { collectedAt: new Date().toISOString(), serial: serial ?? 'default', errors: [] };
|
|
175
|
+
const guard = async (section, collect, assign) => {
|
|
176
|
+
try {
|
|
177
|
+
assign(await collect());
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
report.errors.push({ section, message: error instanceof Error ? error.message : String(error) });
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
await Promise.all([
|
|
184
|
+
include.includes('device') && guard('device', () => collectDevice(ctx, cfg, signal, serial), (v) => { report.device = v; }),
|
|
185
|
+
include.includes('processes') && guard('processes', () => collectProcesses(ctx, cfg, signal, serial, tail), (v) => { report.processes = v; }),
|
|
186
|
+
include.includes('crash') && guard('crash', () => collectCrash(ctx, cfg, signal, serial), (v) => { report.crashBuffer = v; }),
|
|
187
|
+
include.includes('logcat') && guard('logcat', () => collectLogcat(ctx, cfg, signal, serial, tail), (v) => { report.logcat = v; }),
|
|
188
|
+
include.includes('storage') && guard('storage', () => collectStorage(ctx, cfg, signal, serial, tail), (v) => { report.storage = v; }),
|
|
189
|
+
]);
|
|
190
|
+
report.health = buildHealthSummary(report);
|
|
191
|
+
return report;
|
|
192
|
+
}
|
package/lib/rpc.d.ts
CHANGED
|
@@ -9,15 +9,10 @@ export type RpcEndpointResult = {
|
|
|
9
9
|
message: string;
|
|
10
10
|
};
|
|
11
11
|
};
|
|
12
|
-
|
|
13
|
-
* Package-private Client↔Host RPC endpoint dispatch for the Web device panel.
|
|
14
|
-
* Extracted from the connection handler so the endpoints are unit-testable
|
|
15
|
-
* against a fake adb backend.
|
|
16
|
-
*/
|
|
17
|
-
export declare function handleRpcEndpoint(ctx: Context, cfg: AdbConfig, endpoint: string, raw: unknown, signal: AbortSignal): Promise<RpcEndpointResult>;
|
|
12
|
+
export declare function handleRpcEndpoint(ctx: Context, cfg: AdbConfig, reportDir: string, endpoint: string, raw: unknown, signal: AbortSignal): Promise<RpcEndpointResult>;
|
|
18
13
|
/**
|
|
19
14
|
* Register the Package-private Client↔Host RPC for the Web device panel
|
|
20
15
|
* (conversation.view tab "设备"). Called lazily once the client connection
|
|
21
16
|
* mounts; headless compositions (no connection) stay unaffected.
|
|
22
17
|
*/
|
|
23
|
-
export declare function registerRpc(ctx: Context, cfg: AdbConfig): void;
|
|
18
|
+
export declare function registerRpc(ctx: Context, cfg: AdbConfig, reportDir: string): void;
|