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.
- package/README.md +572 -397
- package/cordis.patch.yml +45 -8
- package/lib/client.js +2676 -1653
- package/lib/index.js +790 -707
- package/lib/notice/click.ps1 +67 -0
- package/lib/notice/format.js +313 -0
- package/lib/notice/icon.svg +16 -0
- package/lib/notice/index.js +951 -0
- package/lib/notice/locale/en.json +6 -0
- package/lib/notice/locale/zh.json +6 -0
- package/lib/notice/package.json +6 -0
- package/lib/notice/settings.js +336 -0
- package/lib/notice/toast-host.ps1 +616 -0
- package/lib/notice/toast.js +517 -0
- package/lib/notice/win32.js +206 -0
- package/package.json +73 -65
|
@@ -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
|
+
"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
|
+
}
|