@cowwo/dsh-icon-custom 0.10.2 → 0.12.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
@@ -29,12 +29,16 @@ import { homedir } from 'node:os'
29
29
  import { join, resolve } from 'node:path'
30
30
  import {
31
31
  LAST_TURN_END_KEY,
32
+ UNREAD_SEEN_VERSION,
32
33
  applyLastTurnEnd,
33
34
  initLastTurnEnd,
34
35
  lastTurnEndStateSchema,
35
36
  lastTurnEndView,
36
37
  lastTurnEndViewSchema,
37
- normalizeUnreadConfig
38
+ mergeSeen,
39
+ normalizeSeen,
40
+ normalizeUnreadConfig,
41
+ pruneSeen
38
42
  } from './unread.js'
39
43
 
40
44
  const MAX_BYTES = 10 * 1024 * 1024 // 10 MB
@@ -330,6 +334,10 @@ export class FaviconCustomService extends TypertRemoteService {
330
334
  this.libraryDir = join(this.storageDir, 'library')
331
335
  this.indexPath = join(this.storageDir, 'library.json')
332
336
  this.unreadPath = join(this.storageDir, 'unread.json')
337
+ // The "I have looked at this session" watermark. Host-owned on purpose: it
338
+ // survives a cleared browser profile and means the same thing on every
339
+ // device, while each browser keeps its own copy as cache + offline queue.
340
+ this.seenPath = join(this.storageDir, 'unread-seen.json')
333
341
  // { activeId, icons: { [id]: { id, mime, ext, name, size, savedAt, rev, pwa, logo } } }
334
342
  this.library = { activeId: null, icons: {} }
335
343
  // The active icon's loaded state: { rev, mime, ext, name, size, savedAt, pwa, logo, bytes } | null
@@ -338,10 +346,13 @@ export class FaviconCustomService extends TypertRemoteService {
338
346
  // Kept in its own file so a settings change can never corrupt the icon
339
347
  // library index.
340
348
  this.unread = normalizeUnreadConfig(null)
349
+ // Host-side watermark: { [sessionId]: endAt } — see lib/unread.js.
350
+ this.seen = {}
341
351
  // Platform PWA manifest (raw + parsed), read once at startup.
342
352
  this.baseManifest = loadBaseManifest()
343
353
  this._load()
344
354
  this._loadUnread()
355
+ this._loadUnreadSeen()
345
356
  // "How did this session's last turn end" as an official session
346
357
  // projection. Optional registration on purpose: this plugin is a display
347
358
  // plugin first, so a deployment without the projection service loses the
@@ -439,6 +450,30 @@ export class FaviconCustomService extends TypertRemoteService {
439
450
  return writeFile(this.unreadPath, JSON.stringify(this.unread, null, 2))
440
451
  }
441
452
 
453
+ /**
454
+ * Load the host-side watermark.
455
+ *
456
+ * A missing file, an unknown `version` (a future format), or a corrupt document
457
+ * all open as an EMPTY table — never as a guess. An empty table cannot light
458
+ * anything up: the client re-baselines a session it has no mark for at "the last
459
+ * time this browser was here", so old endings stay read.
460
+ */
461
+ _loadUnreadSeen() {
462
+ try {
463
+ const raw = JSON.parse(readFileSync(this.seenPath).toString('utf8'))
464
+ if (raw === null || typeof raw !== 'object' || raw.version !== UNREAD_SEEN_VERSION) return
465
+ this.seen = pruneSeen(normalizeSeen(raw.seen))
466
+ } catch { /* no watermark yet */ }
467
+ }
468
+
469
+ /** Persist the watermark (best effort, advisory file). Temp then rename, so a crash never truncates it. */
470
+ async _saveUnreadSeen() {
471
+ await mkdir(this.storageDir, { recursive: true })
472
+ const tmp = `${this.seenPath}.tmp`
473
+ await writeFile(tmp, JSON.stringify({ version: UNREAD_SEEN_VERSION, seen: this.seen }, null, 2))
474
+ await rename(tmp, this.seenPath)
475
+ }
476
+
442
477
  /** Currently active icon as a JSON-safe status object (no raw bytes). */
443
478
  status() {
444
479
  const c = this.current
@@ -459,7 +494,7 @@ export class FaviconCustomService extends TypertRemoteService {
459
494
  * icon RPCs (`getStatus` / `setIcon` / `setPwa` / `setLogo` / `resetIcon`)
460
495
  * keep their EXACT pre-badge response shape, so a problem in this feature
461
496
  * can never break the icon itself.
462
- * @returns `{ reasons, pending, workspaceDot, clearDelaySec, hostNow }`.
497
+ * @returns `{ reasons, pending, workspaceDot, appBadge, clearDelaySec, hostNow }`.
463
498
  */
464
499
  async getUnreadRule() {
465
500
  return this._unreadRuleView()
@@ -469,7 +504,7 @@ export class FaviconCustomService extends TypertRemoteService {
469
504
  * typert endpoint: replace the unread-badge rule — which turn-end reasons
470
505
  * light the badge. The rule is applied on the client, so this takes effect
471
506
  * without re-folding any session log.
472
- * @param request - `{ reasons, pending?, workspaceDot?, clearDelaySec? }`; unknown or missing keys fall back to defaults.
507
+ * @param request - `{ reasons, pending?, workspaceDot?, appBadge?, clearDelaySec? }`; unknown or missing keys fall back to defaults.
473
508
  * @returns the stored rule.
474
509
  */
475
510
  async setUnreadRule(request) {
@@ -482,22 +517,52 @@ export class FaviconCustomService extends TypertRemoteService {
482
517
  }
483
518
 
484
519
  /**
485
- * The rule as the client reads it, plus the Host's clock.
520
+ * typert endpoint: merge this browser's "seen" watermarks into the Host's copy.
521
+ *
522
+ * The watermark lives here so that "I have looked at this session" survives a
523
+ * cleared browser profile and holds across devices; each browser keeps its own
524
+ * copy as a cache and offline queue. Merging is max-per-session and therefore
525
+ * idempotent: re-sending the whole table is harmless (that is also how a fresh
526
+ * browser migrates its localStorage marks up), and a stale client can never move
527
+ * a mark backwards.
528
+ * @param request - `{ marks: { [sessionId]: endAt } }`; dirty keys/values are dropped.
529
+ * @returns `{ seen }` — the merged table, authoritative for the caller.
530
+ */
531
+ async setUnreadSeen(request) {
532
+ if (request === null || typeof request !== 'object') {
533
+ throw new Error('缺少 marks 参数')
534
+ }
535
+ const merged = mergeSeen(this.seen, request.marks)
536
+ this.seen = pruneSeen(merged.seen)
537
+ // The write is part of the contract, NOT swallowed: this feature's whole value is
538
+ // that the watermark survives, so answering "ok" while nothing reached the disk
539
+ // is the one failure nobody can see (the client would stop retrying and another
540
+ // device would keep its old answer forever). Throwing lets the client warn once
541
+ // and retry with backoff.
542
+ await this._saveUnreadSeen()
543
+ return { seen: { ...this.seen } }
544
+ }
545
+
546
+ /**
547
+ * The rule as the client reads it, plus the Host's clock and watermark.
486
548
  *
487
549
  * `hostNow` is here because the badge compares the Host's `turn/end.time`
488
- * against a watermark this browser keeps: without a Host time sample the
489
- * browser has to guess that domain from its own clock, and a browser a few
490
- * seconds behind the Host then clears nothing. It rides this existing
491
- * response instead of a new endpoint, so an older client simply ignores it.
492
- * @returns the stored rule with `hostNow`.
550
+ * against a watermark: without a Host time sample the browser has to guess that
551
+ * domain from its own clock, and a browser a few seconds behind the Host then
552
+ * clears nothing. `seen` is the watermark itself — the authority the client
553
+ * merges into its local cache. Both ride this existing response instead of a new
554
+ * read endpoint, so an older client simply ignores the field it does not know.
555
+ * @returns the stored rule with `hostNow` and `seen`.
493
556
  */
494
557
  _unreadRuleView() {
495
558
  return {
496
559
  reasons: this.unread.reasons,
497
560
  pending: this.unread.pending,
498
561
  workspaceDot: this.unread.workspaceDot,
562
+ appBadge: this.unread.appBadge,
499
563
  clearDelaySec: this.unread.clearDelaySec,
500
- hostNow: Date.now()
564
+ hostNow: Date.now(),
565
+ seen: { ...this.seen }
501
566
  }
502
567
  }
503
568
 
@@ -53,6 +53,8 @@ const unreadRuleSchema = z.object({
53
53
  pending: z.boolean(),
54
54
  /** 实验项:工作区图标上的红点(贴产品 DOM,可一键关闭)。 */
55
55
  workspaceDot: z.boolean(),
56
+ /** 在系统应用图标上显示角标(安装为应用后生效);只决定"设几/清空",样子由系统决定。 */
57
+ appBadge: z.boolean(),
56
58
  /** 进入会话后要连续待满多少秒才算"看过";0 = 进入即已读。上限 10 分钟。 */
57
59
  clearDelaySec: z.number().int().nonnegative().max(600),
58
60
  /**
@@ -60,16 +62,33 @@ const unreadRuleSchema = z.object({
60
62
  * 客户端用它把"看过"水位换算进宿主时钟域(`turn/end.time` 也是宿主时钟):
61
63
  * 浏览器与宿主差几秒,红点就会永远清不掉。
62
64
  */
63
- hostNow: z.number()
65
+ hostNow: z.number(),
66
+ /**
67
+ * 宿主持有的"已读"水位线:会话 id → 已确认的最后一次 `turn/end`(宿主时钟域)。
68
+ *
69
+ * 它是真相,浏览器那份是缓存 + 离线队列;随本响应一起下发是为了不新增读端点
70
+ * (老客户端忽略这个字段即可)。客户端拿它按会话取 max 合并。
71
+ */
72
+ seen: z.record(z.string(), z.number())
64
73
  })
65
74
 
66
75
  const setUnreadRuleRequestSchema = z.object({
67
76
  reasons: z.record(z.string(), z.boolean()),
68
77
  pending: z.boolean().optional(),
69
78
  workspaceDot: z.boolean().optional(),
79
+ appBadge: z.boolean().optional(),
70
80
  clearDelaySec: z.number().int().nonnegative().max(600).optional()
71
81
  })
72
82
 
83
+ /** 一次水位线同步:浏览器把自己那份交上来,宿主逐会话取 max 后回权威表。 */
84
+ const setUnreadSeenRequestSchema = z.object({
85
+ marks: z.record(z.string(), z.number())
86
+ })
87
+
88
+ const unreadSeenSchema = z.object({
89
+ seen: z.record(z.string(), z.number())
90
+ })
91
+
73
92
  const setIconResultSchema = statusSchema
74
93
 
75
94
  export const TYPERT = {
@@ -218,6 +237,31 @@ export const TYPERT = {
218
237
  create: () => unreadRuleSchema
219
238
  },
220
239
  sourceLocation: { file: 'lib/index.js', line: 1, column: 1 }
240
+ },
241
+ {
242
+ id: 'dsh-icon-custom#iconCustom/setUnreadSeen',
243
+ service: 'iconCustom',
244
+ namespace: 'iconCustom',
245
+ method: 'setUnreadSeen',
246
+ invocation: { kind: 'direct' },
247
+ parameters: [
248
+ {
249
+ name: 'request',
250
+ wire: 'request',
251
+ source: 'json',
252
+ codec: {
253
+ mode: 'strict',
254
+ typeSymbol: 'dsh-icon-custom#iconCustom/setUnreadSeen:request',
255
+ create: () => setUnreadSeenRequestSchema
256
+ }
257
+ }
258
+ ],
259
+ result: {
260
+ mode: 'strict',
261
+ typeSymbol: 'dsh-icon-custom#iconCustom/setUnreadSeen:result',
262
+ create: () => unreadSeenSchema
263
+ },
264
+ sourceLocation: { file: 'lib/index.js', line: 1, column: 1 }
221
265
  }
222
266
  ],
223
267
  model: {
@@ -278,8 +322,8 @@ export const TYPERT = {
278
322
  {
279
323
  kind: 'method',
280
324
  name: 'getUnreadRule',
281
- signature: 'async getUnreadRule(): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; clearDelaySec: number; hostNow: number }>',
282
- description: "Read the unread-badge rule.",
325
+ signature: 'async getUnreadRule(): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; appBadge: boolean; clearDelaySec: number; hostNow: number; seen: Record<string, number> }>',
326
+ description: "Read the unread-badge rule, the Host clock and the seen watermark.",
283
327
  summary: "Read the unread-badge rule.",
284
328
  jsDoc: "/** Read the unread-badge rule. */",
285
329
  tags: []
@@ -287,11 +331,20 @@ export const TYPERT = {
287
331
  {
288
332
  kind: 'method',
289
333
  name: 'setUnreadRule',
290
- signature: 'async setUnreadRule(request: { reasons: Record<string, boolean>; pending?: boolean; workspaceDot?: boolean; clearDelaySec?: number }): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; clearDelaySec: number; hostNow: number }>',
334
+ signature: 'async setUnreadRule(request: { reasons: Record<string, boolean>; pending?: boolean; workspaceDot?: boolean; appBadge?: boolean; clearDelaySec?: number }): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; appBadge: boolean; clearDelaySec: number; hostNow: number; seen: Record<string, number> }>',
291
335
  description: "Replace the unread-badge rule: which turn-end reasons light the red dot.",
292
336
  summary: "Set the unread-badge rule.",
293
337
  jsDoc: "/** Set the unread-badge rule. */",
294
338
  tags: []
339
+ },
340
+ {
341
+ kind: 'method',
342
+ name: 'setUnreadSeen',
343
+ signature: 'async setUnreadSeen(request: { marks: Record<string, number> }): Promise<{ seen: Record<string, number> }>',
344
+ description: "Merge this browser's seen watermarks into the Host's copy (max per session).",
345
+ summary: "Merge the seen watermark.",
346
+ jsDoc: "/** Merge the seen watermark. */",
347
+ tags: []
295
348
  }
296
349
  ],
297
350
  types: []
package/lib/unread.js CHANGED
@@ -52,6 +52,16 @@ export const UNREAD_PENDING_DEFAULT = true
52
52
  */
53
53
  export const UNREAD_WORKSPACE_DOT_DEFAULT = true
54
54
 
55
+ /**
56
+ * 默认:在系统应用图标上显示角标(安装为应用后生效)。
57
+ *
58
+ * 它只是"同一个数字在系统图标上的投影",所以默认跟随红点一起打开;
59
+ * 关掉它等于"不要这个投影",而不等于"这个功能不存在"。
60
+ * 注意:**探测到 API ≠ 角标会出现**(安卓与 Linux 上方法存在但静默无效,
61
+ * iOS 上还额外要求通知权限),详见 docs/adr/0005-app-icon-badge.md。
62
+ */
63
+ export const UNREAD_APP_BADGE_DEFAULT = true
64
+
55
65
  /**
56
66
  * 默认:进入会话即视为已读(0 秒)。
57
67
  * 大于 0 时,你必须在那个会话里**连续**待满这么多秒它才算"看过";中途切走会
@@ -87,8 +97,8 @@ export function normalizeReasons(input) {
87
97
 
88
98
  /**
89
99
  * 把任意输入夹成完整设置。
90
- * @param input - `{ reasons?, pending?, workspaceDot?, clearDelaySec? }`,可以是脏的、缺项的、null
91
- * @returns `{ reasons, pending, workspaceDot, clearDelaySec }` —— 每一项都完整
100
+ * @param input - `{ reasons?, pending?, workspaceDot?, appBadge?, clearDelaySec? }`,可以是脏的、缺项的、null
101
+ * @returns `{ reasons, pending, workspaceDot, appBadge, clearDelaySec }` —— 每一项都完整
92
102
  */
93
103
  export function normalizeUnreadConfig(input) {
94
104
  const source = input !== null && typeof input === 'object' ? input : {}
@@ -96,6 +106,7 @@ export function normalizeUnreadConfig(input) {
96
106
  reasons: normalizeReasons(source.reasons),
97
107
  pending: typeof source.pending === 'boolean' ? source.pending : UNREAD_PENDING_DEFAULT,
98
108
  workspaceDot: typeof source.workspaceDot === 'boolean' ? source.workspaceDot : UNREAD_WORKSPACE_DOT_DEFAULT,
109
+ appBadge: typeof source.appBadge === 'boolean' ? source.appBadge : UNREAD_APP_BADGE_DEFAULT,
99
110
  clearDelaySec: normalizeClearDelay(source.clearDelaySec)
100
111
  }
101
112
  }
@@ -153,3 +164,82 @@ export const lastTurnEndViewSchema = z.object({
153
164
  endAt: z.number(),
154
165
  reason: z.string().nullable()
155
166
  })
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // 水位线(宿主持有的"已读"标记)
170
+ //
171
+ // 这是这套东西唯一的真相:浏览器那份降级为缓存兼离线队列。放宿主有两个理由——
172
+ // 清掉浏览器数据不再等于"全部未读",以及"我看过了"跨设备一致。
173
+ // 判定精度不变:仍是"进入会话并连续待够 N 秒"才算看过,规则在 client/client.js。
174
+ // ---------------------------------------------------------------------------
175
+
176
+ /** 水位线文件的版本;不认识的版本当空库(猜错会把红点全点亮)。 */
177
+ export const UNREAD_SEEN_VERSION = 1
178
+
179
+ /** 一个库最多留多少条水位线(client/client.js 的 SEEN_MAX 是它的镜像)。 */
180
+ export const UNREAD_SEEN_MAX = 400
181
+
182
+ /** 单个会话 id 的长度上限:挡脏输入,不是安全边界。 */
183
+ const SESSION_ID_MAX = 128
184
+
185
+ /** 水位线文件:`{ version, seen }`;`seen` 是 会话 id → 已确认的最后一次 `turn/end`(宿主时钟域)。 */
186
+ export const unreadSeenFileSchema = z.object({
187
+ version: z.number(),
188
+ seen: z.record(z.string(), z.number())
189
+ })
190
+
191
+ /**
192
+ * 把任意输入夹成一张干净的水位线表。
193
+ * 脏 key(空、超长、非字符串)与脏值(非有限数、≤0)直接丢掉,绝不写回文件。
194
+ * @param input - 文件内容、RPC 载荷,或任何东西
195
+ * @returns 只含合法条目的新对象
196
+ */
197
+ export function normalizeSeen(input) {
198
+ const out = {}
199
+ if (input === null || typeof input !== 'object') return out
200
+ for (const [id, at] of Object.entries(input)) {
201
+ if (typeof id !== 'string' || id === '' || id.length > SESSION_ID_MAX) continue
202
+ if (typeof at !== 'number' || !Number.isFinite(at) || at <= 0) continue
203
+ out[id] = at
204
+ }
205
+ return out
206
+ }
207
+
208
+ /**
209
+ * 合并一批水位线:逐会话取 max。
210
+ *
211
+ * "标记永不倒退"是这套东西唯一的并发保护:两个浏览器(或两个 dsh 进程)同时写,
212
+ * 后写者也不会把别人更新的标记盖回旧的——那会让已清掉的红点"诈尸"。
213
+ * @param seen - 现有水位线表
214
+ * @param marks - 要并入的标记
215
+ * @returns `{ seen, changed }`,`changed` 表示是否有条目前进
216
+ */
217
+ export function mergeSeen(seen, marks) {
218
+ const base = normalizeSeen(seen)
219
+ const incoming = normalizeSeen(marks)
220
+ let changed = false
221
+ for (const [id, at] of Object.entries(incoming)) {
222
+ const previous = base[id]
223
+ if (typeof previous === 'number' && previous >= at) continue
224
+ base[id] = at
225
+ changed = true
226
+ }
227
+ return { seen: base, changed }
228
+ }
229
+
230
+ /**
231
+ * 只留最新的 N 条水位线。
232
+ *
233
+ * 丢掉的都是"很久以前就看过"的记录,不会把红点变回来:客户端对没有标记的会话
234
+ * 按"这台浏览器上次在线"重新起基线,老结束仍算已读(见 client/client.js 的
235
+ * `ensureSeenBaseline`)。所以按时间保留最新即可。
236
+ * @param seen - 水位线表
237
+ * @param max - 上限
238
+ * @returns 新对象
239
+ */
240
+ export function pruneSeen(seen, max = UNREAD_SEEN_MAX) {
241
+ const entries = Object.entries(normalizeSeen(seen))
242
+ if (entries.length <= max) return Object.fromEntries(entries)
243
+ entries.sort((a, b) => b[1] - a[1])
244
+ return Object.fromEntries(entries.slice(0, max))
245
+ }
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@cowwo/dsh-icon-custom",
3
- "version": "0.10.2",
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.",
3
+ "version": "0.12.0",
4
+ "description": "自定义浏览器标签页图标:在设置页上传 SVG/PNG/ICO,立即生效并持久保存,自动适配安卓/iOS/平板 PWA 图标,并可把未读数字显示为系统应用图标角标(Windows 任务栏)。· Custom browser tab favicon: upload SVG/PNG/ICO from the settings page, applies instantly and persists, auto-sizes PWA icons for Android/iOS/tablets, and projects the unread count onto the system app-icon badge (Windows taskbar).",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "license": "MIT",
8
+ "scripts": {
9
+ "test": "node test/app-badge.mjs && node test/unread-panel.mjs && node test/unread-clear.mjs && node test/unread-rows.mjs && node test/unread-seen.mjs && node test/unread-host-sync.mjs"
10
+ },
8
11
  "dependencies": {
9
12
  "jimp": "^1.6.1",
10
13
  "zod": "^4.4.3"