@game25011/dsh-notify-toast 0.0.0-stage → 0.4.2

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/config.js ADDED
@@ -0,0 +1,420 @@
1
+ /**
2
+ * dsh-notify-toast — Host configuration: defaults, patch validation, read
3
+ * helpers, the volatile schemastery Config builder, and the pure completion
4
+ * merge scheduler.
5
+ *
6
+ * Contract: docs/INTERFACE.md §2.4. Everything here is side-effect free and
7
+ * unit-testable; the only platform-dependent step (resolving the profile copy
8
+ * of `@deepseek-ai/schemastery`) lives in lib/index.js.
9
+ */
10
+
11
+ /** package.json name / client module id / loader row id / settings namespace. */
12
+ export const PKG_ID = 'dsh-notify-toast'
13
+ /** Plugin version reported by every RPC endpoint. */
14
+ export const VERSION = '0.4.2'
15
+ /** Official Connection RPC channel (registered by the Host half). */
16
+ export const RPC_CHANNEL = '/dsh-notify-toast'
17
+ /** Settings namespace: the profile patch row id, which is also the package id. */
18
+ export const SETTINGS_NS = PKG_ID
19
+ /** Client locale namespace (client-owned; repeated here for completeness). */
20
+ export const LOCALE_NS = PKG_ID
21
+
22
+ /** Shipped defaults; also the fallback when neither settings nor Config exist. */
23
+ export const DEFAULTS = Object.freeze({
24
+ enabled: true,
25
+ questionEnabled: true,
26
+ approvalEnabled: true,
27
+ completionEnabled: true,
28
+ taskbarBadgeEnabled: true,
29
+ titleOnly: false,
30
+ bodyLength: 40,
31
+ sound: 'ding',
32
+ volume: 60,
33
+ mergeIntervalMs: 2500,
34
+ allDoneTitleEnabled: false,
35
+ allDoneTitle: '所有任务均已完成',
36
+ goalSummaryEnabled: true,
37
+ notificationMode: 'native-progress',
38
+ nativeDurationMs: 8000,
39
+ })
40
+
41
+ /**
42
+ * Whitelist of editable fields with the validation rules from INTERFACE §2.4.
43
+ * Only these keys may be read from a settings patch or written through RPC.
44
+ */
45
+ export const CONFIG_FIELDS = Object.freeze({
46
+ enabled: { type: 'boolean' },
47
+ questionEnabled: { type: 'boolean' },
48
+ approvalEnabled: { type: 'boolean' },
49
+ completionEnabled: { type: 'boolean' },
50
+ taskbarBadgeEnabled: { type: 'boolean' },
51
+ titleOnly: { type: 'boolean' },
52
+ bodyLength: { type: 'number', min: 20, max: 60, integer: true },
53
+ sound: { type: 'enum', values: Object.freeze(['ding', 'chime', 'bell', 'soft', 'bubble', 'none']) },
54
+ volume: { type: 'number', min: 0, max: 100, integer: true },
55
+ mergeIntervalMs: { type: 'number', min: 0, max: 10000, integer: true },
56
+ allDoneTitleEnabled: { type: 'boolean' },
57
+ allDoneTitle: { type: 'string', minBytes: 1, maxBytes: 96 },
58
+ goalSummaryEnabled: { type: 'boolean' },
59
+ notificationMode: { type: 'enum', values: Object.freeze(['native', 'native-progress', 'native-and-inapp']) },
60
+ nativeDurationMs: { type: 'number', min: 3000, max: 30000, integer: true },
61
+ })
62
+
63
+ /** Field order used when building the schemastery object. */
64
+ export const CONFIG_KEYS = Object.freeze(Object.keys(CONFIG_FIELDS))
65
+
66
+ /**
67
+ * Unwrap one schemastery `.volatile()` reference (`{ get(): value }`) when a
68
+ * Loader-validated Config object is read outside the form projection.
69
+ *
70
+ * @param {unknown} value candidate value.
71
+ * @returns {unknown} the snapshot value, or `undefined` for a broken reference.
72
+ */
73
+ export function unwrapVolatileValue(value) {
74
+ if (value !== null && typeof value === 'object' && typeof value.get === 'function') {
75
+ try {
76
+ return value.get()
77
+ } catch {
78
+ return undefined
79
+ }
80
+ }
81
+ return value
82
+ }
83
+
84
+ /**
85
+ * Read a Loader-validated config object (volatile refs) into plain values.
86
+ *
87
+ * @param {unknown} config `(ctx, config)` config object or a settings value.
88
+ * @returns {Record<string, unknown>} only known keys that were present.
89
+ */
90
+ export function unwrapConfigObject(config) {
91
+ const result = {}
92
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) return result
93
+ for (const key of CONFIG_KEYS) {
94
+ if (!Object.hasOwn(config, key)) continue
95
+ const value = unwrapVolatileValue(config[key])
96
+ if (value !== undefined) result[key] = value
97
+ }
98
+ return result
99
+ }
100
+
101
+ /**
102
+ * UTF-8 byte length without depending on TextEncoder/Buffer.
103
+ *
104
+ * @param {string} text candidate text.
105
+ * @returns {number} byte length.
106
+ */
107
+ function utf8ByteLength(text) {
108
+ let bytes = 0
109
+ for (const ch of text) {
110
+ const code = ch.codePointAt(0)
111
+ if (code <= 0x7f) bytes += 1
112
+ else if (code <= 0x7ff) bytes += 2
113
+ else if (code <= 0xffff) bytes += 3
114
+ else bytes += 4
115
+ }
116
+ return bytes
117
+ }
118
+
119
+ /**
120
+ * Validate one value against its field spec.
121
+ *
122
+ * @param {{type: string, min?: number, max?: number, integer?: boolean, values?: readonly string[]}} spec field spec.
123
+ * @param {unknown} value candidate.
124
+ * @returns {{ok: true} | {ok: false, message: string}} result.
125
+ */
126
+ export function checkConfigValue(spec, value) {
127
+ if (spec.type === 'boolean') {
128
+ if (typeof value !== 'boolean') return { ok: false, message: 'expected a boolean' }
129
+ return { ok: true }
130
+ }
131
+ if (spec.type === 'string') {
132
+ if (typeof value !== 'string') return { ok: false, message: 'expected a string' }
133
+ const trimmed = value.trim()
134
+ if (trimmed.length === 0) {
135
+ if (spec.allowEmpty === true) return { ok: true }
136
+ return { ok: false, message: 'must not be empty' }
137
+ }
138
+ if (typeof spec.minBytes === 'number' || typeof spec.maxBytes === 'number') {
139
+ const bytes = utf8ByteLength(trimmed)
140
+ if (typeof spec.minBytes === 'number' && bytes < spec.minBytes) return { ok: false, message: `must be at least ${spec.minBytes} bytes` }
141
+ if (typeof spec.maxBytes === 'number' && bytes > spec.maxBytes) return { ok: false, message: `must be at most ${spec.maxBytes} bytes` }
142
+ }
143
+ return { ok: true }
144
+ }
145
+ if (spec.type === 'enum') {
146
+ if (typeof value !== 'string' || !spec.values.includes(value)) {
147
+ return { ok: false, message: `expected one of ${spec.values.map((v) => `"${v}"`).join(', ')}` }
148
+ }
149
+ return { ok: true }
150
+ }
151
+ // number
152
+ if (typeof value !== 'number' || !Number.isFinite(value)) return { ok: false, message: 'expected a finite number' }
153
+ if (spec.integer && !Number.isInteger(value)) return { ok: false, message: 'expected an integer' }
154
+ if (typeof spec.min === 'number' && value < spec.min) return { ok: false, message: `must be >= ${spec.min}` }
155
+ if (typeof spec.max === 'number' && value > spec.max) return { ok: false, message: `must be <= ${spec.max}` }
156
+ return { ok: true }
157
+ }
158
+
159
+ /**
160
+ * Validate an RPC `save` patch: whitelist + type + range/enum checks only.
161
+ *
162
+ * Unknown keys are rejected (no silent drops) so a stale client cannot write a
163
+ * field this Host version does not understand.
164
+ *
165
+ * @param {unknown} patch candidate patch object.
166
+ * @returns {{ok: true, patch: object} | {ok: false, error: {code: string, message: string}}} result.
167
+ */
168
+ export function validatePatch(patch) {
169
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
170
+ return { ok: false, error: { code: 'invalid-patch', message: 'patch must be a plain object' } }
171
+ }
172
+ const clean = {}
173
+ for (const key of Object.keys(patch)) {
174
+ const spec = CONFIG_FIELDS[key]
175
+ if (!spec) {
176
+ return { ok: false, error: { code: 'unknown-field', message: `unknown config field "${key}"` } }
177
+ }
178
+ const value = patch[key]
179
+ const checked = checkConfigValue(spec, value)
180
+ if (!checked.ok) {
181
+ return { ok: false, error: { code: 'invalid-value', message: `config field "${key}" ${checked.message}` } }
182
+ }
183
+ clean[key] = value
184
+ }
185
+ return { ok: true, patch: clean }
186
+ }
187
+
188
+ /**
189
+ * Normalize any config-shaped value (defaults + only valid known fields).
190
+ *
191
+ * Invalid or unknown values are ignored rather than thrown: this is the read
192
+ * path, which must keep the plugin usable even when the settings document was
193
+ * edited by hand.
194
+ *
195
+ * @param {unknown} raw defaults base, Loader config object or settings value.
196
+ * @returns {typeof DEFAULTS} a fresh plain config object.
197
+ */
198
+ export function normalizeConfig(raw) {
199
+ const result = { ...DEFAULTS }
200
+ const plain = unwrapConfigObject(raw)
201
+ for (const key of CONFIG_KEYS) {
202
+ if (!Object.hasOwn(plain, key)) continue
203
+ const value = plain[key]
204
+ if (checkConfigValue(CONFIG_FIELDS[key], value).ok) result[key] = value
205
+ }
206
+ return result
207
+ }
208
+
209
+ /**
210
+ * Merge a validated patch over a base config.
211
+ *
212
+ * @param {unknown} base current config (normalized first).
213
+ * @param {unknown} patch patch object; invalid fields are ignored.
214
+ * @returns {typeof DEFAULTS} merged plain config.
215
+ */
216
+ export function mergeConfig(base, patch) {
217
+ const merged = normalizeConfig(base)
218
+ const plain = unwrapConfigObject(patch)
219
+ for (const key of CONFIG_KEYS) {
220
+ if (!Object.hasOwn(plain, key)) continue
221
+ const value = plain[key]
222
+ if (checkConfigValue(CONFIG_FIELDS[key], value).ok) merged[key] = value
223
+ }
224
+ return merged
225
+ }
226
+
227
+ /**
228
+ * Build the all-volatile schemastery Config object.
229
+ *
230
+ * Every field is `.volatile()`: the official settings layer only persists and
231
+ * hot-applies volatile fields (docs/INTERFACE.md), and the Loader then updates
232
+ * the stable config references in place without remounting the plugin.
233
+ *
234
+ * @param {{object: Function, boolean: Function, number: Function, union: Function}} Schema
235
+ * the schemastery default export (resolved from the profile in lib/index.js).
236
+ * @returns {unknown} a schemastery schema object.
237
+ * @throws {TypeError} when the supplied module does not look like schemastery.
238
+ */
239
+ export function buildConfigSchema(Schema) {
240
+ if (!Schema || typeof Schema.object !== 'function' || typeof Schema.boolean !== 'function') {
241
+ throw new TypeError('schemastery module is missing Schema.object/boolean')
242
+ }
243
+ return Schema.object({
244
+ enabled: Schema.boolean().default(DEFAULTS.enabled).volatile(),
245
+ questionEnabled: Schema.boolean().default(DEFAULTS.questionEnabled).volatile(),
246
+ approvalEnabled: Schema.boolean().default(DEFAULTS.approvalEnabled).volatile(),
247
+ completionEnabled: Schema.boolean().default(DEFAULTS.completionEnabled).volatile(),
248
+ taskbarBadgeEnabled: Schema.boolean().default(DEFAULTS.taskbarBadgeEnabled).volatile(),
249
+ titleOnly: Schema.boolean().default(DEFAULTS.titleOnly).volatile(),
250
+ bodyLength: Schema.number().min(20).max(60).step(1).default(DEFAULTS.bodyLength).volatile(),
251
+ sound: Schema.union(['ding', 'chime', 'bell', 'soft', 'bubble', 'none']).default(DEFAULTS.sound).volatile(),
252
+ volume: Schema.number().min(0).max(100).step(1).default(DEFAULTS.volume).volatile(),
253
+ mergeIntervalMs: Schema.number().min(0).max(10000).step(100).default(DEFAULTS.mergeIntervalMs).volatile(),
254
+ allDoneTitleEnabled: Schema.boolean().default(DEFAULTS.allDoneTitleEnabled).volatile(),
255
+ allDoneTitle: Schema.string().default(DEFAULTS.allDoneTitle).volatile(),
256
+ goalSummaryEnabled: Schema.boolean().default(DEFAULTS.goalSummaryEnabled).volatile(),
257
+ notificationMode: Schema.union(['native', 'native-progress', 'native-and-inapp']).default(DEFAULTS.notificationMode).volatile(),
258
+ nativeDurationMs: Schema.number().min(3000).max(30000).step(100).default(DEFAULTS.nativeDurationMs).volatile(),
259
+ })
260
+ }
261
+
262
+ /**
263
+ * Create the completion merge scheduler (INTERFACE §2.5 item 5).
264
+ *
265
+ * The scheduler batches completion events for `mergeIntervalMs`: the first
266
+ * event opens a batch and schedules one timer; later events inside the window
267
+ * update the batch (latest text/session, count++) instead of publishing. The
268
+ * timer is the only stateful dependency, so tests inject fake timer functions.
269
+ *
270
+ * Semantics:
271
+ * - a duplicate (sessionId+turn) is rejected before entering the batch;
272
+ * - `count` is published only when it is greater than 1 (default is 1);
273
+ * - a later completion without text keeps the last non-empty text;
274
+ * - `dispose()` drops a pending batch without publishing (the plugin is
275
+ * unloading, so the event queue is going away too).
276
+ *
277
+ * @param {object} [options]
278
+ * @param {(payload: object) => unknown} [options.publish] receives the merged
279
+ * `kind:'complete'` payload (without id/at; the queue adds those).
280
+ * @param {() => number} [options.now] clock (unused placeholders for callers).
281
+ * @param {(callback: () => void, ms: number) => unknown} [options.setTimer] timer factory.
282
+ * @param {(timer: unknown) => void} [options.clearTimer] timer canceller.
283
+ * @param {() => number} [options.getIntervalMs] reads the current merge window.
284
+ * @param {(sessionId: string|undefined, turn: number|undefined) => boolean} [options.isDuplicate]
285
+ * dedupe predicate consulted for every queued completion.
286
+ * @returns {{queue: Function, flushNow: Function, pending: Function, stats: Function, dispose: Function}}
287
+ */
288
+ export function createMergeScheduler(options = {}) {
289
+ const {
290
+ publish = () => {},
291
+ now = () => Date.now(),
292
+ setTimer = (callback, ms) => setTimeout(callback, ms),
293
+ clearTimer = (timer) => clearTimeout(timer),
294
+ getIntervalMs = () => DEFAULTS.mergeIntervalMs,
295
+ isDuplicate = () => false,
296
+ } = options
297
+
298
+ let batch = null
299
+ let batchSessionIds = new Set()
300
+ let timer = null
301
+ let disposed = false
302
+ let merged = 0
303
+
304
+ const clearPendingTimer = () => {
305
+ if (timer === null) return
306
+ try {
307
+ clearTimer(timer)
308
+ } catch {
309
+ /* timer already gone */
310
+ }
311
+ timer = null
312
+ }
313
+
314
+ const flush = () => {
315
+ if (batch === null) return null
316
+ const current = batch
317
+ const sessionIds = batchSessionIds
318
+ batch = null
319
+ batchSessionIds = new Set()
320
+ clearPendingTimer()
321
+ const payload = {
322
+ kind: 'complete',
323
+ text: current.text === null ? null : current.text,
324
+ }
325
+ // INTERFACE §2.5: `count` is only carried when a batch actually merged.
326
+ if (current.count > 1) payload.count = current.count
327
+ if (sessionIds.size > 1) payload.sessionIds = Array.from(sessionIds)
328
+ if (current.allDone === true) payload.allDone = true
329
+ if (current.sessionId !== undefined) payload.sessionId = current.sessionId
330
+ if (current.sessionTitle !== undefined) payload.sessionTitle = current.sessionTitle
331
+ try {
332
+ publish(payload)
333
+ } catch {
334
+ /* publishing is the caller's problem; never break the timer chain */
335
+ }
336
+ return payload
337
+ }
338
+
339
+ const schedule = () => {
340
+ let interval
341
+ try {
342
+ interval = Number(getIntervalMs())
343
+ } catch {
344
+ interval = DEFAULTS.mergeIntervalMs
345
+ }
346
+ if (!Number.isFinite(interval) || interval <= 0) {
347
+ flush()
348
+ return
349
+ }
350
+ try {
351
+ timer = setTimer(() => {
352
+ timer = null
353
+ flush()
354
+ }, interval)
355
+ } catch {
356
+ timer = null
357
+ flush()
358
+ }
359
+ }
360
+
361
+ return {
362
+ /**
363
+ * Queue one completed root turn.
364
+ * @param {{sessionId?: string, sessionTitle?: string, text?: string|null, turn?: number, allDone?: boolean}} entry
365
+ * @returns {boolean} whether the entry was accepted.
366
+ */
367
+ queue(entry) {
368
+ if (disposed) return false
369
+ const current = entry && typeof entry === 'object' ? entry : {}
370
+ try {
371
+ if (isDuplicate(current.sessionId, current.turn)) return false
372
+ } catch {
373
+ /* a broken predicate must not swallow the completion */
374
+ }
375
+ const text = typeof current.text === 'string' && current.text !== '' ? current.text : null
376
+ if (batch !== null) {
377
+ batch.count += 1
378
+ merged += 1
379
+ if (current.allDone === true) batch.allDone = true
380
+ if (current.sessionId !== undefined) {
381
+ batch.sessionId = current.sessionId
382
+ batchSessionIds.add(current.sessionId)
383
+ }
384
+ if (current.sessionTitle !== undefined) batch.sessionTitle = current.sessionTitle
385
+ if (text !== null) batch.text = text
386
+ return true
387
+ }
388
+ batchSessionIds = new Set(current.sessionId !== undefined ? [current.sessionId] : [])
389
+ batch = {
390
+ sessionId: current.sessionId,
391
+ sessionTitle: current.sessionTitle,
392
+ text,
393
+ count: 1,
394
+ openedAt: now(),
395
+ }
396
+ if (current.allDone === true) batch.allDone = true
397
+ schedule()
398
+ return true
399
+ },
400
+ /** Publish the pending batch immediately (used by tests and flush hooks). */
401
+ flushNow() {
402
+ return flush()
403
+ },
404
+ /** Snapshot of the pending batch, or null. */
405
+ pending() {
406
+ return batch === null ? null : { ...batch }
407
+ },
408
+ /** Cumulative merge counters for the settings page. */
409
+ stats() {
410
+ return { merged }
411
+ },
412
+ /** Drop the pending batch and timer; safe to call twice. */
413
+ dispose() {
414
+ disposed = true
415
+ clearPendingTimer()
416
+ batch = null
417
+ batchSessionIds = new Set()
418
+ },
419
+ }
420
+ }