@goodandready/dsh-plugin-notify 0.3.8 → 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/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 desktop notifications',
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
- const scope = React.useMemo(
503
- () => (ctx && ctx.configForms ? ctx.configForms.get(NS) : undefined),
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 snapshot = React.useSyncExternalStore(
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
- open ? React.createElement('div', { className: 'pn-body' },
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 scope = ctx.configForms.get(NS)
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'] }