@cowwo/dsh-lookout 0.1.2 → 0.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 +11 -1
- package/client/client.js +1899 -0
- package/lib/icon/index.js +54 -0
- package/lib/icon/workspace-prefs.js +129 -0
- package/lib/typert.host.js +88 -1
- package/package.json +1 -1
package/lib/icon/index.js
CHANGED
|
@@ -41,6 +41,13 @@ import {
|
|
|
41
41
|
pruneSeen
|
|
42
42
|
} from './unread.js'
|
|
43
43
|
import { normalizeRunningConfig } from './running.js'
|
|
44
|
+
import {
|
|
45
|
+
emptyWorkspacePrefs,
|
|
46
|
+
normalizeWorkspacePrefs,
|
|
47
|
+
readWorkspacePrefs,
|
|
48
|
+
workspacePrefsFilePath,
|
|
49
|
+
writeWorkspacePrefs
|
|
50
|
+
} from './workspace-prefs.js'
|
|
44
51
|
|
|
45
52
|
const MAX_BYTES = 10 * 1024 * 1024 // 10 MB
|
|
46
53
|
// Normalized mime -> safe extension. CUR (mouse cursor) is accepted as ICO.
|
|
@@ -344,6 +351,10 @@ export class FaviconCustomService extends TypertRemoteService {
|
|
|
344
351
|
// list already carries each session's `running`), so nothing about it is
|
|
345
352
|
// stored here — only whether to show the projection of it.
|
|
346
353
|
this.runningPath = join(this.storageDir, 'running.json')
|
|
354
|
+
// Per-workspace preferences (tag / color / pinned / hidden). Same folder as
|
|
355
|
+
// the icon library, but its own file + endpoint pair: a corrupt preference
|
|
356
|
+
// document must never take the favicon or the badge rules down with it.
|
|
357
|
+
this.workspacePrefsPath = workspacePrefsFilePath(this.storageDir)
|
|
347
358
|
// { activeId, icons: { [id]: { id, mime, ext, name, size, savedAt, rev, pwa, logo } } }
|
|
348
359
|
this.library = { activeId: null, icons: {} }
|
|
349
360
|
// The active icon's loaded state: { rev, mime, ext, name, size, savedAt, pwa, logo, bytes } | null
|
|
@@ -355,6 +366,9 @@ export class FaviconCustomService extends TypertRemoteService {
|
|
|
355
366
|
// Running-mark switch: whether the yellow projection of the official
|
|
356
367
|
// `running` fact is drawn. See lib/running.js.
|
|
357
368
|
this.running = normalizeRunningConfig(null)
|
|
369
|
+
// Workspace preferences: { version: 1, workspaces: { [workspaceId]: { tag, color, pinned, hidden, pinAnchor } } }.
|
|
370
|
+
// The client sends the whole document on every set (full replacement).
|
|
371
|
+
this.workspacePrefs = emptyWorkspacePrefs()
|
|
358
372
|
// Host-side watermark: { [sessionId]: endAt } — see lib/unread.js.
|
|
359
373
|
this.seen = {}
|
|
360
374
|
// Platform PWA manifest (raw + parsed), read once at startup.
|
|
@@ -362,6 +376,7 @@ export class FaviconCustomService extends TypertRemoteService {
|
|
|
362
376
|
this._load()
|
|
363
377
|
this._loadUnread()
|
|
364
378
|
this._loadRunning()
|
|
379
|
+
this._loadWorkspacePrefs()
|
|
365
380
|
this._loadUnreadSeen()
|
|
366
381
|
// "How did this session's last turn end" as an official session
|
|
367
382
|
// projection. Optional registration on purpose: this plugin is a display
|
|
@@ -477,6 +492,16 @@ export class FaviconCustomService extends TypertRemoteService {
|
|
|
477
492
|
return writeFile(this.runningPath, JSON.stringify(this.running, null, 2))
|
|
478
493
|
}
|
|
479
494
|
|
|
495
|
+
/** Load workspace preferences (missing or corrupt file = empty document). */
|
|
496
|
+
_loadWorkspacePrefs() {
|
|
497
|
+
this.workspacePrefs = readWorkspacePrefs(this.workspacePrefsPath)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Persist workspace preferences (best effort; atomic tmp+rename in the helper). */
|
|
501
|
+
_saveWorkspacePrefs() {
|
|
502
|
+
return writeWorkspacePrefs(this.workspacePrefsPath, this.workspacePrefs)
|
|
503
|
+
}
|
|
504
|
+
|
|
480
505
|
/**
|
|
481
506
|
* Load the host-side watermark.
|
|
482
507
|
*
|
|
@@ -585,6 +610,35 @@ export class FaviconCustomService extends TypertRemoteService {
|
|
|
585
610
|
return { ...this.running }
|
|
586
611
|
}
|
|
587
612
|
|
|
613
|
+
/**
|
|
614
|
+
* typert endpoint: read the per-workspace preferences (tag / color / pinned /
|
|
615
|
+
* hidden). A copy, not the live object: a caller must not be able to mutate the
|
|
616
|
+
* Host's state by editing the response.
|
|
617
|
+
* @returns `{ version: 1, workspaces: { [workspaceId]: { tag, color, pinned, hidden, pinAnchor } } }`.
|
|
618
|
+
*/
|
|
619
|
+
async getWorkspacePrefs() {
|
|
620
|
+
return normalizeWorkspacePrefs(this.workspacePrefs)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* typert endpoint: replace the whole workspace-preferences document.
|
|
625
|
+
*
|
|
626
|
+
* Full replacement, not a patch: the client owns the document and sends it
|
|
627
|
+
* whole, so a workspace the client dropped disappears here too. Dirty input is
|
|
628
|
+
* normalized (invalid ids/values degrade to defaults) and never throws; the
|
|
629
|
+
* write is best effort, like the other advisory preference files.
|
|
630
|
+
* @param request - `{ version: 1, workspaces: { ... } }`.
|
|
631
|
+
* @returns the stored (normalized) document.
|
|
632
|
+
*/
|
|
633
|
+
async setWorkspacePrefs(request) {
|
|
634
|
+
if (request === null || typeof request !== 'object' || Array.isArray(request)) {
|
|
635
|
+
throw new Error('缺少 workspaces 参数')
|
|
636
|
+
}
|
|
637
|
+
this.workspacePrefs = normalizeWorkspacePrefs(request)
|
|
638
|
+
await this._saveWorkspacePrefs().catch(() => {}) // preference file is advisory
|
|
639
|
+
return normalizeWorkspacePrefs(this.workspacePrefs)
|
|
640
|
+
}
|
|
641
|
+
|
|
588
642
|
/**
|
|
589
643
|
* typert endpoint: merge this browser's "seen" watermarks into the Host's copy.
|
|
590
644
|
*
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-lookout · 工作区偏好(标签/隐藏/置顶)的宿主半
|
|
3
|
+
*
|
|
4
|
+
* 只干两件事:
|
|
5
|
+
* 1. 把任意脏输入夹成一份**完整、可安全落盘**的偏好文档;
|
|
6
|
+
* 2. 在 $DSH_HOME/custom-favicon/workspace-prefs.json 上读/写这份文档。
|
|
7
|
+
*
|
|
8
|
+
* 设计约束(与 unread.js 的规则开关同一条思路):
|
|
9
|
+
* * normalize 永不抛错 —— 文件被手改坏、客户端版本对不上,都只降级成默认值,
|
|
10
|
+
* 绝不因为一条偏好把图标/红点这些主功能一起带崩;
|
|
11
|
+
* * 写盘 best effort —— 调用方(index.js)吞掉落盘错误,内存里的偏好照常生效;
|
|
12
|
+
* * 文档整份覆盖 —— 客户端每次提交完整文档,宿主不保留任何"上一版残留"。
|
|
13
|
+
*
|
|
14
|
+
* 文档形状(version 固定为 1):
|
|
15
|
+
* { "version": 1, "workspaces": { "<workspaceId>": { "tag", "color",
|
|
16
|
+
* "pinned", "hidden", "pinAnchor" } } }
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync } from 'node:fs'
|
|
19
|
+
import { mkdir, rename, writeFile } from 'node:fs/promises'
|
|
20
|
+
import { dirname, join } from 'node:path'
|
|
21
|
+
|
|
22
|
+
/** 文档版本。未来改结构时靠它整份丢弃旧文档,而不是猜字段。 */
|
|
23
|
+
export const WORKSPACE_PREFS_VERSION = 1
|
|
24
|
+
|
|
25
|
+
/** 与 library.json / unread.json 同目录:custom-favicon/。 */
|
|
26
|
+
export const WORKSPACE_PREFS_FILE = 'workspace-prefs.json'
|
|
27
|
+
|
|
28
|
+
/** 标签颜色白名单。非法颜色一律落到 gray,不猜"最接近的颜色"。 */
|
|
29
|
+
export const WORKSPACE_COLORS = ['red', 'orange', 'yellow', 'green', 'cyan', 'blue', 'purple', 'gray']
|
|
30
|
+
|
|
31
|
+
/** 非法/缺省颜色。 */
|
|
32
|
+
export const WORKSPACE_COLOR_DEFAULT = 'gray'
|
|
33
|
+
|
|
34
|
+
/** 标签最多 6 个 Unicode 字符(不是 UTF-16 单元:emoji 也只算一个)。 */
|
|
35
|
+
export const WORKSPACE_TAG_MAX = 6
|
|
36
|
+
|
|
37
|
+
/** 工作区 id 长度上限(与客户端约定一致)。 */
|
|
38
|
+
export const WORKSPACE_ID_MAX = 200
|
|
39
|
+
|
|
40
|
+
/** 危险对象键:绝不能通过 JSON 里的 `__proto__` 改到原型。 */
|
|
41
|
+
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
42
|
+
|
|
43
|
+
/** 空文档。文件缺失/损坏/版本未知时都用它。 */
|
|
44
|
+
export function emptyWorkspacePrefs() {
|
|
45
|
+
return { version: WORKSPACE_PREFS_VERSION, workspaces: {} }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 只留可打印字符:去掉控制字符(Cc),再 trim。 */
|
|
49
|
+
export function normalizeTag(input) {
|
|
50
|
+
if (typeof input !== 'string') return ''
|
|
51
|
+
const cleaned = input.replace(/\p{Cc}/gu, '').trim()
|
|
52
|
+
return [...cleaned].slice(0, WORKSPACE_TAG_MAX).join('')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 颜色白名单,非法回落 gray。 */
|
|
56
|
+
export function normalizeColor(input) {
|
|
57
|
+
return WORKSPACE_COLORS.includes(input) ? input : WORKSPACE_COLOR_DEFAULT
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 夹一个工作区条目。未知字段丢弃,坏类型一律降级:
|
|
62
|
+
* tag 非法 → '';color 非法 → 'gray';pinned/hidden 非 true → false;
|
|
63
|
+
* pinAnchor 非 string → null。
|
|
64
|
+
*/
|
|
65
|
+
export function normalizeWorkspaceEntry(input) {
|
|
66
|
+
const source = input !== null && typeof input === 'object' && !Array.isArray(input) ? input : {}
|
|
67
|
+
return {
|
|
68
|
+
tag: normalizeTag(source.tag),
|
|
69
|
+
color: normalizeColor(source.color),
|
|
70
|
+
// 只认真正的 true:字符串 'true' / 1 都不能把工作区置顶或藏起来。
|
|
71
|
+
pinned: source.pinned === true,
|
|
72
|
+
hidden: source.hidden === true,
|
|
73
|
+
pinAnchor: typeof source.pinAnchor === 'string' ? source.pinAnchor : null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 工作区 id 是否可用:非空 string,长度不超过 200。 */
|
|
78
|
+
export function isValidWorkspaceId(input) {
|
|
79
|
+
return typeof input === 'string' && input.length > 0 && input.length <= WORKSPACE_ID_MAX
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 把任意输入夹成完整文档 —— 永不抛错。
|
|
84
|
+
* @param {unknown} input - 可能是 null、数组、字符串、缺字段、字段类型全错的对象,或一份旧文档
|
|
85
|
+
* @returns {{ version: 1, workspaces: Record<string, { tag: string, color: string, pinned: boolean, hidden: boolean, pinAnchor: string | null }> }}
|
|
86
|
+
*/
|
|
87
|
+
export function normalizeWorkspacePrefs(input) {
|
|
88
|
+
const document = emptyWorkspacePrefs()
|
|
89
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) return document
|
|
90
|
+
const workspaces = input.workspaces
|
|
91
|
+
if (workspaces === null || typeof workspaces !== 'object' || Array.isArray(workspaces)) return document
|
|
92
|
+
for (const [workspaceId, entry] of Object.entries(workspaces)) {
|
|
93
|
+
if (!isValidWorkspaceId(workspaceId) || UNSAFE_KEYS.has(workspaceId)) continue
|
|
94
|
+
document.workspaces[workspaceId] = normalizeWorkspaceEntry(entry)
|
|
95
|
+
}
|
|
96
|
+
return document
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 偏好文件路径(与图标库同目录)。 */
|
|
100
|
+
export function workspacePrefsFilePath(storageDir) {
|
|
101
|
+
return join(storageDir, WORKSPACE_PREFS_FILE)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 读偏好文档。文件缺失、空文件、坏 JSON、版本/结构不对 —— 一律返回空文档,绝不抛错。
|
|
106
|
+
* 同步读:与 index.js 其余"启动时读一次"的文件保持一致,调用方不必等 Promise。
|
|
107
|
+
*/
|
|
108
|
+
export function readWorkspacePrefs(filePath) {
|
|
109
|
+
try {
|
|
110
|
+
const raw = readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '')
|
|
111
|
+
return normalizeWorkspacePrefs(JSON.parse(raw))
|
|
112
|
+
} catch {
|
|
113
|
+
return emptyWorkspacePrefs()
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 原子写偏好文档:先写 `<file>.tmp`,再 rename 覆盖正式文件,
|
|
119
|
+
* 这样进程在写一半时被杀也不会留下半截 JSON(照 unread-seen.json 的写法)。
|
|
120
|
+
* 失败会 throw,由调用方决定是否吞掉 —— 偏好文件是 advisory(尽力而为)。
|
|
121
|
+
*/
|
|
122
|
+
export async function writeWorkspacePrefs(filePath, prefs) {
|
|
123
|
+
const document = normalizeWorkspacePrefs(prefs)
|
|
124
|
+
await mkdir(dirname(filePath), { recursive: true })
|
|
125
|
+
const tmp = `${filePath}.tmp`
|
|
126
|
+
await writeFile(tmp, JSON.stringify(document, null, 2))
|
|
127
|
+
await rename(tmp, filePath)
|
|
128
|
+
return document
|
|
129
|
+
}
|
package/lib/typert.host.js
CHANGED
|
@@ -116,6 +116,36 @@ const setRunningRuleRequestSchema = z.object({
|
|
|
116
116
|
headerRing: z.boolean().optional()
|
|
117
117
|
})
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* 工作区偏好(标签 / 颜色 / 置顶 / 隐藏)。
|
|
121
|
+
*
|
|
122
|
+
* 与图标 RPC 分开的一对端点:偏好文件坏了最多丢标签,不能拖累 favicon、红点
|
|
123
|
+
* 和进行中黄点。文档整份读写(客户端每次提交完整文档,宿主整份覆盖)。
|
|
124
|
+
*/
|
|
125
|
+
const workspacePrefsEntrySchema = z.object({
|
|
126
|
+
// 按 Unicode 字符数(不是 UTF-16 长度)限 6:6 个 emoji 是合法标签。
|
|
127
|
+
tag: z.string().refine((value) => [...value].length <= 6, { message: 'tag 最多 6 个字符' }),
|
|
128
|
+
color: z.enum(['red', 'orange', 'yellow', 'green', 'cyan', 'blue', 'purple', 'gray']),
|
|
129
|
+
pinned: z.boolean(),
|
|
130
|
+
hidden: z.boolean(),
|
|
131
|
+
pinAnchor: z.string().nullable()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const workspacePrefsSchema = z.object({
|
|
135
|
+
version: z.literal(1),
|
|
136
|
+
workspaces: z.record(z.string().min(1).max(200), workspacePrefsEntrySchema)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* set 的请求 schema 故意放宽到"只验形状":端点自己会用 normalize 把非法 tag /
|
|
141
|
+
* 颜色 / 布尔值降级成默认值,所以旧客户端或手改的文档不会在网关层被整份拒绝,
|
|
142
|
+
* 从而丢掉本来还能用的那些工作区。
|
|
143
|
+
*/
|
|
144
|
+
const setWorkspacePrefsRequestSchema = z.object({
|
|
145
|
+
version: z.number().int().optional(),
|
|
146
|
+
workspaces: z.record(z.string(), z.unknown())
|
|
147
|
+
})
|
|
148
|
+
|
|
119
149
|
const setIconResultSchema = statusSchema
|
|
120
150
|
|
|
121
151
|
|
|
@@ -250,13 +280,31 @@ const services = [
|
|
|
250
280
|
summary: "Set the running-mark switch.",
|
|
251
281
|
jsDoc: "/** Set the running-mark switch. */",
|
|
252
282
|
tags: []
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
kind: 'method',
|
|
286
|
+
name: 'getWorkspacePrefs',
|
|
287
|
+
signature: 'async getWorkspacePrefs(): Promise<{ version: 1; workspaces: Record<string, { tag: string; color: string; pinned: boolean; hidden: boolean; pinAnchor: string | null }> }>',
|
|
288
|
+
description: "Read the per-workspace preferences (tag, color, pinned, hidden).",
|
|
289
|
+
summary: "Read workspace preferences.",
|
|
290
|
+
jsDoc: "/** Read workspace preferences. */",
|
|
291
|
+
tags: []
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
kind: 'method',
|
|
295
|
+
name: 'setWorkspacePrefs',
|
|
296
|
+
signature: 'async setWorkspacePrefs(request: { version?: number; workspaces: Record<string, unknown> }): Promise<{ version: 1; workspaces: Record<string, { tag: string; color: string; pinned: boolean; hidden: boolean; pinAnchor: string | null }> }>',
|
|
297
|
+
description: "Replace the whole workspace-preferences document (dirty fields degrade to defaults).",
|
|
298
|
+
summary: "Set workspace preferences.",
|
|
299
|
+
jsDoc: "/** Set workspace preferences. */",
|
|
300
|
+
tags: []
|
|
253
301
|
}
|
|
254
302
|
],
|
|
255
303
|
types: []
|
|
256
304
|
}
|
|
257
305
|
]
|
|
258
306
|
|
|
259
|
-
/** 合并后的宿主端点:A 的
|
|
307
|
+
/** 合并后的宿主端点:A 的 12 条 iconCustom/* + B 的 2 条 mailNotify/*。 */
|
|
260
308
|
const invocations = [
|
|
261
309
|
{
|
|
262
310
|
id: 'dsh-lookout#iconCustom/getStatus',
|
|
@@ -439,6 +487,45 @@ const invocations = [
|
|
|
439
487
|
},
|
|
440
488
|
sourceLocation: { file: 'lib/index.js', line: 1, column: 1 }
|
|
441
489
|
},
|
|
490
|
+
{
|
|
491
|
+
id: 'dsh-lookout#iconCustom/getWorkspacePrefs',
|
|
492
|
+
service: 'iconCustom',
|
|
493
|
+
namespace: 'iconCustom',
|
|
494
|
+
method: 'getWorkspacePrefs',
|
|
495
|
+
invocation: { kind: 'direct' },
|
|
496
|
+
parameters: [],
|
|
497
|
+
result: {
|
|
498
|
+
mode: 'strict',
|
|
499
|
+
typeSymbol: 'dsh-lookout#iconCustom/getWorkspacePrefs:result',
|
|
500
|
+
create: () => workspacePrefsSchema
|
|
501
|
+
},
|
|
502
|
+
sourceLocation: { file: 'lib/index.js', line: 1, column: 1 }
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
id: 'dsh-lookout#iconCustom/setWorkspacePrefs',
|
|
506
|
+
service: 'iconCustom',
|
|
507
|
+
namespace: 'iconCustom',
|
|
508
|
+
method: 'setWorkspacePrefs',
|
|
509
|
+
invocation: { kind: 'direct' },
|
|
510
|
+
parameters: [
|
|
511
|
+
{
|
|
512
|
+
name: 'request',
|
|
513
|
+
wire: 'request',
|
|
514
|
+
source: 'json',
|
|
515
|
+
codec: {
|
|
516
|
+
mode: 'strict',
|
|
517
|
+
typeSymbol: 'dsh-lookout#iconCustom/setWorkspacePrefs:request',
|
|
518
|
+
create: () => setWorkspacePrefsRequestSchema
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
],
|
|
522
|
+
result: {
|
|
523
|
+
mode: 'strict',
|
|
524
|
+
typeSymbol: 'dsh-lookout#iconCustom/setWorkspacePrefs:result',
|
|
525
|
+
create: () => workspacePrefsSchema
|
|
526
|
+
},
|
|
527
|
+
sourceLocation: { file: 'lib/index.js', line: 1, column: 1 }
|
|
528
|
+
},
|
|
442
529
|
{
|
|
443
530
|
id: 'dsh-lookout#iconCustom/setUnreadSeen',
|
|
444
531
|
service: 'iconCustom',
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cowwo/dsh-lookout",
|
|
3
3
|
"description": "瞭望哨:盯着你的每个会话——谁跑完了、谁出错了、谁卡住了、谁在等你审批,用浏览器图标角标、红点/黄点和邮件通知你;顺带显示当前会话 ID。· Lookout: watches every session — who finished, who errored, who is stuck, and who is waiting for your approval — and notifies you through the browser favicon badge, red/yellow dots, and email, while also showing the current session ID.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|