@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/README.md +34 -0
- package/README.zh.md +39 -4
- package/client/client.js +582 -84
- package/lib/index.js +75 -10
- package/lib/typert.host.js +57 -4
- package/lib/unread.js +92 -2
- package/package.json +5 -2
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
|
-
|
|
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
|
-
*
|
|
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
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
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
|
|
package/lib/typert.host.js
CHANGED
|
@@ -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.
|
|
4
|
-
"description": "自定义浏览器标签页图标:在设置页上传 SVG/PNG/ICO,立即生效并持久保存,自动适配安卓/iOS/平板 PWA
|
|
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"
|