dsh-notify-say 0.4.34 → 0.4.35

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/lib/client.js CHANGED
@@ -127,6 +127,15 @@ window.__ModuleLoader__.load({
127
127
  body: JSON.stringify({ key: key }),
128
128
  }).then(function (r) { return r ? r.json() : null }).catch(function () { return null })
129
129
  }
130
+ // 导出诊断(v0.4.35):宿主读日志 → 脱敏 → 写文件,返回路径。
131
+ // 用 POST:它会写文件,GET 可被任意网页用 <img> 触发。
132
+ function exportDiagnostics() {
133
+ return fetch(apiUrl('diagnostics'), {
134
+ method: 'POST', cache: 'no-store', credentials: 'same-origin',
135
+ headers: { 'content-type': 'application/json' },
136
+ body: '{}',
137
+ }).then(function (r) { return r ? r.json() : null }).catch(function () { return null })
138
+ }
130
139
 
131
140
  function openSession(ctx, sessionId) {
132
141
  try {
@@ -514,6 +523,22 @@ window.__ModuleLoader__.load({
514
523
  if (ok) loadPacks()
515
524
  })
516
525
  }
526
+ // 导出诊断:成功后把路径显示出来(用户要能直接找到文件)。
527
+ function doExportDiagnostics() {
528
+ set(function (p) {
529
+ return { loaded: p.loaded, values: p.values, advOpen: p.advOpen, packs: p.packs, busy: true, msg: '正在导出诊断…', err: '' }
530
+ })
531
+ exportDiagnostics().then(function (r) {
532
+ var ok = r && r.ok
533
+ set(function (p) {
534
+ return {
535
+ loaded: p.loaded, values: p.values, advOpen: p.advOpen, packs: p.packs, busy: false,
536
+ msg: ok ? ("已导出:" + r.path + "(" + r.lines + " 行)") : "",
537
+ err: ok ? "" : ("导出失败:" + ((r && (r.error || r.reason)) || "未知原因")),
538
+ }
539
+ })
540
+ })
541
+ }
517
542
 
518
543
  var v = s.values || {}
519
544
  var rows = []
@@ -1446,6 +1471,23 @@ window.__ModuleLoader__.load({
1446
1471
  ? '⚠️ debug 档会把台词和通知正文写进日志。日志在 %TEMP%\\dsh-notify-say\\notify.log,'
1447
1472
  + '提 Issue 前请先切回「只记元数据」或自行删改。'
1448
1473
  : '日志只记事件类型、音源、成败与数量,不含台词正文。排查问题时可切到 debug。'))
1474
+
1475
+ // ── 导出诊断(v0.4.35)───────────────────────────────────────────
1476
+ //
1477
+ // 用户提 Issue 时最需要的东西:一份**已脱敏**的日志。
1478
+ // 让他自己去 %TEMP% 找、手动删用户名,多数人不会做。
1479
+ rows.push(h('div', { key: 'dg-row', style: S.row },
1480
+ h('span', { style: S.label }, '诊断'),
1481
+ h('button', {
1482
+ type: 'button',
1483
+ disabled: s.busy,
1484
+ style: S.btn,
1485
+ onClick: function () { doExportDiagnostics() },
1486
+ }, '导出诊断文件'),
1487
+ ))
1488
+ rows.push(h('div', { key: 'dg-hint', style: S.subHint },
1489
+ '导出到日志同目录(%TEMP%\\dsh-notify-say\\)。会自动把用户名替换为 <user>、'
1490
+ + '绝对路径替换为 <path>,但**仍请自己再看一遍**再上传。'))
1449
1491
  }
1450
1492
 
1451
1493
  if (s.msg) rows.push(h('div', { key: 'msg', style: S.ok }, s.msg))
@@ -0,0 +1,143 @@
1
+ /**
2
+ * 诊断导出(v0.4.35)。
3
+ *
4
+ * ## 为什么要单独一个出口
5
+ *
6
+ * `log-privacy.js` 里的 `sanitizeDiagnostics()` 早就写好了,但**没有调用方** ——
7
+ * 等于脱敏能力存在却用不上。用户提 Issue 时还是得自己去 `%TEMP%` 找日志、
8
+ * 手动删用户名,多数人不会做。
9
+ *
10
+ * 所以这里把它接成一个真正的功能:读日志 → 脱敏 → 写一份可直接上传的文件。
11
+ *
12
+ * ## 为什么写到同一目录而不是让浏览器下载
13
+ *
14
+ * 渲染进程(设置卡片)跑在沙箱里,没有 `fs`,也没有可用的保存对话框 API。
15
+ * 宿主侧写文件 + 返回路径让用户自己去打开,是唯一可靠的做法。
16
+ *
17
+ * ## 输出位置
18
+ *
19
+ * ```
20
+ * %TEMP%\dsh-notify-say\diagnostics-<时间戳>.txt
21
+ * ```
22
+ *
23
+ * 与日志同目录 —— 用户已经知道去哪儿找日志,就一定能找到它。
24
+ *
25
+ * ## ⚠️ 脱敏不是万能的
26
+ *
27
+ * 只处理:用户名、绝对路径、长 token。
28
+ * **不处理**:用户自己写在台词里的项目名、人名(那些看起来就是普通文本)。
29
+ *
30
+ * 所以导出文件的头部明确写「仍请自行检查一遍」。
31
+ * 默认档日志本来就不含台词,这条主要是给开了 debug 的用户兜底。
32
+ */
33
+
34
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
35
+ import { join } from 'node:path';
36
+
37
+ import { sanitizeDiagnostics, diagnosticsHeader, normalizeLevel } from './log-privacy.js';
38
+
39
+ /** 导出的诊断文件保留几份(太多了目录会乱)。 */
40
+ const KEEP_MAX = 5;
41
+
42
+ /**
43
+ * 日志目录。
44
+ *
45
+ * @returns {string}
46
+ */
47
+ export function logDirPath() {
48
+ const tmp = process.env.TEMP || process.env.TMP || '';
49
+ return tmp ? join(tmp, 'dsh-notify-say') : '';
50
+ }
51
+
52
+ /**
53
+ * 读日志正文(含轮转出来的 .1)。
54
+ *
55
+ * 为什么要带 .1:用户遇到问题时日志可能刚轮转过,
56
+ * 上一份里才有事发时的记录。
57
+ *
58
+ * @returns {{ ok: boolean, text?: string, error?: string }}
59
+ */
60
+ export function readLogText() {
61
+ const dir = logDirPath();
62
+ if (!dir) return { ok: false, error: 'no-temp-dir' };
63
+
64
+ const main = join(dir, 'notify.log');
65
+ const rotated = join(dir, 'notify.log.1');
66
+
67
+ const parts = [];
68
+ try {
69
+ // 轮转的先读(它更旧)
70
+ if (existsSync(rotated)) {
71
+ parts.push('--- notify.log.1(较早,已轮转)---');
72
+ parts.push(readFileSync(rotated, 'utf8'));
73
+ }
74
+ if (existsSync(main)) {
75
+ parts.push('--- notify.log(当前)---');
76
+ parts.push(readFileSync(main, 'utf8'));
77
+ }
78
+ } catch (e) {
79
+ return { ok: false, error: `read: ${(e && e.message) || e}` };
80
+ }
81
+
82
+ if (parts.length === 0) return { ok: false, error: 'no-log-file' };
83
+ return { ok: true, text: parts.join('\n') };
84
+ }
85
+
86
+ /** 清理旧的诊断文件,只留最近 KEEP_MAX 份。 */
87
+ function pruneOldDiagnostics(dir) {
88
+ try {
89
+ const files = readdirSync(dir)
90
+ .filter((f) => /^diagnostics-.*\.txt$/.test(f))
91
+ .map((f) => ({ f, t: statSync(join(dir, f)).mtimeMs }))
92
+ .sort((a, b) => b.t - a.t);
93
+ for (const { f } of files.slice(KEEP_MAX)) {
94
+ try { unlinkSync(join(dir, f)); } catch { /* ignore */ }
95
+ }
96
+ } catch { /* 清理失败不影响导出 */ }
97
+ }
98
+
99
+ /** 时间戳里去掉 Windows 文件名不允许的字符。 */
100
+ function stamp() {
101
+ return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
102
+ }
103
+
104
+ /**
105
+ * 导出一份可直接上传的诊断文件。
106
+ *
107
+ * @param {object} info 环境信息(version / nodeVersion / platform / logLevel)
108
+ * @returns {{ ok: boolean, path?: string, bytes?: number, lines?: number, error?: string }}
109
+ */
110
+ export function exportDiagnostics(info) {
111
+ const dir = logDirPath();
112
+ if (!dir) return { ok: false, error: 'no-temp-dir' };
113
+
114
+ const read = readLogText();
115
+ if (!read.ok) return { ok: false, error: read.error };
116
+
117
+ const header = diagnosticsHeader({
118
+ version: info && info.version,
119
+ nodeVersion: process.version,
120
+ platform: process.platform,
121
+ logLevel: normalizeLevel(info && info.logLevel),
122
+ now: new Date().toISOString(),
123
+ });
124
+
125
+ // ⚠️ 先脱敏再拼头部:头部本身不含用户内容,但保持"整个文件都过了一遍脱敏"的习惯。
126
+ const body = sanitizeDiagnostics(read.text);
127
+ const content = header + body;
128
+
129
+ try {
130
+ mkdirSync(dir, { recursive: true });
131
+ pruneOldDiagnostics(dir);
132
+ const out = join(dir, `diagnostics-${stamp()}.txt`);
133
+ writeFileSync(out, content, 'utf8');
134
+ return {
135
+ ok: true,
136
+ path: out,
137
+ bytes: Buffer.byteLength(content, 'utf8'),
138
+ lines: content.split('\n').length,
139
+ };
140
+ } catch (e) {
141
+ return { ok: false, error: `write: ${(e && e.message) || e}` };
142
+ }
143
+ }
package/lib/index.js CHANGED
@@ -99,10 +99,21 @@ import { checkLocalOrigin, throttle } from './http-guard.js';
99
99
  import { poolSummary } from './voice-pack-store.js';
100
100
  import { createVoicePackRoute } from './voice-pack-route.js';
101
101
  import { isDebug, redactText } from './log-privacy.js';
102
+ import { exportDiagnostics } from './diagnostics.js';
102
103
  import { selfReload, watchPackage, computeFingerprint } from './dev-reload.js';
103
104
  import { pickTtsText, sanitizeTtsText, MAX_TEXT_LEN } from './tts-lines.js';
104
105
 
105
106
  const PLUGIN_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
107
+
108
+ // 插件版本(诊断导出用)。读 package.json,失败就返回 unknown ——
109
+ // 这只是个信息字段,不值得为它抛异常。
110
+ const PLUGIN_VERSION = (() => {
111
+ try {
112
+ return JSON.parse(readFileSync(join(PLUGIN_ROOT, 'package.json'), 'utf8')).version || 'unknown';
113
+ } catch {
114
+ return 'unknown';
115
+ }
116
+ })();
106
117
  const SCRIPT_PATH = join(PLUGIN_ROOT, 'dsh-notify.ps1');
107
118
 
108
119
  /**
@@ -1532,7 +1543,38 @@ function hasVoiceLine(psKind) {
1532
1543
  // ② 事件进来了但 notify() 坏了
1533
1544
  // 有了这个端点就能二分:/selftest 能弹窗出声 → notify 好、问题在事件;
1534
1545
  // 否则 → notify 链路坏了。
1535
- // 语音包管理(异步:会下载与写盘)
1546
+ // ── 诊断导出(v0.4.35)────────────────────────────────────
1547
+ //
1548
+ // 读日志 → 脱敏(用户名/路径/长 token)→ 写到日志同目录,
1549
+ // 返回路径给界面显示。
1550
+ //
1551
+ // 为什么必须 POST:它会**写文件**。用 GET 的话,
1552
+ // 任意网页放个 <img src=".../diagnostics"> 就能让本机反复生成文件。
1553
+ if (head === 'diagnostics') {
1554
+ if (String((req && req.method) || 'GET').toUpperCase() !== 'POST') {
1555
+ try {
1556
+ res.writeHead(405, { 'content-type': 'application/json; charset=utf-8', allow: 'POST' });
1557
+ res.end(JSON.stringify({ ok: false, reason: 'method-not-allowed' }));
1558
+ } catch { /* 响应已发出 */ }
1559
+ return;
1560
+ }
1561
+ try {
1562
+ const r = exportDiagnostics({
1563
+ version: PLUGIN_VERSION,
1564
+ logLevel: (cfg && cfg.logLevel) || 'info',
1565
+ });
1566
+ res.writeHead(r.ok ? 200 : 500, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
1567
+ res.end(JSON.stringify(r));
1568
+ } catch (e) {
1569
+ try {
1570
+ res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
1571
+ res.end(JSON.stringify({ ok: false, error: String((e && e.message) || e) }));
1572
+ } catch { /* 响应已发出 */ }
1573
+ }
1574
+ return;
1575
+ }
1576
+
1577
+ // 语音包管理(异步:会下载与写盘)
1536
1578
  if (head === 'voicepacks') {
1537
1579
  Promise.resolve(voicePackRoute.handle(req, res, head, pathname))
1538
1580
  .catch((e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-notify-say",
3
- "version": "0.4.34",
3
+ "version": "0.4.35",
4
4
  "description": "Desktop notifications for DSH Desktop that actually speak: Windows TTS out of the box (zero download), VOICEVOX voice packs optional, a selectable system-sound fallback, and an idle gate that waits for running subagents before saying the turn is done. Includes a settings-page GUI (sound source, per-event overrides, jump-back-to-session, cooldowns) and a self hot-reload for development.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,178 @@
1
+ /**
2
+ * 诊断导出测试(v0.4.35)。
3
+ *
4
+ * ## 为什么必须测这个
5
+ *
6
+ * 这是**唯一一个"把本机数据写到文件"且用户会主动外发**的功能。
7
+ * 一旦脱敏失效,用户会以为安全、然后把它传到公开 Issue 里。
8
+ *
9
+ * 所以重点不是"能不能导出",而是:
10
+ * · 导出的文件里有没有残留用户名 / 路径 / token
11
+ * · 路由是不是 POST(GET 可被任意网页用 <img> 触发,反复生成文件)
12
+ * · 头部是否明确提醒"仍需自己检查"
13
+ */
14
+ import { test } from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join, resolve, dirname } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ import { exportDiagnostics, readLogText, logDirPath } from '../lib/diagnostics.js';
22
+
23
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
24
+ const INDEX = readFileSync(join(ROOT, 'lib', 'index.js'), 'utf8');
25
+ const CLIENT = readFileSync(join(ROOT, 'lib', 'client.js'), 'utf8');
26
+
27
+ /** 在隔离的临时目录里跑(改 TEMP 环境变量)。 */
28
+ async function withTemp(fn) {
29
+ const old = process.env.TEMP;
30
+ const oldTmp = process.env.TMP;
31
+ const dir = join(tmpdir(), `diag-${Math.random().toString(36).slice(2, 10)}`);
32
+ mkdirSync(dir, { recursive: true });
33
+ process.env.TEMP = dir;
34
+ process.env.TMP = dir;
35
+ try {
36
+ return await fn(dir);
37
+ } finally {
38
+ if (old === undefined) delete process.env.TEMP; else process.env.TEMP = old;
39
+ if (oldTmp === undefined) delete process.env.TMP; else process.env.TMP = oldTmp;
40
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
41
+ }
42
+ }
43
+
44
+ /** 造一份假日志(含敏感内容)。 */
45
+ function seedLog(dir, text) {
46
+ const logDir = join(dir, 'dsh-notify-say');
47
+ mkdirSync(logDir, { recursive: true });
48
+ writeFileSync(join(logDir, 'notify.log'), text, 'utf8');
49
+ return logDir;
50
+ }
51
+
52
+ // ── G1: 没有日志时给出明确原因(不崩)──────────────────────────────────
53
+ test('G1: 没有日志文件时返回 no-log-file', async () => {
54
+ await withTemp(async () => {
55
+ const r = exportDiagnostics({ version: '0.4.35', logLevel: 'info' });
56
+ assert.equal(r.ok, false);
57
+ assert.equal(r.error, 'no-log-file');
58
+ });
59
+ });
60
+
61
+ // ── G2: 正常导出(文件真的写出来了)────────────────────────────────────
62
+ test('G2: 有日志时导出成功,文件存在且非空', async () => {
63
+ await withTemp(async (dir) => {
64
+ seedLog(dir, '2026-10-18 12:00:00 [plan] kind=done source=bundled\n');
65
+ const r = exportDiagnostics({ version: '0.4.35', logLevel: 'info' });
66
+ assert.equal(r.ok, true, `应成功,实际 ${r.error}`);
67
+ assert.ok(existsSync(r.path), '导出文件应存在');
68
+ assert.ok(r.bytes > 100, '文件不应是空的');
69
+ });
70
+ });
71
+
72
+ // ── G3: 脱敏真的生效(核心)────────────────────────────────────────────
73
+ test('G3: 导出文件里没有用户名、绝对路径、长 token', async () => {
74
+ await withTemp(async (dir) => {
75
+ seedLog(dir, [
76
+ 'log=C:\\Users\\alice\\AppData\\Local\\dsh-notify-say\\notify.log',
77
+ 'src=E:\\secret-project\\lib\\index.js',
78
+ 'token=abcdefghijklmnopqrstuvwxyz0123456789ABCDEF',
79
+ 'normal [plan] kind=done',
80
+ ].join('\n'));
81
+
82
+ const r = exportDiagnostics({ version: '0.4.35', logLevel: 'info' });
83
+ assert.equal(r.ok, true);
84
+
85
+ const text = readFileSync(r.path, 'utf8');
86
+ assert.doesNotMatch(text, /alice/, '用户名必须被替换');
87
+ assert.doesNotMatch(text, /secret-project/, '绝对路径必须被替换');
88
+ assert.doesNotMatch(text, /abcdefghijklmnopqrstuvwxyz/, '长 token 必须被替换');
89
+ // 但正常内容要保留(诊断价值)
90
+ assert.match(text, /\[plan\] kind=done/, '非敏感内容应保留');
91
+ });
92
+ });
93
+
94
+ // ── G4: 头部必须声明已脱敏 + 提醒自查 ───────────────────────────────────
95
+ test('G4: 导出文件头部声明脱敏范围并提醒自查', async () => {
96
+ await withTemp(async (dir) => {
97
+ seedLog(dir, 'x\n');
98
+ const r = exportDiagnostics({ version: '0.4.35', logLevel: 'debug' });
99
+ const text = readFileSync(r.path, 'utf8');
100
+
101
+ assert.match(text, /已自动脱敏/, '必须声明脱敏');
102
+ assert.match(text, /仍请自行检查/, '必须提醒用户自己再看(脱敏不是万能的)');
103
+ assert.match(text, /logLevel: debug/, '应记录导出时的日志级别(debug 说明可能含内容)');
104
+ assert.match(text, /pluginVersion: 0\.4\.35/);
105
+ });
106
+ });
107
+
108
+ // ── G5: 轮转的旧日志也要带上 ───────────────────────────────────────────
109
+ test('G5: 有 notify.log.1 时一并导出(事发记录可能在那里)', async () => {
110
+ await withTemp(async (dir) => {
111
+ const logDir = seedLog(dir, 'current line\n');
112
+ writeFileSync(join(logDir, 'notify.log.1'), 'older line from rotation\n', 'utf8');
113
+
114
+ const r = exportDiagnostics({ version: '0.4.35', logLevel: 'info' });
115
+ const text = readFileSync(r.path, 'utf8');
116
+ assert.match(text, /older line from rotation/, '轮转的旧日志必须带上');
117
+ assert.match(text, /current line/, '当前日志也要有');
118
+ });
119
+ });
120
+
121
+ // ── G6: 保留策略 —— 不留无限多的导出文件 ───────────────────────────────
122
+ test('G6: 多次导出只保留最近几份', async () => {
123
+ await withTemp(async (dir) => {
124
+ seedLog(dir, 'x\n');
125
+ for (let i = 0; i < 8; i++) {
126
+ const r = exportDiagnostics({ version: '0.4.35', logLevel: 'info' });
127
+ assert.equal(r.ok, true);
128
+ // 文件名按秒级时间戳,同秒会覆盖 —— 稍等一下保证 mtime 不同
129
+ await new Promise((res) => setTimeout(res, 15));
130
+ }
131
+ const files = readdirSync(join(dir, 'dsh-notify-say')).filter((f) => /^diagnostics-/.test(f));
132
+ assert.ok(files.length <= 5, `最多保留 5 份,实际 ${files.length}`);
133
+ });
134
+ });
135
+
136
+ // ── G7: readLogText 对不存在目录返回结构化错误 ─────────────────────────
137
+ test('G7: readLogText 在无日志时返回结构化错误', async () => {
138
+ await withTemp(async () => {
139
+ const r = readLogText();
140
+ assert.equal(r.ok, false);
141
+ assert.ok(r.error, '应给出原因');
142
+ });
143
+ });
144
+
145
+ // ── G8: 路由必须是 POST(GET 可被网页触发)──────────────────────────────
146
+ test('G8: /diagnostics 路由只接受 POST', () => {
147
+ const at = INDEX.indexOf("head === 'diagnostics'");
148
+ assert.ok(at > 0, '应有 diagnostics 路由');
149
+ const seg = INDEX.slice(at, at + 600);
150
+ assert.match(seg, /!== 'POST'/, '必须显式拒绝非 POST');
151
+ assert.match(seg, /405/, '非 POST 应回 405');
152
+ assert.match(seg, /allow: 'POST'/, '应告知正确方法');
153
+ });
154
+
155
+ // ── G9: 路由把版本与级别传进去 ─────────────────────────────────────────
156
+ test('G9: 导出时带上插件版本与当前日志级别', () => {
157
+ const at = INDEX.indexOf('exportDiagnostics({');
158
+ assert.ok(at > 0);
159
+ const seg = INDEX.slice(at, at + 300);
160
+ assert.match(seg, /version: PLUGIN_VERSION/, '应传版本');
161
+ assert.match(seg, /logLevel:/, '应传日志级别(debug 导出要标明可能含内容)');
162
+ });
163
+
164
+ // ── G10: 前端有按钮且用 POST ───────────────────────────────────────────
165
+ test('G10: 设置页有导出按钮,请求用 POST', () => {
166
+ assert.match(CLIENT, /导出诊断文件/, '应有按钮文案');
167
+ const at = CLIENT.indexOf("apiUrl('diagnostics')");
168
+ assert.ok(at > 0, '应调用 diagnostics 路由');
169
+ const seg = CLIENT.slice(at, at + 300);
170
+ assert.match(seg, /method: 'POST'/, '前端必须用 POST(后端会拒 GET)');
171
+ });
172
+
173
+ // ── G11: 按钮提示里说明脱敏范围与自查 ──────────────────────────────────
174
+ test('G11: UI 提示说明会自动脱敏但需自查', () => {
175
+ assert.match(CLIENT, /<user>/, '应说明用户名会被替换');
176
+ assert.match(CLIENT, /<path>/, '应说明路径会被替换');
177
+ assert.match(CLIENT, /仍请自己再看一遍/, '必须提醒自查 —— 脱敏不覆盖用户自己写的内容');
178
+ });