@things-factory/shell 10.1.247 → 10.1.258

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/_index.html CHANGED
@@ -5,7 +5,9 @@
5
5
  <meta name="google" content="notranslate" />
6
6
  <title>Things Factory App</title>
7
7
  <meta name="generator" content="Things Factory Starter Kit" />
8
- <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
8
+ <!-- interactive-widget: on a phone the on-screen keyboard shrinks the page, so the action bar and a popup's
9
+ last field stay above it instead of under it (Chrome on Android; Safari keeps its own behaviour). -->
10
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
9
11
  <meta name="description" content="Reimagining Software" />
10
12
 
11
13
  <base href="/" />
@@ -0,0 +1,61 @@
1
+ /*
2
+ * [ Caution ]
3
+ * Since this module is being used by a service worker, be very careful about adding imports or using functions for browsers.
4
+ */
5
+
6
+ export type PushWorker = {
7
+ registration: { showNotification(title: string, options?: any): Promise<void> }
8
+ channel: { postMessage(message: any): void }
9
+ store: { add(record: any): Promise<unknown>; limit(): Promise<unknown> }
10
+ /* the title of a push that brings none */
11
+ tagline?: string
12
+ }
13
+
14
+ /**
15
+ * Shows a web push, keeps it in the worker's store and tells the open screens. The work is handed to
16
+ * `event.waitUntil` before anything is awaited - otherwise the browser may stop the worker first, and a mobile
17
+ * browser drops the notice or revokes the subscription of a site that received a push and showed nothing.
18
+ */
19
+ export function handlePush(event: { data?: any; waitUntil(work: Promise<unknown>): void }, worker: PushWorker) {
20
+ if (!event.data) {
21
+ return
22
+ }
23
+
24
+ try {
25
+ var { notification, data } = event.data.json()
26
+ } catch (e) {
27
+ notification = {
28
+ body: event.data.text()
29
+ }
30
+ }
31
+
32
+ const title = notification.title || worker.tagline
33
+
34
+ const options = {
35
+ icon: '/assets/manifest/icon-128x128.png',
36
+ badge: '/assets/manifest/badge-128x128.png',
37
+ ...notification,
38
+ data
39
+ }
40
+
41
+ event.waitUntil(
42
+ (async () => {
43
+ await worker.store.add({
44
+ timestamp: Date.now(),
45
+ ...notification,
46
+ ...data
47
+ })
48
+
49
+ await worker.store.limit()
50
+
51
+ worker.channel.postMessage({
52
+ type: 'notification',
53
+ notification,
54
+ data
55
+ })
56
+
57
+ /* https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification */
58
+ await worker.registration.showNotification(title, options)
59
+ })()
60
+ )
61
+ }
@@ -6,6 +6,7 @@ import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching'
6
6
  import { StaleWhileRevalidate, NetworkFirst, CacheFirst, CacheOnly, NetworkOnly } from 'workbox-strategies'
7
7
  import 'broadcastchannel-polyfill'
8
8
  import { notificationStore } from './indexdb'
9
+ import { handlePush } from './push'
9
10
 
10
11
  const ENV = process.env
11
12
  const production = ENV['NODE-ENV'] == 'production'
@@ -114,45 +115,9 @@ if (production) {
114
115
  }
115
116
 
116
117
  /* for push notification */
117
- addEventListener('push', async event => {
118
- if (!event.data) {
119
- return
120
- }
121
-
122
- try {
123
- var { notification, data } = event.data.json()
124
- } catch (e) {
125
- notification = {
126
- body: event.data.text()
127
- }
128
- }
129
-
130
- const title = notification.title || ENV['APP-TAGLINE']
131
-
132
- const options = {
133
- icon: '/assets/manifest/icon-128x128.png',
134
- badge: '/assets/manifest/badge-128x128.png',
135
- ...notification,
136
- data
137
- }
138
-
139
- await notificationStore.add({
140
- timestamp: Date.now(),
141
- ...notification,
142
- ...data
143
- })
144
-
145
- await notificationStore.limit()
146
-
147
- channel.postMessage({
148
- type: 'notification',
149
- notification,
150
- data
151
- })
152
-
153
- /* https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification */
154
- registration.showNotification(title, options)
155
- })
118
+ addEventListener('push', (event: any) =>
119
+ handlePush(event, { registration, channel, store: notificationStore, tagline: ENV['APP-TAGLINE'] })
120
+ )
156
121
 
157
122
  addEventListener('notificationclick', async event => {
158
123
  const { action, notification } = event
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/shell",
3
- "version": "10.1.247",
3
+ "version": "10.1.258",
4
4
  "description": "Core module for framework",
5
5
  "bin": {
6
6
  "things-factory": "bin/things-factory",
@@ -142,5 +142,5 @@
142
142
  "pg": "^8.7.3",
143
143
  "sqlite3": "^5.0.8"
144
144
  },
145
- "gitHead": "3c672029b2ef4a09f3370d37e5d225392856a602"
145
+ "gitHead": "c7b7e3b4e9aa1b36a246ccee312a4124a50924dd"
146
146
  }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * A web push is shown inside `event.waitUntil`.
3
+ *
4
+ * ## Why this is tested
5
+ *
6
+ * Until 2026-11-05 the worker's push handler awaited its store and then called `showNotification` without handing
7
+ * any of it to `event.waitUntil`. The browser may stop a worker as soon as the handler returns, and a mobile browser
8
+ * drops such a notice or revokes the subscription of a site that received a push and showed nothing.
9
+ */
10
+ import { handlePush } from '../client/serviceworker/push'
11
+
12
+ function worker() {
13
+ const shown: Array<{ title: string; options: any }> = []
14
+ const posted: any[] = []
15
+ let release: () => void = () => {}
16
+ const showing = new Promise<void>(resolve => (release = resolve))
17
+
18
+ return {
19
+ shown,
20
+ posted,
21
+ release: () => release(),
22
+ worker: {
23
+ registration: {
24
+ showNotification: async (title: string, options: any) => {
25
+ shown.push({ title, options })
26
+ await showing
27
+ }
28
+ },
29
+ channel: { postMessage: (message: any) => void posted.push(message) },
30
+ store: { add: async () => undefined, limit: async () => undefined },
31
+ tagline: 'Operato Office'
32
+ }
33
+ }
34
+ }
35
+
36
+ function pushEvent(payload: any) {
37
+ const kept: Promise<unknown>[] = []
38
+ return {
39
+ kept,
40
+ event: {
41
+ data: { json: () => payload, text: () => JSON.stringify(payload) },
42
+ waitUntil: (work: Promise<unknown>) => void kept.push(work)
43
+ }
44
+ }
45
+ }
46
+
47
+ const PAYLOAD = {
48
+ notification: { title: '결재 차례', body: '지출결의 1건' },
49
+ data: { type: 'approval', url: 'https://office.example.com/domain/hatiolab/activity-approval/a-1', timestamp: '1' }
50
+ }
51
+
52
+ describe('a web push in the service worker', () => {
53
+ it('hands its work to waitUntil before the handler returns', () => {
54
+ const { worker: w } = worker()
55
+ const { event, kept } = pushEvent(PAYLOAD)
56
+
57
+ handlePush(event, w)
58
+
59
+ expect(kept).toHaveLength(1)
60
+ })
61
+
62
+ it('keeps the worker alive until the notice is shown, with the full address to open', async () => {
63
+ const { worker: w, shown, posted, release } = worker()
64
+ const { event, kept } = pushEvent(PAYLOAD)
65
+
66
+ handlePush(event, w)
67
+
68
+ let done = false
69
+ void kept[0].then(() => (done = true))
70
+ for (let i = 0; i < 5; i++) await Promise.resolve()
71
+
72
+ expect(shown).toEqual([{ title: '결재 차례', options: expect.objectContaining({ body: '지출결의 1건', data: PAYLOAD.data }) }])
73
+ expect(posted).toEqual([{ type: 'notification', notification: PAYLOAD.notification, data: PAYLOAD.data }])
74
+ expect(done).toBe(false)
75
+
76
+ release()
77
+ await kept[0]
78
+ expect(done).toBe(true)
79
+ })
80
+
81
+ it('takes the application tagline when the push brings no title', async () => {
82
+ const { worker: w, shown, release } = worker()
83
+ const { event, kept } = pushEvent({ notification: { body: 'b' }, data: {} })
84
+
85
+ handlePush(event, w)
86
+ release()
87
+ await kept[0]
88
+
89
+ expect(shown[0].title).toBe('Operato Office')
90
+ })
91
+ })
@@ -212,5 +212,11 @@
212
212
  "error.ip-protectedlist-entry-invalid": "'{entry}' in the protected list is not an IP address regular expression. For example: ^10\\.0\\.",
213
213
  "error.ip-whitelist-entry-empty": "The allow list has an empty entry. Leave nothing empty between commas.",
214
214
  "error.ip-blacklist-entry-empty": "The block list has an empty entry. Leave nothing empty between commas.",
215
- "error.ip-protectedlist-entry-empty": "The protected list has an empty entry. Leave nothing empty between commas."
215
+ "error.ip-protectedlist-entry-empty": "The protected list has an empty entry. Leave nothing empty between commas.",
216
+ "field.control-no": "control No.",
217
+ "field.note": "note",
218
+ "figure.label.data-bindings": "bindings",
219
+ "text.delete": "delete",
220
+ "text.copy": "copy",
221
+ "text.scenario": "scenario"
216
222
  }
@@ -206,5 +206,17 @@
206
206
  "text.tax-kind-zero-rated": "Tipo cero",
207
207
  "text.tax-kind-exempt": "Exento",
208
208
  "button.rename": "cambiar nombre",
209
- "button.search": "buscar"
209
+ "button.search": "buscar",
210
+ "error.ip-whitelist-entry-invalid": "'{entry}' en la lista de permitidos no es una expresión regular de dirección IP. Por ejemplo: ^10\\.0\\.",
211
+ "error.ip-blacklist-entry-invalid": "'{entry}' en la lista de bloqueo no es una expresión regular de dirección IP. Por ejemplo: ^10\\.0\\.",
212
+ "error.ip-protectedlist-entry-invalid": "'{entry}' en la lista protegida no es una expresión regular de dirección IP. Por ejemplo: ^10\\.0\\.",
213
+ "error.ip-whitelist-entry-empty": "La lista de permitidos tiene una entrada vacía. No deje nada vacío entre comas.",
214
+ "error.ip-blacklist-entry-empty": "La lista de bloqueo tiene una entrada vacía. No deje nada vacío entre comas.",
215
+ "error.ip-protectedlist-entry-empty": "La lista protegida tiene una entrada vacía. No deje nada vacío entre comas.",
216
+ "field.control-no": "n.º de control",
217
+ "field.note": "nota",
218
+ "figure.label.data-bindings": "vínculos",
219
+ "text.delete": "eliminar",
220
+ "text.copy": "copiar",
221
+ "text.scenario": "escenario"
210
222
  }
@@ -206,5 +206,17 @@
206
206
  "text.tax-kind-zero-rated": "ゼロ税率",
207
207
  "text.tax-kind-exempt": "免税",
208
208
  "button.rename": "名前変更",
209
- "button.search": "検索"
209
+ "button.search": "検索",
210
+ "error.ip-whitelist-entry-invalid": "許可リストの '{entry}' はIPアドレスの正規表現ではありません。例: ^10\\.0\\.",
211
+ "error.ip-blacklist-entry-invalid": "ブロックリストの '{entry}' はIPアドレスの正規表現ではありません。例: ^10\\.0\\.",
212
+ "error.ip-protectedlist-entry-invalid": "保護リストの '{entry}' はIPアドレスの正規表現ではありません。例: ^10\\.0\\.",
213
+ "error.ip-whitelist-entry-empty": "許可リストに空の項目があります。カンマの間を空けないでください。",
214
+ "error.ip-blacklist-entry-empty": "ブロックリストに空の項目があります。カンマの間を空けないでください。",
215
+ "error.ip-protectedlist-entry-empty": "保護リストに空の項目があります。カンマの間を空けないでください。",
216
+ "field.control-no": "管理番号",
217
+ "field.note": "メモ",
218
+ "figure.label.data-bindings": "連結",
219
+ "text.delete": "削除",
220
+ "text.copy": "コピー",
221
+ "text.scenario": "シナリオ"
210
222
  }
@@ -212,5 +212,11 @@
212
212
  "error.ip-protectedlist-entry-invalid": "보호 목록의 '{entry}'는 IP 주소 정규식이 아닙니다. 예: ^10\\.0\\.",
213
213
  "error.ip-whitelist-entry-empty": "허용 목록에 빈 항목이 있습니다. 쉼표 사이를 비우지 마세요.",
214
214
  "error.ip-blacklist-entry-empty": "차단 목록에 빈 항목이 있습니다. 쉼표 사이를 비우지 마세요.",
215
- "error.ip-protectedlist-entry-empty": "보호 목록에 빈 항목이 있습니다. 쉼표 사이를 비우지 마세요."
215
+ "error.ip-protectedlist-entry-empty": "보호 목록에 빈 항목이 있습니다. 쉼표 사이를 비우지 마세요.",
216
+ "field.control-no": "관리 번호",
217
+ "field.note": "메모",
218
+ "figure.label.data-bindings": "연결",
219
+ "text.delete": "삭제",
220
+ "text.copy": "복사",
221
+ "text.scenario": "시나리오"
216
222
  }
@@ -206,5 +206,17 @@
206
206
  "text.tax-kind-zero-rated": "Kadar sifar",
207
207
  "text.tax-kind-exempt": "Dikecualikan",
208
208
  "button.rename": "Namakan Semula",
209
- "button.search": "cari"
209
+ "button.search": "cari",
210
+ "error.ip-whitelist-entry-invalid": "'{entry}' dalam senarai benarkan bukan ungkapan nalar alamat IP. Contoh: ^10\\.0\\.",
211
+ "error.ip-blacklist-entry-invalid": "'{entry}' dalam senarai sekatan bukan ungkapan nalar alamat IP. Contoh: ^10\\.0\\.",
212
+ "error.ip-protectedlist-entry-invalid": "'{entry}' dalam senarai dilindungi bukan ungkapan nalar alamat IP. Contoh: ^10\\.0\\.",
213
+ "error.ip-whitelist-entry-empty": "Senarai benarkan mempunyai entri kosong. Jangan biarkan kosong di antara koma.",
214
+ "error.ip-blacklist-entry-empty": "Senarai sekatan mempunyai entri kosong. Jangan biarkan kosong di antara koma.",
215
+ "error.ip-protectedlist-entry-empty": "Senarai dilindungi mempunyai entri kosong. Jangan biarkan kosong di antara koma.",
216
+ "field.control-no": "nombor kawalan",
217
+ "field.note": "nota",
218
+ "figure.label.data-bindings": "ikatan",
219
+ "text.delete": "padam",
220
+ "text.copy": "salin",
221
+ "text.scenario": "senario"
210
222
  }
@@ -206,5 +206,17 @@
206
206
  "text.tax-kind-zero-rated": "零税率",
207
207
  "text.tax-kind-exempt": "免税",
208
208
  "button.rename": "名称变更",
209
- "button.search": "搜索"
209
+ "button.search": "搜索",
210
+ "error.ip-whitelist-entry-invalid": "允许列表中的 '{entry}' 不是 IP 地址正则表达式。例如:^10\\.0\\.",
211
+ "error.ip-blacklist-entry-invalid": "阻止列表中的 '{entry}' 不是 IP 地址正则表达式。例如:^10\\.0\\.",
212
+ "error.ip-protectedlist-entry-invalid": "保护列表中的 '{entry}' 不是 IP 地址正则表达式。例如:^10\\.0\\.",
213
+ "error.ip-whitelist-entry-empty": "允许列表中有空项。逗号之间不能留空。",
214
+ "error.ip-blacklist-entry-empty": "阻止列表中有空项。逗号之间不能留空。",
215
+ "error.ip-protectedlist-entry-empty": "保护列表中有空项。逗号之间不能留空。",
216
+ "field.control-no": "控制编号",
217
+ "field.note": "备注",
218
+ "figure.label.data-bindings": "绑定",
219
+ "text.delete": "删除",
220
+ "text.copy": "复制",
221
+ "text.scenario": "方案"
210
222
  }