@cowwo/dsh-icon-custom 0.7.0 → 0.8.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/lib/index.js CHANGED
@@ -27,6 +27,15 @@ import { mkdir, rename, writeFile } from 'node:fs/promises'
27
27
  import { createRequire } from 'node:module'
28
28
  import { homedir } from 'node:os'
29
29
  import { join, resolve } from 'node:path'
30
+ import {
31
+ LAST_TURN_END_KEY,
32
+ applyLastTurnEnd,
33
+ initLastTurnEnd,
34
+ lastTurnEndStateSchema,
35
+ lastTurnEndView,
36
+ lastTurnEndViewSchema,
37
+ normalizeUnreadConfig
38
+ } from './unread.js'
30
39
 
31
40
  const MAX_BYTES = 10 * 1024 * 1024 // 10 MB
32
41
  // Normalized mime -> safe extension. CUR (mouse cursor) is accepted as ICO.
@@ -306,13 +315,33 @@ export class FaviconCustomService extends TypertRemoteService {
306
315
  this.storageDir = join(resolveHome(), 'custom-favicon')
307
316
  this.libraryDir = join(this.storageDir, 'library')
308
317
  this.indexPath = join(this.storageDir, 'library.json')
318
+ this.unreadPath = join(this.storageDir, 'unread.json')
309
319
  // { activeId, icons: { [id]: { id, mime, ext, name, size, savedAt, rev, pwa, logo } } }
310
320
  this.library = { activeId: null, icons: {} }
311
321
  // The active icon's loaded state: { rev, mime, ext, name, size, savedAt, pwa, logo, bytes } | null
312
322
  this.current = null
323
+ // Unread-badge rule: { reasons: { [reason]: boolean }, pending: boolean }.
324
+ // Kept in its own file so a settings change can never corrupt the icon
325
+ // library index.
326
+ this.unread = normalizeUnreadConfig(null)
313
327
  // Platform PWA manifest (raw + parsed), read once at startup.
314
328
  this.baseManifest = loadBaseManifest()
315
329
  this._load()
330
+ this._loadUnread()
331
+ // "How did this session's last turn end" as an official session
332
+ // projection. Optional registration on purpose: this plugin is a display
333
+ // plugin first, so a deployment without the projection service loses the
334
+ // badge's number, never the favicon.
335
+ ctx.inject(['sessionProjections'], (projectionCtx) => {
336
+ projectionCtx.sessionProjections.register({
337
+ key: LAST_TURN_END_KEY,
338
+ stateSchema: lastTurnEndStateSchema,
339
+ stateVersion: 1,
340
+ init: () => initLastTurnEnd(),
341
+ apply: applyLastTurnEnd,
342
+ wire: { viewSchema: lastTurnEndViewSchema, view: lastTurnEndView }
343
+ })
344
+ })
316
345
  ctx.effect(() => ctx.webServer.register({
317
346
  kind: 'exact',
318
347
  path: '/icon-custom.svg',
@@ -383,6 +412,19 @@ export class FaviconCustomService extends TypertRemoteService {
383
412
  return writeFile(this.indexPath, JSON.stringify(this.library, null, 2))
384
413
  }
385
414
 
415
+ /** Load the unread-badge rule (missing or corrupt file = defaults). */
416
+ _loadUnread() {
417
+ try {
418
+ const raw = JSON.parse(readFileSync(this.unreadPath).toString('utf8'))
419
+ this.unread = normalizeUnreadConfig(raw)
420
+ } catch { /* no persisted rule yet: keep defaults */ }
421
+ }
422
+
423
+ /** Persist the unread-badge rule (best effort, advisory file). */
424
+ _saveUnread() {
425
+ return writeFile(this.unreadPath, JSON.stringify(this.unread, null, 2))
426
+ }
427
+
386
428
  /** Currently active icon as a JSON-safe status object (no raw bytes). */
387
429
  status() {
388
430
  const c = this.current
@@ -396,6 +438,35 @@ export class FaviconCustomService extends TypertRemoteService {
396
438
  return this.status()
397
439
  }
398
440
 
441
+ /**
442
+ * typert endpoint: read the unread-badge rule.
443
+ *
444
+ * Deliberately its own endpoint instead of a field on `status()`: the five
445
+ * icon RPCs (`getStatus` / `setIcon` / `setPwa` / `setLogo` / `resetIcon`)
446
+ * keep their EXACT pre-badge response shape, so a problem in this feature
447
+ * can never break the icon itself.
448
+ * @returns `{ reasons, pending }`.
449
+ */
450
+ async getUnreadRule() {
451
+ return { reasons: this.unread.reasons, pending: this.unread.pending, workspaceDot: this.unread.workspaceDot }
452
+ }
453
+
454
+ /**
455
+ * typert endpoint: replace the unread-badge rule — which turn-end reasons
456
+ * light the badge. The rule is applied on the client, so this takes effect
457
+ * without re-folding any session log.
458
+ * @param request - `{ reasons, pending? }`; unknown or missing keys fall back to defaults.
459
+ * @returns the stored rule.
460
+ */
461
+ async setUnreadRule(request) {
462
+ if (request === null || typeof request !== 'object') {
463
+ throw new Error('缺少 reasons 参数')
464
+ }
465
+ this.unread = normalizeUnreadConfig(request)
466
+ await this._saveUnread().catch(() => {}) // rule file is advisory
467
+ return { reasons: this.unread.reasons, pending: this.unread.pending, workspaceDot: this.unread.workspaceDot }
468
+ }
469
+
399
470
  /** typert endpoint: store a new icon uploaded as a data URL and make it active. */
400
471
  async setIcon(request) {
401
472
  if (request === null || typeof request !== 'object') throw new Error('缺少图标参数')
@@ -34,6 +34,24 @@ const setLogoRequestSchema = z.object({
34
34
  enabled: z.boolean()
35
35
  })
36
36
 
37
+ /**
38
+ * Unread-badge rule: which `turn/end` reasons light the badge, plus whether
39
+ * "someone is waiting for you" counts. Its own endpoint pair on purpose — the
40
+ * icon RPCs above keep their exact pre-badge response shape.
41
+ */
42
+ const unreadRuleSchema = z.object({
43
+ reasons: z.record(z.string(), z.boolean()),
44
+ pending: z.boolean(),
45
+ /** 实验项:工作区图标上的红点(贴产品 DOM,可一键关闭)。 */
46
+ workspaceDot: z.boolean()
47
+ })
48
+
49
+ const setUnreadRuleRequestSchema = z.object({
50
+ reasons: z.record(z.string(), z.boolean()),
51
+ pending: z.boolean().optional(),
52
+ workspaceDot: z.boolean().optional()
53
+ })
54
+
37
55
  const setIconResultSchema = statusSchema
38
56
 
39
57
  export const TYPERT = {
@@ -143,6 +161,45 @@ export const TYPERT = {
143
161
  schema: statusSchema
144
162
  },
145
163
  sourceLocation: { file: 'lib/index.js', line: 1, column: 1 }
164
+ },
165
+ {
166
+ id: 'dsh-icon-custom#iconCustom/getUnreadRule',
167
+ service: 'iconCustom',
168
+ namespace: 'iconCustom',
169
+ method: 'getUnreadRule',
170
+ invocation: { kind: 'direct' },
171
+ parameters: [],
172
+ result: {
173
+ mode: 'strict',
174
+ typeSymbol: 'dsh-icon-custom#iconCustom/getUnreadRule:result',
175
+ schema: unreadRuleSchema
176
+ },
177
+ sourceLocation: { file: 'lib/index.js', line: 1, column: 1 }
178
+ },
179
+ {
180
+ id: 'dsh-icon-custom#iconCustom/setUnreadRule',
181
+ service: 'iconCustom',
182
+ namespace: 'iconCustom',
183
+ method: 'setUnreadRule',
184
+ invocation: { kind: 'direct' },
185
+ parameters: [
186
+ {
187
+ name: 'request',
188
+ wire: 'request',
189
+ source: 'json',
190
+ codec: {
191
+ mode: 'strict',
192
+ typeSymbol: 'dsh-icon-custom#iconCustom/setUnreadRule:request',
193
+ schema: setUnreadRuleRequestSchema
194
+ }
195
+ }
196
+ ],
197
+ result: {
198
+ mode: 'strict',
199
+ typeSymbol: 'dsh-icon-custom#iconCustom/setUnreadRule:result',
200
+ schema: unreadRuleSchema
201
+ },
202
+ sourceLocation: { file: 'lib/index.js', line: 1, column: 1 }
146
203
  }
147
204
  ],
148
205
  model: {
@@ -199,6 +256,24 @@ export const TYPERT = {
199
256
  summary: "Clear the custom favicon.",
200
257
  jsDoc: "/** Clear the custom favicon. */",
201
258
  tags: []
259
+ },
260
+ {
261
+ kind: 'method',
262
+ name: 'getUnreadRule',
263
+ signature: 'async getUnreadRule(): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean }>',
264
+ description: "Read the unread-badge rule.",
265
+ summary: "Read the unread-badge rule.",
266
+ jsDoc: "/** Read the unread-badge rule. */",
267
+ tags: []
268
+ },
269
+ {
270
+ kind: 'method',
271
+ name: 'setUnreadRule',
272
+ signature: 'async setUnreadRule(request: { reasons: Record<string, boolean>; pending?: boolean; workspaceDot?: boolean }): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean }>',
273
+ description: "Replace the unread-badge rule: which turn-end reasons light the red dot.",
274
+ summary: "Set the unread-badge rule.",
275
+ jsDoc: "/** Set the unread-badge rule. */",
276
+ tags: []
202
277
  }
203
278
  ],
204
279
  types: []
package/lib/unread.js ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * dsh-icon-custom · "未读"规则(宿主半,纯函数)
3
+ *
4
+ * 只干一件事:把会话日志里的 `turn/end` 折叠成一条**事实**——
5
+ * 「这个会话最后一次是怎么结束的、什么时候结束的」。
6
+ *
7
+ * 它**不判断**「这种结束值不值得亮红点」。那是用户设置,由客户端套用。
8
+ * 这样勾掉一个原因会立刻生效,不需要回头重折任何日志。
9
+ *
10
+ * 这个文件不碰 cordis、不碰文件系统、不认任何其他插件:
11
+ * 纯 reducer + 一套原因词汇表。以后想把它搬进独立的包,原样搬走即可。
12
+ */
13
+ import { z } from 'zod'
14
+
15
+ /**
16
+ * 红点可以为哪些结束原因亮起来。
17
+ * `aborted`(被取消)按取消来源拆成两条——你亲手按的停止不该提醒你。
18
+ */
19
+ export const UNREAD_REASONS = [
20
+ 'completed',
21
+ 'error',
22
+ 'blocked',
23
+ 'max-tokens',
24
+ 'interrupted',
25
+ 'aborted:user',
26
+ 'aborted:other'
27
+ ]
28
+
29
+ /** 默认:除了"你自己按的停止",其余都算。 */
30
+ export const UNREAD_REASON_DEFAULTS = {
31
+ completed: true,
32
+ error: true,
33
+ blocked: true,
34
+ 'max-tokens': true,
35
+ interrupted: true,
36
+ 'aborted:user': false,
37
+ 'aborted:other': true
38
+ }
39
+
40
+ /** 投影 key:客户端从 `entry.projectionValues[LAST_TURN_END_KEY]` 读它。 */
41
+ export const LAST_TURN_END_KEY = 'lastTurnEnd'
42
+
43
+ /** 默认:门铃("有人在等你")也算一条。 */
44
+ export const UNREAD_PENDING_DEFAULT = true
45
+
46
+ /**
47
+ * 默认:在工作区那一行的文件夹图标上画红点。
48
+ *
49
+ * 这是全套功能里**唯一贴着产品 DOM 走**的部分(侧栏那片没有插槽),
50
+ * 所以它有自己的开关:一旦 DSH 升级改了结构导致它失效或画歪,
51
+ * 关掉它就行,红点数字/清单/跳转那些稳的部分完全不受影响。
52
+ */
53
+ export const UNREAD_WORKSPACE_DOT_DEFAULT = true
54
+
55
+ /** 把任意输入夹成一张完整、只有布尔值的开关表(缺项取默认)。 */
56
+ export function normalizeReasons(input) {
57
+ const out = {}
58
+ for (const key of UNREAD_REASONS) {
59
+ const value = input !== null && typeof input === 'object' ? input[key] : undefined
60
+ out[key] = typeof value === 'boolean' ? value : UNREAD_REASON_DEFAULTS[key]
61
+ }
62
+ return out
63
+ }
64
+
65
+ /**
66
+ * 把任意输入夹成完整设置。
67
+ * @param input - `{ reasons?, pending? }`,可以是脏的、缺项的、null
68
+ * @returns `{ reasons, pending }` —— 两张表都完整
69
+ */
70
+ export function normalizeUnreadConfig(input) {
71
+ const source = input !== null && typeof input === 'object' ? input : {}
72
+ return {
73
+ reasons: normalizeReasons(source.reasons),
74
+ pending: typeof source.pending === 'boolean' ? source.pending : UNREAD_PENDING_DEFAULT,
75
+ workspaceDot: typeof source.workspaceDot === 'boolean' ? source.workspaceDot : UNREAD_WORKSPACE_DOT_DEFAULT
76
+ }
77
+ }
78
+
79
+ /**
80
+ * 一个 `turn/end` 的 reason → 稳定的原因 key。
81
+ * `aborted` 再看一层 `reason.kind`:`user` 是你自己按的,其余是重启/热更/父级取消。
82
+ * @param reason - `TurnEndReason`
83
+ * @returns 原因 key(`'aborted:user'` / `'aborted:other'` / 原 kind / `'unknown'`)
84
+ */
85
+ export function reasonKey(reason) {
86
+ const kind = reason !== null && typeof reason === 'object' && typeof reason.kind === 'string' ? reason.kind : 'unknown'
87
+ if (kind !== 'aborted') return kind
88
+ const cause = reason.reason !== null && typeof reason.reason === 'object' ? reason.reason.kind : undefined
89
+ return cause === 'user' ? 'aborted:user' : 'aborted:other'
90
+ }
91
+
92
+ /** 折叠初始态:还没有任何回合结束过。 */
93
+ export function initLastTurnEnd() {
94
+ return { endAt: 0, reason: null }
95
+ }
96
+
97
+ /**
98
+ * 纯转移:只有 `turn/end` 改动它,其余事件原样返回同一个引用
99
+ * (契约要求 `Object.is` 不变以产生零下游工作)。
100
+ * @param state - 覆盖此前全部事件的状态
101
+ * @param event - 下一个已提交的会话事件
102
+ * @returns 下一个状态(事件与本单元无关时返回同一个引用)
103
+ */
104
+ export function applyLastTurnEnd(state, event) {
105
+ if (event === null || typeof event !== 'object' || event.type !== 'turn/end') return state
106
+ const data = event.data !== null && typeof event.data === 'object' ? event.data : {}
107
+ const reason = reasonKey(data.reason)
108
+ const endAt = typeof event.time === 'number' ? event.time : 0
109
+ if (state.endAt === endAt && state.reason === reason) return state
110
+ return { endAt, reason }
111
+ }
112
+
113
+ /** 状态 → 上线载荷。 */
114
+ export function lastTurnEndView(state) {
115
+ return {
116
+ endAt: typeof state.endAt === 'number' ? state.endAt : 0,
117
+ reason: typeof state.reason === 'string' && state.reason !== '' ? state.reason : null
118
+ }
119
+ }
120
+
121
+ /** 持久化折叠状态(纯 JSON,供投影缓存检查点)。 */
122
+ export const lastTurnEndStateSchema = z.object({
123
+ endAt: z.number(),
124
+ reason: z.string().nullable()
125
+ })
126
+
127
+ /** 上线载荷。 */
128
+ export const lastTurnEndViewSchema = z.object({
129
+ endAt: z.number(),
130
+ reason: z.string().nullable()
131
+ })
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@cowwo/dsh-icon-custom",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "自定义浏览器标签页图标:在设置页上传 SVG/PNG/ICO,立即生效并持久保存,自动适配安卓/iOS/平板 PWA 图标。· Custom browser tab favicon: upload SVG/PNG/ICO from the settings page, applies instantly and persists, auto-sizes PWA icons for Android/iOS/tablets.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "license": "MIT",
8
8
  "dependencies": {
9
- "jimp": "^1.6.1"
9
+ "jimp": "^1.6.1",
10
+ "zod": "^4.4.3"
10
11
  },
11
12
  "dsh": {
12
13
  "bundle": {
@@ -33,8 +34,7 @@
33
34
  "@deepseek-ai/dsh-client-connection": "*",
34
35
  "@deepseek-ai/dsh-client-locale": "*",
35
36
  "@deepseek-ai/dsh-client-runtime": "*",
36
- "@deepseek-ai/dsh-typert-protocol": "*",
37
- "zod": "^4.4.3"
37
+ "@deepseek-ai/dsh-typert-protocol": "*"
38
38
  },
39
39
  "peerDependenciesMeta": {
40
40
  "@deepseek-ai/cordis": {
@@ -51,9 +51,6 @@
51
51
  },
52
52
  "@deepseek-ai/dsh-typert-protocol": {
53
53
  "optional": true
54
- },
55
- "zod": {
56
- "optional": true
57
54
  }
58
55
  },
59
56
  "files": [