dsh-workspace-plus 0.2.0 → 0.6.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.
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Notice (desktop notifications)",
4
+ "description": "Windows native notifications for running conversations: permission requests, questions, finished, interrupted and failed turns, each marked with its kind. Click a notification to open that conversation."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "通知(桌面通知)",
4
+ "description": "为正在运行的对话发送 Windows 原生通知:请求权限、询问用户、任务完成、任务中断、任务出错,每种都会标明类型;点击通知直接打开对应的对话。"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "dsh-workspace-plus-notice",
3
+ "description": "Windows native notifications for running conversations: permission requests, questions, finished, interrupted and failed turns, each marked with its kind. Click a notification to open that conversation. Merged from the dsh-notice plugin as a sub-feature.",
4
+ "icon": "./icon.svg",
5
+ "type": "module"
6
+ }
@@ -0,0 +1,336 @@
1
+ /**
2
+ * dsh-workspace-plus/notice — settings (ported from dsh-notice).
3
+ *
4
+ * Two layers decide how the plugin behaves, and they are deliberately kept
5
+ * apart:
6
+ *
7
+ * - the **bundle config**, written by the user in `cordis.patch.yml` under the
8
+ * plugin row's `config:` key. It is a deployment default: it survives
9
+ * upgrades, and it is where a whole profile turns a notification class off.
10
+ * - the **override store**, this plugin's own JSON file under
11
+ * `$DSH_HOME/notice/settings.json`. It holds only the keys the user changed
12
+ * from the Harness Settings page, so "restore default" is a key deletion
13
+ * rather than a guess at what the default was.
14
+ *
15
+ * Effective settings are `normalize(config)` with the overrides merged on top.
16
+ * Every value is coerced and clamped here, because the config object arrives
17
+ * from YAML where `"false"` and `false` are easy to confuse, and a wrong type
18
+ * must never reach the toast path.
19
+ *
20
+ * @module dsh-workspace-plus/notice/settings
21
+ */
22
+
23
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
24
+ import { homedir } from 'node:os'
25
+ import { dirname, join, resolve } from 'node:path'
26
+
27
+ /** On-disk store format version. */
28
+ export const STORE_VERSION = 1
29
+
30
+ /** Notification classes the plugin can raise, in Settings-page order. */
31
+ export const KINDS = ['approval', 'question', 'done', 'aborted', 'error']
32
+
33
+ /** Human labels for each class, used by the settings route and the docs. */
34
+ export const KIND_LABELS = {
35
+ approval: '请求权限',
36
+ question: '询问用户',
37
+ done: '任务完成',
38
+ aborted: '任务中断',
39
+ error: '任务出错',
40
+ }
41
+
42
+ /** Which sessions produce notifications by default. */
43
+ export const SCOPES = ['roots', 'all']
44
+
45
+ /** Toast display durations Windows accepts. */
46
+ export const DURATIONS = ['short', 'long']
47
+
48
+ /**
49
+ * Protocol scheme grammar. A scheme with a stray colon or space would be written
50
+ * into the registry as an unusable key, so it is validated before any write.
51
+ */
52
+ const SCHEME_PATTERN = /^[a-z][a-z0-9+.-]{1,31}$/
53
+
54
+ /**
55
+ * Schemes this plugin must never claim.
56
+ *
57
+ * The scheme becomes `HKCU\Software\Classes\<scheme>\shell\open\command`, so
58
+ * registering one of these would hijack every link of that kind for the current
59
+ * user: `scheme: "http"` would route every web link on the machine into the
60
+ * plugin's launcher. The set covers the browser, the shell, the office and
61
+ * settings handlers, and the script protocols; prefixes cover the `ms-*` family
62
+ * and vendor-specific handlers.
63
+ */
64
+ export const RESERVED_SCHEMES = new Set([
65
+ 'about',
66
+ 'blob',
67
+ 'callto',
68
+ 'data',
69
+ 'dsh',
70
+ 'file',
71
+ 'ftp',
72
+ 'http',
73
+ 'https',
74
+ 'javascript',
75
+ 'mailto',
76
+ 'microsoft-edge',
77
+ 'search-ms',
78
+ 'shell',
79
+ 'sms',
80
+ 'tel',
81
+ 'vbscript',
82
+ 'view-source',
83
+ 'ws',
84
+ 'wss',
85
+ ])
86
+
87
+ /** Prefixes whose schemes belong to the operating system or another application. */
88
+ const RESERVED_PREFIXES = ['ms-', 'microsoft-', 'chrome', 'moz-', 'edge', 'vscode', 'vscode-insiders']
89
+
90
+ /**
91
+ * Whether a scheme may be registered by this plugin.
92
+ *
93
+ * @param scheme - normalized lowercase scheme.
94
+ * @returns true when the plugin may own it.
95
+ */
96
+ export function isClaimableScheme(scheme) {
97
+ if (!SCHEME_PATTERN.test(scheme)) return false
98
+ if (RESERVED_SCHEMES.has(scheme)) return false
99
+ return !RESERVED_PREFIXES.some((prefix) => scheme.startsWith(prefix))
100
+ }
101
+
102
+ /** Default settings. This object is the only source of truth for defaults. */
103
+ export const DEFAULT_SETTINGS = Object.freeze({
104
+ enabled: true,
105
+ kinds: Object.freeze({ approval: true, question: true, done: true, aborted: true, error: true }),
106
+ scope: 'roots',
107
+ sound: true,
108
+ duration: 'short',
109
+ appId: '',
110
+ scheme: 'dsh-notice',
111
+ registerProtocol: true,
112
+ focusOnClick: true,
113
+ minTurnMs: 0,
114
+ })
115
+
116
+ /**
117
+ * Locate the Harness home the same way the rest of the Harness does.
118
+ *
119
+ * @param env - environment snapshot (injectable for tests).
120
+ * @returns the absolute Harness home directory.
121
+ */
122
+ export function dshHome(env = process.env) {
123
+ const fromEnv = env.DSH_HOME
124
+ if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return resolve(fromEnv.trim())
125
+ return join(homedir(), '.dsh')
126
+ }
127
+
128
+ /**
129
+ * Absolute path of the override store.
130
+ *
131
+ * @param env - environment snapshot (injectable for tests).
132
+ * @returns the store file path.
133
+ */
134
+ export function storePath(env = process.env) {
135
+ return join(dshHome(env), 'notice', 'settings.json')
136
+ }
137
+
138
+ /** @returns a fresh default settings object (never a shared reference). */
139
+ export function defaultSettings() {
140
+ return { ...DEFAULT_SETTINGS, kinds: { ...DEFAULT_SETTINGS.kinds } }
141
+ }
142
+
143
+ /**
144
+ * Coerce one value to a boolean.
145
+ *
146
+ * YAML users write `true`, but a settings page or an older store may carry
147
+ * `"true"`, `1` or `"on"`; anything else keeps the fallback rather than
148
+ * silently flipping a switch.
149
+ *
150
+ * @param value - raw value.
151
+ * @param fallback - value used when nothing sensible is present.
152
+ * @returns the boolean.
153
+ */
154
+ export function coerceBoolean(value, fallback) {
155
+ if (typeof value === 'boolean') return value
156
+ if (typeof value === 'number') return value !== 0
157
+ if (typeof value === 'string') {
158
+ const text = value.trim().toLowerCase()
159
+ if (['true', '1', 'yes', 'on'].includes(text)) return true
160
+ if (['false', '0', 'no', 'off'].includes(text)) return false
161
+ }
162
+ return fallback
163
+ }
164
+
165
+ /**
166
+ * Coerce one value to a non-negative integer.
167
+ *
168
+ * @param value - raw value.
169
+ * @param fallback - value used when nothing sensible is present.
170
+ * @param max - inclusive upper bound.
171
+ * @returns the integer.
172
+ */
173
+ export function coerceCount(value, fallback, max = Number.MAX_SAFE_INTEGER) {
174
+ const number = typeof value === 'number' ? value : Number(String(value ?? '').trim())
175
+ if (!Number.isFinite(number) || number < 0) return fallback
176
+ return Math.min(Math.floor(number), max)
177
+ }
178
+
179
+ /**
180
+ * Coerce one value to a member of a closed set.
181
+ *
182
+ * @param value - raw value.
183
+ * @param allowed - accepted members.
184
+ * @param fallback - value used when the raw value is not a member.
185
+ * @returns the member.
186
+ */
187
+ export function coerceChoice(value, allowed, fallback) {
188
+ return typeof value === 'string' && allowed.includes(value) ? value : fallback
189
+ }
190
+
191
+ /**
192
+ * Normalize a whole settings object, filling every default and discarding
193
+ * anything malformed.
194
+ *
195
+ * @param input - raw settings (bundle config, store overrides, or both merged).
196
+ * @returns a complete, valid settings object.
197
+ */
198
+ export function normalizeSettings(input) {
199
+ const raw = input !== null && typeof input === 'object' ? input : {}
200
+ const rawKinds = raw.kinds !== null && typeof raw.kinds === 'object' ? raw.kinds : {}
201
+ const kinds = {}
202
+ for (const kind of KINDS) kinds[kind] = coerceBoolean(rawKinds[kind], DEFAULT_SETTINGS.kinds[kind])
203
+
204
+ const appId = typeof raw.appId === 'string' ? raw.appId.trim() : ''
205
+ const scheme = typeof raw.scheme === 'string' ? raw.scheme.trim().toLowerCase() : ''
206
+ return {
207
+ enabled: coerceBoolean(raw.enabled, DEFAULT_SETTINGS.enabled),
208
+ kinds,
209
+ scope: coerceChoice(raw.scope, SCOPES, DEFAULT_SETTINGS.scope),
210
+ sound: coerceBoolean(raw.sound, DEFAULT_SETTINGS.sound),
211
+ duration: coerceChoice(raw.duration, DURATIONS, DEFAULT_SETTINGS.duration),
212
+ appId,
213
+ scheme: isClaimableScheme(scheme) ? scheme : DEFAULT_SETTINGS.scheme,
214
+ registerProtocol: coerceBoolean(raw.registerProtocol, DEFAULT_SETTINGS.registerProtocol),
215
+ focusOnClick: coerceBoolean(raw.focusOnClick, DEFAULT_SETTINGS.focusOnClick),
216
+ minTurnMs: coerceCount(raw.minTurnMs, DEFAULT_SETTINGS.minTurnMs, 600_000),
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Merge one patch onto an override object, one level deep.
222
+ *
223
+ * `kinds` merges key by key (the settings page flips a single class at a time);
224
+ * every other key replaces. Unknown keys are dropped, so a store written by a
225
+ * newer plugin version cannot smuggle values into an older one.
226
+ *
227
+ * @param overrides - current overrides.
228
+ * @param patch - requested changes.
229
+ * @returns a new overrides object.
230
+ */
231
+ export function mergeOverrides(overrides, patch) {
232
+ const current = overrides !== null && typeof overrides === 'object' ? overrides : {}
233
+ const next = {}
234
+ for (const key of Object.keys(DEFAULT_SETTINGS)) {
235
+ if (key === 'kinds') continue
236
+ if (Object.prototype.hasOwnProperty.call(current, key)) next[key] = current[key]
237
+ if (patch !== null && typeof patch === 'object' && Object.prototype.hasOwnProperty.call(patch, key)) {
238
+ next[key] = patch[key]
239
+ }
240
+ }
241
+ const currentKinds = current.kinds !== null && typeof current.kinds === 'object' ? current.kinds : {}
242
+ const patchKinds = patch !== null && typeof patch === 'object' && patch.kinds !== null && typeof patch.kinds === 'object' ? patch.kinds : {}
243
+ const kinds = {}
244
+ for (const kind of KINDS) {
245
+ if (Object.prototype.hasOwnProperty.call(currentKinds, kind)) kinds[kind] = currentKinds[kind]
246
+ if (Object.prototype.hasOwnProperty.call(patchKinds, kind)) kinds[kind] = patchKinds[kind]
247
+ }
248
+ if (Object.keys(kinds).length > 0) next.kinds = kinds
249
+ return next
250
+ }
251
+
252
+ /**
253
+ * Drop one key from an override object.
254
+ *
255
+ * @param overrides - current overrides.
256
+ * @param key - settings key, or `kinds.<name>` for a single class.
257
+ * @returns a new overrides object.
258
+ */
259
+ export function clearOverride(overrides, key) {
260
+ const current = overrides !== null && typeof overrides === 'object' ? overrides : {}
261
+ const next = { ...current }
262
+ if (typeof key !== 'string') return next
263
+ if (key.startsWith('kinds.')) {
264
+ const kind = key.slice('kinds.'.length)
265
+ if (current.kinds !== null && typeof current.kinds === 'object') {
266
+ const kinds = { ...current.kinds }
267
+ delete kinds[kind]
268
+ if (Object.keys(kinds).length === 0) delete next.kinds
269
+ else next.kinds = kinds
270
+ }
271
+ return next
272
+ }
273
+ delete next[key]
274
+ return next
275
+ }
276
+
277
+ /** @returns an empty store. */
278
+ export function emptyStore() {
279
+ return { version: STORE_VERSION, overrides: {} }
280
+ }
281
+
282
+ /**
283
+ * Read the override store, tolerating a missing or damaged file.
284
+ *
285
+ * A corrupt store must never keep the plugin from loading: notifications are the
286
+ * feature, and losing a preference is a far smaller failure than losing the
287
+ * feature. The caller is told about the damage through the returned `warning`.
288
+ *
289
+ * @param file - store path.
290
+ * @returns the store plus an optional warning.
291
+ */
292
+ export function readStore(file) {
293
+ let text
294
+ try {
295
+ text = readFileSync(file, 'utf8')
296
+ } catch {
297
+ return { store: emptyStore(), warning: null }
298
+ }
299
+ let parsed
300
+ try {
301
+ parsed = JSON.parse(text)
302
+ } catch {
303
+ return { store: emptyStore(), warning: `${file} is not valid JSON; settings were reset to defaults` }
304
+ }
305
+ if (parsed === null || typeof parsed !== 'object') {
306
+ return { store: emptyStore(), warning: `${file} does not hold an object; settings were reset to defaults` }
307
+ }
308
+ return {
309
+ store: {
310
+ version: STORE_VERSION,
311
+ overrides: parsed.overrides !== null && typeof parsed.overrides === 'object' ? parsed.overrides : {},
312
+ },
313
+ warning: null,
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Write the override store atomically (temp file plus rename), so a crash mid
319
+ * write cannot leave a half-written file for the next boot to parse.
320
+ *
321
+ * @param file - store path.
322
+ * @param store - store to persist.
323
+ * @returns the outcome, never a throw.
324
+ */
325
+ export function writeStore(file, store) {
326
+ const body = `${JSON.stringify(store, null, 2)}\n`
327
+ try {
328
+ mkdirSync(dirname(file), { recursive: true })
329
+ const temporary = `${file}.tmp`
330
+ writeFileSync(temporary, body, 'utf8')
331
+ renameSync(temporary, file)
332
+ return { ok: true, message: `saved ${file}` }
333
+ } catch (error) {
334
+ return { ok: false, message: String((error && error.message) || error) }
335
+ }
336
+ }