@goodandready/dsh-plugin-notify 0.3.7 → 0.4.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/CHANGELOG.md +21 -5
- package/README.md +58 -4
- package/README.ru.md +48 -4
- package/README.zh.md +48 -4
- package/lib/client.js +240 -7
- package/lib/index.js +490 -24
- package/package.json +10 -4
package/lib/client.js
CHANGED
|
@@ -13,7 +13,7 @@ window.__ModuleLoader__.load({
|
|
|
13
13
|
|
|
14
14
|
const en = {
|
|
15
15
|
title: 'Notify',
|
|
16
|
-
subtitle: 'IM webhook, sound, toast, and
|
|
16
|
+
subtitle: 'IM webhook, sound, toast, desktop, and web push notifications',
|
|
17
17
|
intro: 'Store each webhook URL under Settings → Credentials, then type only the credential name below. Do not paste URLs into this form.',
|
|
18
18
|
'field.feishu': 'Feishu credential',
|
|
19
19
|
'field.wecom': 'WeCom credential',
|
|
@@ -51,10 +51,21 @@ window.__ModuleLoader__.load({
|
|
|
51
51
|
'settings.saving': 'Saving…',
|
|
52
52
|
'settings.saved': 'Saved',
|
|
53
53
|
'settings.saveFailed': 'Save failed: ',
|
|
54
|
+
'webpush.section': 'Web push (reaches devices with every dsh tab closed)',
|
|
55
|
+
'field.enableWebPush': 'Enable web push channel',
|
|
56
|
+
'field.webPushOnlyWhenAway': 'Only push when no browser is connected',
|
|
57
|
+
'btn.webpushEnable': 'Enable push on this device',
|
|
58
|
+
'btn.testPush': 'Test push',
|
|
59
|
+
'webpush.state.unsupported': 'Web push is not available in this context — open dsh in a browser (HTTPS or localhost) to use it',
|
|
60
|
+
'webpush.state.denied': 'Notification permission denied by browser settings',
|
|
61
|
+
'webpush.state.disabled': 'Web push channel is disabled — enable it above and save first',
|
|
62
|
+
'webpush.state.notSubscribed': 'This device is not subscribed yet',
|
|
63
|
+
'webpush.state.subscribed': 'This device is subscribed — push will reach it',
|
|
64
|
+
'webpush.state.error': 'Push setup failed: ',
|
|
54
65
|
}
|
|
55
66
|
const zh = {
|
|
56
67
|
title: '通知',
|
|
57
|
-
subtitle: 'IM Webhook
|
|
68
|
+
subtitle: 'IM Webhook、音频、浮动弹窗、桌面系统通知与网页推送',
|
|
58
69
|
intro: '请先在设置 → 凭据中保存 Webhook URL,然后只在下方填写凭据名称。不要直接粘贴 URL。',
|
|
59
70
|
'field.feishu': '飞书凭据',
|
|
60
71
|
'field.wecom': '企业微信凭据',
|
|
@@ -92,6 +103,17 @@ window.__ModuleLoader__.load({
|
|
|
92
103
|
'settings.saving': '正在保存…',
|
|
93
104
|
'settings.saved': '已保存',
|
|
94
105
|
'settings.saveFailed': '保存失败:',
|
|
106
|
+
'webpush.section': '网页推送(所有 dsh 标签页关闭时也能收到)',
|
|
107
|
+
'field.enableWebPush': '启用网页推送通道',
|
|
108
|
+
'field.webPushOnlyWhenAway': '仅在无浏览器连接时推送',
|
|
109
|
+
'btn.webpushEnable': '在本设备上启用推送',
|
|
110
|
+
'btn.testPush': '测试推送',
|
|
111
|
+
'webpush.state.unsupported': '当前上下文不支持网页推送——请在浏览器中打开 dsh(HTTPS 或 localhost)使用该通道',
|
|
112
|
+
'webpush.state.denied': '通知权限已被浏览器设置拒绝',
|
|
113
|
+
'webpush.state.disabled': '网页推送通道未启用——请先在上方启用并保存',
|
|
114
|
+
'webpush.state.notSubscribed': '此设备尚未订阅推送',
|
|
115
|
+
'webpush.state.subscribed': '此设备已订阅——可以接收推送',
|
|
116
|
+
'webpush.state.error': '推送设置失败:',
|
|
95
117
|
}
|
|
96
118
|
|
|
97
119
|
let ChevronIcon = null
|
|
@@ -250,6 +272,117 @@ window.__ModuleLoader__.load({
|
|
|
250
272
|
}
|
|
251
273
|
}
|
|
252
274
|
|
|
275
|
+
function urlBase64ToUint8Array(base64String) {
|
|
276
|
+
try {
|
|
277
|
+
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
|
278
|
+
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
|
279
|
+
const raw = window.atob(base64)
|
|
280
|
+
const output = new Uint8Array(raw.length)
|
|
281
|
+
for (let i = 0; i < raw.length; i++) output[i] = raw.charCodeAt(i)
|
|
282
|
+
return output
|
|
283
|
+
} catch (_err) {
|
|
284
|
+
return new Uint8Array(0)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Document-relative on purpose: the web document declares <base href="./">,
|
|
289
|
+
// so plugin routes resolve correctly behind a --public-url mount prefix.
|
|
290
|
+
function pluginUrl(relPath) {
|
|
291
|
+
try {
|
|
292
|
+
return new URL(relPath, document.baseURI).href
|
|
293
|
+
} catch (_err) {
|
|
294
|
+
return relPath
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function webPushSupported() {
|
|
299
|
+
if (typeof window === 'undefined' || typeof navigator === 'undefined') return false
|
|
300
|
+
if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) return false
|
|
301
|
+
if (window.isSecureContext === false) return false
|
|
302
|
+
// Service workers only register on http(s) origins; desktop shells load
|
|
303
|
+
// the UI from custom schemes (e.g. dsh-app://) where they are unavailable.
|
|
304
|
+
const proto = window.location ? window.location.protocol : ''
|
|
305
|
+
if (proto !== 'https:' && proto !== 'http:') return false
|
|
306
|
+
return true
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function registerNotifyServiceWorker() {
|
|
310
|
+
if (!webPushSupported()) return Promise.reject(new Error('unsupported'))
|
|
311
|
+
return navigator.serviceWorker.register(pluginUrl('dsh-plugin-notify/sw.js'), { scope: './' })
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Permission → service worker → applicationServerKey → subscribe → upload.
|
|
315
|
+
async function enableWebPushOnDevice() {
|
|
316
|
+
if (!webPushSupported()) throw new Error('unsupported')
|
|
317
|
+
const perm = await Notification.requestPermission()
|
|
318
|
+
if (perm !== 'granted') throw new Error('denied')
|
|
319
|
+
const reg = await registerNotifyServiceWorker()
|
|
320
|
+
const keyRes = await fetch(pluginUrl('dsh-plugin-notify/push/key'))
|
|
321
|
+
if (!keyRes.ok) throw new Error('disabled')
|
|
322
|
+
const keyInfo = await keyRes.json().catch(() => null)
|
|
323
|
+
if (!keyInfo || !keyInfo.publicKey) throw new Error('disabled')
|
|
324
|
+
// A stale subscription bound to a previous key blocks re-subscription.
|
|
325
|
+
const existing = await reg.pushManager.getSubscription()
|
|
326
|
+
if (existing) await existing.unsubscribe()
|
|
327
|
+
const sub = await reg.pushManager.subscribe({
|
|
328
|
+
userVisibleOnly: true,
|
|
329
|
+
applicationServerKey: urlBase64ToUint8Array(keyInfo.publicKey),
|
|
330
|
+
})
|
|
331
|
+
const saveRes = await fetch(pluginUrl('dsh-plugin-notify/push/subscribe'), {
|
|
332
|
+
method: 'POST',
|
|
333
|
+
headers: { 'content-type': 'application/json' },
|
|
334
|
+
body: JSON.stringify(sub.toJSON ? sub.toJSON() : sub),
|
|
335
|
+
})
|
|
336
|
+
if (!saveRes.ok) throw new Error('subscribe failed (' + saveRes.status + ')')
|
|
337
|
+
return sub
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function refreshWebPushStatus() {
|
|
341
|
+
if (!webPushSupported()) return 'unsupported'
|
|
342
|
+
if (Notification.permission === 'denied') return 'denied'
|
|
343
|
+
try {
|
|
344
|
+
const keyRes = await fetch(pluginUrl('dsh-plugin-notify/push/key'))
|
|
345
|
+
if (!keyRes.ok) return 'disabled'
|
|
346
|
+
const reg = await navigator.serviceWorker.getRegistration()
|
|
347
|
+
if (!reg) return 'notSubscribed'
|
|
348
|
+
const sub = await reg.pushManager.getSubscription()
|
|
349
|
+
return sub ? 'subscribed' : 'notSubscribed'
|
|
350
|
+
} catch (_err) {
|
|
351
|
+
return 'notSubscribed'
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// A notification click that found no live window opened a fresh one with
|
|
356
|
+
// ?dshNotifySession=<id>: navigate once the sessions service shows up.
|
|
357
|
+
function consumeDeepLink(ctx) {
|
|
358
|
+
if (typeof window === 'undefined') return
|
|
359
|
+
let sessionId = ''
|
|
360
|
+
try {
|
|
361
|
+
const params = new URLSearchParams(window.location.search)
|
|
362
|
+
sessionId = params.get('dshNotifySession') || ''
|
|
363
|
+
if (!sessionId) return
|
|
364
|
+
params.delete('dshNotifySession')
|
|
365
|
+
const qs = params.toString()
|
|
366
|
+
window.history.replaceState({}, '', window.location.pathname + (qs ? '?' + qs : '') + window.location.hash)
|
|
367
|
+
} catch (_err) {
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
let tries = 0
|
|
371
|
+
const timer = setInterval(() => {
|
|
372
|
+
tries++
|
|
373
|
+
try {
|
|
374
|
+
if (ctx && ctx.sessions && typeof ctx.sessions.activate === 'function') {
|
|
375
|
+
ctx.sessions.activate(sessionId)
|
|
376
|
+
clearInterval(timer)
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
} catch (_err) {
|
|
380
|
+
// retry until the budget is exhausted
|
|
381
|
+
}
|
|
382
|
+
if (tries > 40) clearInterval(timer)
|
|
383
|
+
}, 250)
|
|
384
|
+
}
|
|
385
|
+
|
|
253
386
|
function getToastContainer() {
|
|
254
387
|
if (typeof document === 'undefined') return null
|
|
255
388
|
let container = document.getElementById('dsh-notify-toast-container')
|
|
@@ -487,6 +620,49 @@ window.__ModuleLoader__.load({
|
|
|
487
620
|
}
|
|
488
621
|
return 'unsupported'
|
|
489
622
|
})
|
|
623
|
+
const [webPushState, setWebPushState] = React.useState('')
|
|
624
|
+
const [webPushErr, setWebPushErr] = React.useState('')
|
|
625
|
+
|
|
626
|
+
React.useEffect(() => {
|
|
627
|
+
refreshWebPushStatus().then(setWebPushState).catch(() => {})
|
|
628
|
+
}, [])
|
|
629
|
+
|
|
630
|
+
const onEnableWebPush = async () => {
|
|
631
|
+
setWebPushErr('')
|
|
632
|
+
try {
|
|
633
|
+
await enableWebPushOnDevice()
|
|
634
|
+
setWebPushState('subscribed')
|
|
635
|
+
if (draft) setDraft({ ...draft, enableWebPush: true })
|
|
636
|
+
} catch (e) {
|
|
637
|
+
const msg = String((e && e.message) || e)
|
|
638
|
+
if (msg === 'denied' || msg === 'unsupported' || /not supported/i.test(msg)) {
|
|
639
|
+
setWebPushState('unsupported')
|
|
640
|
+
} else {
|
|
641
|
+
setWebPushState('error')
|
|
642
|
+
setWebPushErr(msg)
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const onTestPush = async () => {
|
|
648
|
+
setWebPushErr('')
|
|
649
|
+
try {
|
|
650
|
+
const res = await fetch(pluginUrl('dsh-plugin-notify/push/test'), { method: 'POST' })
|
|
651
|
+
if (!res.ok) {
|
|
652
|
+
setWebPushState(res.status === 404 ? 'disabled' : 'error')
|
|
653
|
+
if (res.status !== 404) setWebPushErr('HTTP ' + res.status)
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
setWebPushState('subscribed')
|
|
657
|
+
} catch (e) {
|
|
658
|
+
setWebPushState('error')
|
|
659
|
+
setWebPushErr(String((e && e.message) || e))
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const webPushStatusText = webPushState === 'error'
|
|
664
|
+
? t('webpush.state.error') + webPushErr
|
|
665
|
+
: (webPushState ? t('webpush.state.' + webPushState) : '')
|
|
490
666
|
|
|
491
667
|
const requestPermission = async () => {
|
|
492
668
|
if (typeof window !== 'undefined' && 'Notification' in window) {
|
|
@@ -499,15 +675,23 @@ window.__ModuleLoader__.load({
|
|
|
499
675
|
}
|
|
500
676
|
}
|
|
501
677
|
|
|
502
|
-
|
|
503
|
-
|
|
678
|
+
// dsh 0.1 keys the settings scope by package name; 0.2 keys it by the
|
|
679
|
+
// composition row id. get() always returns a controller, so hold BOTH and
|
|
680
|
+
// use whichever reports ready (the other stays permanently unavailable).
|
|
681
|
+
const [scopeNS, scopeRow] = React.useMemo(
|
|
682
|
+
() => (ctx && ctx.configForms ? [ctx.configForms.get(NS), ctx.configForms.get(ROW_ID)] : [undefined, undefined]),
|
|
504
683
|
[ctx],
|
|
505
684
|
)
|
|
506
|
-
const
|
|
685
|
+
const useSnap = (scope) => React.useSyncExternalStore(
|
|
507
686
|
React.useMemo(() => (cb) => (scope ? scope.subscribe(cb) : () => {}), [scope]),
|
|
508
687
|
React.useCallback(() => (scope ? scope.getSnapshot() : { status: 'loading' }), [scope]),
|
|
509
688
|
React.useCallback(() => ({ status: 'loading' }), []),
|
|
510
689
|
)
|
|
690
|
+
const snapNS = useSnap(scopeNS)
|
|
691
|
+
const snapRow = useSnap(scopeRow)
|
|
692
|
+
const rowWins = scopeRow && snapNS.status !== 'ready' && snapRow.status === 'ready'
|
|
693
|
+
const scope = rowWins ? scopeRow : scopeNS
|
|
694
|
+
const snapshot = rowWins ? snapRow : snapNS
|
|
511
695
|
|
|
512
696
|
React.useEffect(() => { ensureStyles() }, [])
|
|
513
697
|
|
|
@@ -529,6 +713,8 @@ window.__ModuleLoader__.load({
|
|
|
529
713
|
enableSound: Boolean(stored.enableSound),
|
|
530
714
|
enableToasts: Boolean(stored.enableToasts),
|
|
531
715
|
enableDesktopNotifications: Boolean(stored.enableDesktopNotifications),
|
|
716
|
+
enableWebPush: Boolean(stored.webPush && stored.webPush.enabled),
|
|
717
|
+
webPushOnlyWhenAway: !(stored.webPush && stored.webPush.onlyWhenAway === false),
|
|
532
718
|
notifyBackgroundOnly: !!stored.notifyBackgroundOnly,
|
|
533
719
|
timeoutMs: String(stored.timeoutMs != null ? stored.timeoutMs : 5000),
|
|
534
720
|
dndStart: (stored.dnd && stored.dnd.start) || '',
|
|
@@ -554,6 +740,7 @@ window.__ModuleLoader__.load({
|
|
|
554
740
|
enableSound: !!draft.enableSound,
|
|
555
741
|
enableToasts: !!draft.enableToasts,
|
|
556
742
|
enableDesktopNotifications: !!draft.enableDesktopNotifications,
|
|
743
|
+
webPush: { enabled: !!draft.enableWebPush, onlyWhenAway: draft.webPushOnlyWhenAway !== false },
|
|
557
744
|
notifyBackgroundOnly: !!draft.notifyBackgroundOnly,
|
|
558
745
|
timeoutMs: Number.isFinite(timeoutNum) && timeoutNum > 0 ? timeoutNum : 5000,
|
|
559
746
|
dnd: { start: String(draft.dndStart || '').trim(), end: String(draft.dndEnd || '').trim() },
|
|
@@ -585,6 +772,9 @@ window.__ModuleLoader__.load({
|
|
|
585
772
|
return React.createElement('span', { className: 'pn-sub' }, t('subtitle'))
|
|
586
773
|
}
|
|
587
774
|
const page = !!(props && props.view === 'page')
|
|
775
|
+
// On the plugin page (view === 'page') the head button is hidden, so the
|
|
776
|
+
// body must not depend on the accordion state — always show the form there.
|
|
777
|
+
const expanded = page || open
|
|
588
778
|
|
|
589
779
|
return React.createElement(page ? 'div' : 'li', { className: page ? 'pn-page' : 'pn-card' },
|
|
590
780
|
React.createElement('button', { type: 'button', className: 'pn-head', style: page ? { display: 'none' } : undefined, 'aria-expanded': page ? true : open, onClick: () => setOpen((v) => !v) },
|
|
@@ -594,7 +784,7 @@ window.__ModuleLoader__.load({
|
|
|
594
784
|
),
|
|
595
785
|
React.createElement(Chevron, { open }),
|
|
596
786
|
),
|
|
597
|
-
|
|
787
|
+
expanded ? React.createElement('div', { className: 'pn-body' },
|
|
598
788
|
status === 'loading'
|
|
599
789
|
? React.createElement('p', { className: 'pn-hint', style: { padding: '12px 0' } }, t('settings.loading'))
|
|
600
790
|
: status !== 'ready'
|
|
@@ -649,6 +839,22 @@ window.__ModuleLoader__.load({
|
|
|
649
839
|
? React.createElement('button', { type: 'button', className: 'pn-btn-action', onClick: requestPermission }, t('btn.reqPermission'))
|
|
650
840
|
: React.createElement('span', { className: 'pn-hint' }, permState === 'granted' ? t('perm.granted') : t('perm.denied')),
|
|
651
841
|
),
|
|
842
|
+
React.createElement('div', { className: 'pn-field' },
|
|
843
|
+
React.createElement('span', { className: 'pn-label' }, t('webpush.section')),
|
|
844
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
|
|
845
|
+
React.createElement('input', { type: 'checkbox', id: 'pn-webpush', checked: !!(draft && draft.enableWebPush), onChange: (e) => setDraft({ ...draft, enableWebPush: e.target.checked }) }),
|
|
846
|
+
React.createElement('label', { htmlFor: 'pn-webpush', className: 'pn-label' }, t('field.enableWebPush')),
|
|
847
|
+
),
|
|
848
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
|
|
849
|
+
React.createElement('input', { type: 'checkbox', id: 'pn-wponlyaway', checked: (draft && draft.webPushOnlyWhenAway) !== false, onChange: (e) => setDraft({ ...draft, webPushOnlyWhenAway: e.target.checked }) }),
|
|
850
|
+
React.createElement('label', { htmlFor: 'pn-wponlyaway', className: 'pn-label' }, t('field.webPushOnlyWhenAway')),
|
|
851
|
+
),
|
|
852
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
|
|
853
|
+
React.createElement('button', { type: 'button', className: 'pn-btn-action', onClick: onEnableWebPush }, t('btn.webpushEnable')),
|
|
854
|
+
React.createElement('button', { type: 'button', className: 'pn-btn-action', onClick: onTestPush }, t('btn.testPush')),
|
|
855
|
+
webPushStatusText ? React.createElement('span', { className: 'pn-hint' }, webPushStatusText) : null,
|
|
856
|
+
),
|
|
857
|
+
),
|
|
652
858
|
React.createElement('div', { className: 'pn-field', style: { flexDirection: 'row', alignItems: 'center', gap: 8 } },
|
|
653
859
|
React.createElement('input', { type: 'checkbox', id: 'pn-bgonly', checked: !!(draft && draft.notifyBackgroundOnly), onChange: (e) => setDraft({ ...draft, notifyBackgroundOnly: e.target.checked }) }),
|
|
654
860
|
React.createElement('label', { htmlFor: 'pn-bgonly', className: 'pn-label' }, t('field.notifyBackgroundOnly')),
|
|
@@ -710,7 +916,9 @@ window.__ModuleLoader__.load({
|
|
|
710
916
|
let currentSettings = {}
|
|
711
917
|
if (ctx.configForms) {
|
|
712
918
|
try {
|
|
713
|
-
const
|
|
919
|
+
const candidates = [ctx.configForms.get(NS), ctx.configForms.get(ROW_ID)]
|
|
920
|
+
const ready = candidates.find((s) => s && s.getSnapshot && s.getSnapshot().status === 'ready')
|
|
921
|
+
const scope = ready || candidates[0]
|
|
714
922
|
if (scope && typeof scope.subscribe === 'function') {
|
|
715
923
|
scope.subscribe(() => {
|
|
716
924
|
const snap = scope.getSnapshot ? scope.getSnapshot() : null
|
|
@@ -735,6 +943,31 @@ window.__ModuleLoader__.load({
|
|
|
735
943
|
} else {
|
|
736
944
|
startSseListener(ctx, t, () => currentSettings)
|
|
737
945
|
}
|
|
946
|
+
|
|
947
|
+
// Web push: a notification click that found this live window jumps it here.
|
|
948
|
+
try {
|
|
949
|
+
if (typeof navigator !== 'undefined' && navigator.serviceWorker && typeof navigator.serviceWorker.addEventListener === 'function') {
|
|
950
|
+
ctx.effect(() => {
|
|
951
|
+
const onMessage = (e) => {
|
|
952
|
+
const d = e && e.data
|
|
953
|
+
if (d && d.type === 'dsh-notify-jump' && d.sessionId) navigateToSession(ctx, d.sessionId)
|
|
954
|
+
}
|
|
955
|
+
navigator.serviceWorker.addEventListener('message', onMessage)
|
|
956
|
+
return () => {
|
|
957
|
+
try { navigator.serviceWorker.removeEventListener('message', onMessage) } catch (_err) { /* ignore */ }
|
|
958
|
+
}
|
|
959
|
+
}, 'dsh-plugin-notify: sw messages')
|
|
960
|
+
}
|
|
961
|
+
} catch (_err) {
|
|
962
|
+
// best effort: service worker messaging unavailable
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// A push notification that opened a fresh window lands here: ?dshNotifySession=<id>.
|
|
966
|
+
try {
|
|
967
|
+
consumeDeepLink(ctx)
|
|
968
|
+
} catch (_err) {
|
|
969
|
+
// best effort: deep-link consumption is optional
|
|
970
|
+
}
|
|
738
971
|
}
|
|
739
972
|
|
|
740
973
|
module.exports = { apply, inject: ['slots', 'locale', 'configForms'] }
|