@marktiderman/genesis-native 0.2.0 → 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.
Files changed (98) hide show
  1. package/README.md +470 -10
  2. package/dist/block-Onqx7NiK.d.ts +223 -0
  3. package/dist/blocks-C2RDpvaN.d.ts +1595 -0
  4. package/dist/chunk-65P7RU4V.js +49 -0
  5. package/dist/chunk-65P7RU4V.js.map +1 -0
  6. package/dist/chunk-6BT4PTGH.js +37 -0
  7. package/dist/chunk-6BT4PTGH.js.map +1 -0
  8. package/dist/chunk-75YP72G6.js +374 -0
  9. package/dist/chunk-75YP72G6.js.map +1 -0
  10. package/dist/chunk-NGYNYJPL.js +90 -0
  11. package/dist/chunk-NGYNYJPL.js.map +1 -0
  12. package/dist/chunk-ONAXQHNG.js +23 -0
  13. package/dist/chunk-ONAXQHNG.js.map +1 -0
  14. package/dist/chunk-VKTIHNAV.js +95 -0
  15. package/dist/chunk-VKTIHNAV.js.map +1 -0
  16. package/dist/{chunk-MMTPWUWI.js → chunk-VTJDIKFC.js} +9 -6
  17. package/dist/chunk-VTJDIKFC.js.map +1 -0
  18. package/dist/chunk-XE7LC7K6.js +29 -0
  19. package/dist/chunk-XE7LC7K6.js.map +1 -0
  20. package/dist/dev-warnings.d.ts +34 -0
  21. package/dist/dev-warnings.js +15 -0
  22. package/dist/dev-warnings.js.map +1 -0
  23. package/dist/devtools.d.ts +144 -0
  24. package/dist/devtools.js +390 -0
  25. package/dist/devtools.js.map +1 -0
  26. package/dist/index.d.ts +12 -368
  27. package/dist/index.js +88 -191
  28. package/dist/index.js.map +1 -1
  29. package/dist/observability.d.ts +170 -0
  30. package/dist/observability.js +123 -0
  31. package/dist/observability.js.map +1 -0
  32. package/dist/persist.d.ts +1 -1
  33. package/dist/persist.js +1 -1
  34. package/dist/push-payload.d.ts +43 -0
  35. package/dist/push-payload.js +3 -0
  36. package/dist/push-payload.js.map +1 -0
  37. package/dist/push.d.ts +187 -0
  38. package/dist/push.js +1069 -0
  39. package/dist/push.js.map +1 -0
  40. package/dist/{query-DhH3lLFa.d.ts → query-DUmVAfsF.d.ts} +11 -6
  41. package/dist/rich-push-plugin.d.ts +74 -0
  42. package/dist/rich-push-plugin.js +209 -0
  43. package/dist/rich-push-plugin.js.map +1 -0
  44. package/dist/updates.d.ts +8 -1
  45. package/dist/updates.js +44 -69
  46. package/dist/updates.js.map +1 -1
  47. package/ios/notification-service/NotificationService.swift +309 -0
  48. package/package.json +238 -5
  49. package/src/app.tsx +491 -0
  50. package/src/auth.tsx +176 -0
  51. package/src/blocks.tsx +144 -0
  52. package/src/boot.tsx +188 -0
  53. package/src/dev-warnings.ts +43 -0
  54. package/src/devtools/block.tsx +488 -0
  55. package/src/devtools/gestures.ts +107 -0
  56. package/src/devtools/index.ts +22 -0
  57. package/src/devtools/info.ts +45 -0
  58. package/src/devtools/slots.ts +21 -0
  59. package/src/errors.tsx +195 -0
  60. package/src/held-link.ts +90 -0
  61. package/src/hydration-gate.ts +88 -0
  62. package/src/index.ts +83 -0
  63. package/src/links.ts +140 -0
  64. package/src/nativewind.d.ts +41 -0
  65. package/src/observability/block.tsx +300 -0
  66. package/src/observability/events.ts +89 -0
  67. package/src/observability/index.ts +44 -0
  68. package/src/observability/scrub.ts +166 -0
  69. package/src/observability/sentry.ts +222 -0
  70. package/src/observability/sink.ts +147 -0
  71. package/src/persist.ts +83 -0
  72. package/src/plugin/rich-push.ts +312 -0
  73. package/src/push/backend.ts +137 -0
  74. package/src/push/block.tsx +604 -0
  75. package/src/push/index.ts +105 -0
  76. package/src/push/modules.ts +123 -0
  77. package/src/push/payload.ts +61 -0
  78. package/src/push/permission.ts +191 -0
  79. package/src/push/preferences.ts +136 -0
  80. package/src/push/quiet-hours.ts +71 -0
  81. package/src/push/registration.ts +225 -0
  82. package/src/push/routing.ts +57 -0
  83. package/src/push/screens.tsx +46 -0
  84. package/src/push/settings.tsx +378 -0
  85. package/src/query.ts +100 -0
  86. package/src/updates/block.tsx +442 -0
  87. package/src/updates/channels.ts +131 -0
  88. package/src/updates/checker.ts +190 -0
  89. package/src/updates/index.ts +87 -0
  90. package/src/updates/modules.ts +64 -0
  91. package/src/updates/policy.ts +314 -0
  92. package/src/updates/screens.tsx +159 -0
  93. package/src/updates/signature.ts +80 -0
  94. package/src/updates/store.ts +33 -0
  95. package/src/updates/version.ts +39 -0
  96. package/src/updates/whats-new.ts +122 -0
  97. package/dist/block-Dh5ny06W.d.ts +0 -535
  98. package/dist/chunk-MMTPWUWI.js.map +0 -1
@@ -0,0 +1,309 @@
1
+ // Genesis rich push: the Notification Service Extension that
2
+ // `@marktiderman/genesis-native/rich-push-plugin` adds to an Expo app.
3
+ //
4
+ // iOS runs it for a push with `mutable-content: 1` (Expo: `mutableContent: true`).
5
+ // It reads only the payload: it never fetches a message, so a push sent with
6
+ // hidden previews (neutral text, no sender fields) stays neutral.
7
+ //
8
+ // - `data.image` / `data.senderAvatar` (https only, and only from the hosts in
9
+ // the Info.plist's `GenesisRichPushImageHosts` when the plugin's `imageHosts`
10
+ // set it) are downloaded within the time limit and attached. A download that fails or runs late is skipped:
11
+ // the notification is always shown, with what the payload already said.
12
+ // - A push in the `messages` category becomes a Communication Notification:
13
+ // an incoming INSendMessageIntent with the sender (`senderName`,
14
+ // `senderAvatar`, `senderId`), the conversation (`threadId`, else
15
+ // `data.conversationId`) and, in a group, `groupName`. The sender's photo
16
+ // shows on the notification, and Focus treats it as a message.
17
+ //
18
+ // `data` is where Expo's push service puts it (`body` in the APNs payload),
19
+ // or the payload's top level for a push sent to APNs directly.
20
+ //
21
+ // Nothing here logs what a notification says.
22
+
23
+ import Intents
24
+ import UserNotifications
25
+ import os
26
+
27
+ private let messagesCategory = "messages"
28
+ private let maxImageBytes = 10 * 1024 * 1024
29
+ // The extension has about 24 MB; only the avatar is read into memory, and small.
30
+ private let maxAvatarBytes = 1024 * 1024
31
+ private let maxURLLength = 1024
32
+ private let log = Logger(subsystem: "genesis.rich-push", category: "service")
33
+
34
+ final class NotificationService: UNNotificationServiceExtension {
35
+ private let lock = NSLock()
36
+ private var contentHandler: ((UNNotificationContent) -> Void)?
37
+ private var bestAttempt: UNNotificationContent?
38
+ private var session: URLSession?
39
+
40
+ override func didReceive(
41
+ _ request: UNNotificationRequest,
42
+ withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
43
+ ) {
44
+ lock.lock()
45
+ self.contentHandler = contentHandler
46
+ self.bestAttempt = request.content
47
+ lock.unlock()
48
+
49
+ guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
50
+ deliver(request.content)
51
+ return
52
+ }
53
+ let data = payloadData(request.content.userInfo)
54
+ let isMessage = content.categoryIdentifier == messagesCategory
55
+ let imageURL = httpsURL(data["image"])
56
+ let avatarURL = httpsURL(data["senderAvatar"])
57
+
58
+ if imageURL == nil && avatarURL == nil && !isMessage {
59
+ deliver(content)
60
+ return
61
+ }
62
+
63
+ let configuration = URLSessionConfiguration.ephemeral
64
+ configuration.timeoutIntervalForRequest = 8
65
+ configuration.timeoutIntervalForResource = 15
66
+ configuration.httpCookieStorage = nil
67
+ configuration.urlCache = nil
68
+ let session = URLSession(configuration: configuration)
69
+ lock.lock()
70
+ self.session = session
71
+ lock.unlock()
72
+
73
+ let group = DispatchGroup()
74
+ var imageFile: URL?
75
+ var avatarData: Data?
76
+ var avatarFile: URL?
77
+ if let imageURL {
78
+ group.enter()
79
+ download(imageURL, session: session, keepBytes: false) { file, _ in
80
+ imageFile = file
81
+ group.leave()
82
+ }
83
+ }
84
+ if let avatarURL {
85
+ group.enter()
86
+ download(avatarURL, session: session, keepBytes: true) { file, bytes in
87
+ avatarFile = file
88
+ avatarData = bytes
89
+ group.leave()
90
+ }
91
+ }
92
+
93
+ group.notify(queue: .global(qos: .userInitiated)) { [weak self] in
94
+ // Already shown (the time ran out): don't donate or build anything more.
95
+ guard let self, !self.delivered else { return }
96
+ var attachments: [UNNotificationAttachment] = []
97
+ if let imageFile, let attachment = self.attachment("image", imageFile) {
98
+ attachments.append(attachment)
99
+ }
100
+ // On a message the avatar is the sender's photo (the intent's); on anything else it is attached.
101
+ if !isMessage, let avatarFile, let attachment = self.attachment("avatar", avatarFile) {
102
+ attachments.append(attachment)
103
+ }
104
+ content.attachments = attachments
105
+ self.keep(content)
106
+
107
+ guard isMessage else {
108
+ self.deliver(content)
109
+ return
110
+ }
111
+ self.communication(content, data: data, avatar: avatarData, avatarFile: avatarFile)
112
+ }
113
+ }
114
+
115
+ override func serviceExtensionTimeWillExpire() {
116
+ // Out of time: show what we have (at worst the push as it arrived), never nothing.
117
+ lock.lock()
118
+ let content = bestAttempt
119
+ session?.invalidateAndCancel()
120
+ lock.unlock()
121
+ if let content { deliver(content) }
122
+ }
123
+
124
+ // MARK: Communication Notifications
125
+
126
+ private func communication(_ content: UNMutableNotificationContent, data: [String: Any], avatar: Data?, avatarFile: URL?) {
127
+ // Not a communication notification after all: the avatar is the picture, as on any other push.
128
+ let plain = { [weak self] in
129
+ guard let self else { return }
130
+ if content.attachments.isEmpty, let avatarFile, let attachment = self.attachment("avatar", avatarFile) {
131
+ content.attachments = [attachment]
132
+ }
133
+ self.deliver(content)
134
+ }
135
+ guard let name = text(data["senderName"]) else {
136
+ // No sender (hidden previews send none): a plain notification, as sent.
137
+ plain()
138
+ return
139
+ }
140
+ let conversationId = text(content.threadIdentifier) ?? text(data["conversationId"])
141
+ let senderId = text(data["senderId"]) ?? name
142
+ let sender = INPerson(
143
+ personHandle: INPersonHandle(value: senderId, type: .unknown),
144
+ nameComponents: nil,
145
+ displayName: name,
146
+ image: avatar.map { INImage(imageData: $0) },
147
+ contactIdentifier: nil,
148
+ customIdentifier: senderId
149
+ )
150
+ let groupName = text(data["groupName"])
151
+ var recipients: [INPerson]?
152
+ if groupName != nil {
153
+ // iOS draws a group (its name over the sender) when the message has more than one recipient.
154
+ let me = INPerson(
155
+ personHandle: INPersonHandle(value: "me", type: .unknown),
156
+ nameComponents: nil,
157
+ displayName: nil,
158
+ image: nil,
159
+ contactIdentifier: nil,
160
+ customIdentifier: nil,
161
+ isMe: true
162
+ )
163
+ recipients = [me, sender]
164
+ }
165
+ let intent = INSendMessageIntent(
166
+ recipients: recipients,
167
+ outgoingMessageType: .outgoingMessageText,
168
+ content: content.body,
169
+ speakableGroupName: groupName.map { INSpeakableString(spokenPhrase: $0) },
170
+ conversationIdentifier: conversationId,
171
+ serviceName: nil,
172
+ sender: sender,
173
+ attachments: nil
174
+ )
175
+ let interaction = INInteraction(intent: intent, response: nil)
176
+ interaction.direction = .incoming
177
+ interaction.donate { [weak self] error in
178
+ guard let self else { return }
179
+ if error != nil { log.error("donate failed") }
180
+ do {
181
+ let updated = try content.updating(from: intent)
182
+ self.deliver(updated)
183
+ } catch {
184
+ // Without the entitlement (or on an iOS that refuses), the plain notification still shows.
185
+ log.error("communication update failed")
186
+ plain()
187
+ }
188
+ }
189
+ }
190
+
191
+ // MARK: Delivery
192
+
193
+ private var delivered: Bool {
194
+ lock.lock()
195
+ defer { lock.unlock() }
196
+ return contentHandler == nil
197
+ }
198
+
199
+ /// What `serviceExtensionTimeWillExpire` shows if the time runs out from here on.
200
+ private func keep(_ content: UNNotificationContent) {
201
+ lock.lock()
202
+ bestAttempt = content.copy() as? UNNotificationContent
203
+ lock.unlock()
204
+ }
205
+
206
+ private func deliver(_ content: UNNotificationContent) {
207
+ lock.lock()
208
+ let handler = contentHandler
209
+ contentHandler = nil
210
+ session?.finishTasksAndInvalidate()
211
+ session = nil
212
+ lock.unlock()
213
+ handler?(content)
214
+ }
215
+
216
+ // MARK: Downloads
217
+
218
+ private func download(_ url: URL, session: URLSession, keepBytes: Bool, done: @escaping (URL?, Data?) -> Void) {
219
+ let task = session.downloadTask(with: url) { location, response, error in
220
+ guard error == nil, let location,
221
+ let http = response as? HTTPURLResponse, http.statusCode == 200,
222
+ let mime = http.mimeType?.lowercased(), let ext = imageExtension(mime)
223
+ else {
224
+ log.error("image download skipped")
225
+ done(nil, nil)
226
+ return
227
+ }
228
+ do {
229
+ let size = (try FileManager.default.attributesOfItem(atPath: location.path)[.size] as? NSNumber)?.intValue ?? 0
230
+ guard size > 0, size <= maxImageBytes else {
231
+ done(nil, nil)
232
+ return
233
+ }
234
+ let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
235
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
236
+ let file = dir.appendingPathComponent("image.\(ext)")
237
+ try FileManager.default.moveItem(at: location, to: file)
238
+ // Bytes only for the avatar (the intent's INImage), and only a small one.
239
+ done(file, keepBytes && size <= maxAvatarBytes ? try? Data(contentsOf: file) : nil)
240
+ } catch {
241
+ log.error("image save failed")
242
+ done(nil, nil)
243
+ }
244
+ }
245
+ task.resume()
246
+ }
247
+
248
+ private func attachment(_ id: String, _ file: URL) -> UNNotificationAttachment? {
249
+ do {
250
+ return try UNNotificationAttachment(identifier: id, url: file, options: nil)
251
+ } catch {
252
+ log.error("attachment refused")
253
+ return nil
254
+ }
255
+ }
256
+ }
257
+
258
+ // MARK: Payload
259
+
260
+ /// `data`: Expo puts it under `body`; a push sent to APNs directly has it at the top level.
261
+ private func payloadData(_ userInfo: [AnyHashable: Any]) -> [String: Any] {
262
+ if let body = userInfo["body"] as? [String: Any] { return body }
263
+ if let body = userInfo["body"] as? String, let bytes = body.data(using: .utf8),
264
+ let parsed = try? JSONSerialization.jsonObject(with: bytes) as? [String: Any]
265
+ {
266
+ return parsed
267
+ }
268
+ var top: [String: Any] = [:]
269
+ for (key, value) in userInfo {
270
+ if let key = key as? String, key != "aps" { top[key] = value }
271
+ }
272
+ return top
273
+ }
274
+
275
+ private func text(_ value: Any?) -> String? {
276
+ guard let string = value as? String else { return nil }
277
+ let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
278
+ return trimmed.isEmpty ? nil : String(trimmed.prefix(200))
279
+ }
280
+
281
+ /// The hosts images may come from (the plugin's `imageHosts`); none set means any https host.
282
+ private let imageHosts: [String] = ((Bundle.main.object(forInfoDictionaryKey: "GenesisRichPushImageHosts") as? [String]) ?? [])
283
+ .map { $0.lowercased() }
284
+
285
+ private func allowedHost(_ host: String) -> Bool {
286
+ if imageHosts.isEmpty { return true }
287
+ let h = host.lowercased()
288
+ return imageHosts.contains { h == $0 || h.hasSuffix("." + $0) }
289
+ }
290
+
291
+ /// Only https, with a host and no credentials, at most 1024 characters (the JS `isRichPushUrl`).
292
+ private func httpsURL(_ value: Any?) -> URL? {
293
+ guard let string = value as? String, string.count <= maxURLLength,
294
+ let components = URLComponents(string: string),
295
+ components.scheme?.lowercased() == "https",
296
+ let host = components.host, !host.isEmpty, allowedHost(host),
297
+ components.user == nil, components.password == nil
298
+ else { return nil }
299
+ return components.url
300
+ }
301
+
302
+ private func imageExtension(_ mime: String) -> String? {
303
+ switch mime {
304
+ case "image/jpeg", "image/jpg": return "jpg"
305
+ case "image/png": return "png"
306
+ case "image/gif": return "gif"
307
+ default: return nil
308
+ }
309
+ }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@marktiderman/genesis-native",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
- "description": "The native app shell for Genesis Expo apps: one GenesisNativeApp root that runs boot steps behind the splash, catches errors into a fatal screen with retry, persists the query cache, gates on the Supabase session and normalizes deep links. An optional updates block (/updates) adds force upgrade, maintenance, an update banner and what's new. Every part is a slot and is also exported on its own. Part of the Genesis framework.",
7
+ "description": "The native app shell for Genesis Expo apps: one GenesisNativeApp root that runs boot steps behind the splash, catches errors into a fatal screen with retry, persists the query cache, gates on the Supabase session and normalizes deep links. An optional updates block (/updates) adds force upgrade, maintenance, an update banner and what's new; an optional push block (/push) adds push registration, tap routing, the badge and a notification settings screen with quiet hours and private previews; the observability block (/observability) reports errors, breadcrumbs, typed analytics events and timings through vendor-neutral sinks (Sentry, EAS Observe, console) with PII scrubbed on the device; the devtools overlay (/devtools) is a debug sheet for development and preview builds. Blocks (defineBlock declarations, through `blocks`) add boot steps, roots, slot fills and Settings rows. Every part is a slot and is also exported on its own. Part of the Genesis framework.",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/marktiderman/genesis.git",
@@ -18,48 +18,103 @@
18
18
  "main": "./dist/index.js",
19
19
  "module": "./dist/index.js",
20
20
  "types": "./dist/index.d.ts",
21
+ "source": "./src/index.ts",
22
+ "react-native": "./src/index.ts",
21
23
  "exports": {
22
24
  "./package.json": "./package.json",
23
25
  ".": {
24
26
  "types": "./dist/index.d.ts",
27
+ "react-native": "./src/index.ts",
25
28
  "import": "./dist/index.js"
26
29
  },
27
30
  "./persist": {
28
31
  "types": "./dist/persist.d.ts",
32
+ "react-native": "./src/persist.ts",
29
33
  "import": "./dist/persist.js"
30
34
  },
31
35
  "./updates": {
32
36
  "types": "./dist/updates.d.ts",
37
+ "react-native": "./src/updates/index.ts",
33
38
  "import": "./dist/updates.js"
39
+ },
40
+ "./push": {
41
+ "types": "./dist/push.d.ts",
42
+ "react-native": "./src/push/index.ts",
43
+ "import": "./dist/push.js"
44
+ },
45
+ "./push-payload": {
46
+ "types": "./dist/push-payload.d.ts",
47
+ "import": "./dist/push-payload.js",
48
+ "default": "./dist/push-payload.js"
49
+ },
50
+ "./observability": {
51
+ "types": "./dist/observability.d.ts",
52
+ "react-native": "./src/observability/index.ts",
53
+ "import": "./dist/observability.js"
54
+ },
55
+ "./devtools": {
56
+ "types": "./dist/devtools.d.ts",
57
+ "react-native": "./src/devtools/index.ts",
58
+ "import": "./dist/devtools.js"
59
+ },
60
+ "./dev-warnings": {
61
+ "types": "./dist/dev-warnings.d.ts",
62
+ "react-native": "./src/dev-warnings.ts",
63
+ "import": "./dist/dev-warnings.js"
64
+ },
65
+ "./rich-push-plugin": {
66
+ "types": "./dist/rich-push-plugin.d.ts",
67
+ "import": "./dist/rich-push-plugin.js",
68
+ "default": "./dist/rich-push-plugin.js"
34
69
  }
35
70
  },
36
71
  "files": [
37
72
  "dist",
73
+ "src",
74
+ "ios",
75
+ "!src/**/__tests__",
38
76
  "README.md"
39
77
  ],
40
78
  "dependencies": {
41
79
  "@marktiderman/genesis-core": "^1.7.0",
42
- "@marktiderman/genesis-design-system": "^1.4.1",
43
- "@marktiderman/genesis-ui-native": "^2.2.0"
80
+ "@marktiderman/genesis-design-system": "^1.5.0",
81
+ "@marktiderman/genesis-features": "^0.3.0",
82
+ "@marktiderman/genesis-ui-native": "^2.4.0"
44
83
  },
45
84
  "peerDependencies": {
46
85
  "@tanstack/query-async-storage-persister": "^5.0.0",
47
86
  "@tanstack/query-persist-client-core": "^5.0.0",
48
87
  "@tanstack/react-query": "^5.0.0",
88
+ "expo": ">=54",
49
89
  "react": "^19.0.0",
50
90
  "react-native": ">=0.76.0",
51
91
  "react-native-safe-area-context": ">=4.0.0"
52
92
  },
53
93
  "peerDependenciesMeta": {
94
+ "@sentry/react-native": {
95
+ "optional": true
96
+ },
54
97
  "@tanstack/query-async-storage-persister": {
55
98
  "optional": true
56
99
  },
57
100
  "@tanstack/query-persist-client-core": {
58
101
  "optional": true
59
102
  },
103
+ "expo": {
104
+ "optional": true
105
+ },
60
106
  "expo-application": {
61
107
  "optional": true
62
108
  },
109
+ "expo-notifications": {
110
+ "optional": true
111
+ },
112
+ "expo-observe": {
113
+ "optional": true
114
+ },
115
+ "expo-sensors": {
116
+ "optional": true
117
+ },
63
118
  "expo-updates": {
64
119
  "optional": true
65
120
  }
@@ -71,13 +126,191 @@
71
126
  "@tanstack/react-query": "^5.75.0",
72
127
  "@testing-library/react": "^16.3.2",
73
128
  "@types/react": "^19.2.14",
129
+ "expo": "~58.0.19",
74
130
  "happy-dom": "^20.9.0",
75
131
  "react": "^19.2.5",
76
132
  "react-dom": "^19.2.5",
77
133
  "tsup": "^8.3.0",
78
- "typescript": "^5.9.3",
134
+ "typescript": "~6.0.3",
79
135
  "vitest": "^4.1.12"
80
136
  },
137
+ "genesis": {
138
+ "blocks": {
139
+ "push": {
140
+ "feature": {
141
+ "name": "Push and notification preferences",
142
+ "summary": "Registers this device's push token for whoever signs in, routes taps, and edits per-category preferences, quiet hours and previews.",
143
+ "keywords": [
144
+ "notifications",
145
+ "push token",
146
+ "quiet hours"
147
+ ],
148
+ "audience": [
149
+ "person"
150
+ ]
151
+ },
152
+ "platforms": [
153
+ "native"
154
+ ],
155
+ "packages": [
156
+ "@marktiderman/genesis-native",
157
+ "@marktiderman/genesis-supabase"
158
+ ],
159
+ "migrations": [
160
+ {
161
+ "package": "@marktiderman/genesis-supabase",
162
+ "file": "migrations/0005_genesis_push.sql",
163
+ "test": "migrations/tests/0005_genesis_push.test.sql"
164
+ }
165
+ ],
166
+ "functions": [
167
+ {
168
+ "package": "@marktiderman/genesis-supabase",
169
+ "dir": "functions/genesis-push-send",
170
+ "verifyJwt": false,
171
+ "secrets": [
172
+ "GENESIS_PUSH_SECRET",
173
+ "EXPO_ACCESS_TOKEN"
174
+ ]
175
+ }
176
+ ],
177
+ "routes": [
178
+ {
179
+ "path": "/settings/notifications",
180
+ "description": "Notification settings: categories, quiet hours, previews",
181
+ "signedIn": true
182
+ }
183
+ ],
184
+ "steps": [
185
+ "npx expo install expo-notifications, and add \"expo-notifications\" to the plugins in app.config.",
186
+ "Create the block with createPush (@marktiderman/genesis-native/push) and pass it to <GenesisNativeApp blocks>; render NotificationSettings on the /settings/notifications screen (genesis-native README, Push).",
187
+ "A push token needs an EAS project id (eas init)."
188
+ ],
189
+ "privacy": {
190
+ "collects": [
191
+ {
192
+ "type": "device ID",
193
+ "purposes": [
194
+ "app functionality"
195
+ ],
196
+ "linked": true,
197
+ "tracking": false
198
+ }
199
+ ]
200
+ }
201
+ },
202
+ "app-updates": {
203
+ "feature": {
204
+ "name": "App updates",
205
+ "summary": "Force upgrade, a maintenance screen, an update banner and a what's new sheet, from the app_release_policy row.",
206
+ "keywords": [
207
+ "force upgrade",
208
+ "maintenance",
209
+ "ota"
210
+ ],
211
+ "audience": [
212
+ "person"
213
+ ]
214
+ },
215
+ "platforms": [
216
+ "native"
217
+ ],
218
+ "packages": [
219
+ "@marktiderman/genesis-native",
220
+ "@marktiderman/genesis-supabase"
221
+ ],
222
+ "migrations": [
223
+ {
224
+ "package": "@marktiderman/genesis-supabase",
225
+ "file": "migrations/0002_genesis_app_release_policy.sql",
226
+ "test": "migrations/tests/0002_genesis_app_release_policy.test.sql"
227
+ }
228
+ ],
229
+ "steps": [
230
+ "npx expo install expo-updates expo-application.",
231
+ "Create the block with createUpdates (@marktiderman/genesis-native/updates) and pass it to <GenesisNativeApp updates> (genesis-native README, Updates)."
232
+ ]
233
+ },
234
+ "observability": {
235
+ "feature": {
236
+ "name": "Observability",
237
+ "summary": "Crash reports, breadcrumbs, timings and typed analytics events, scrubbed on the device, through vendor-neutral sinks (Sentry included).",
238
+ "keywords": [
239
+ "crash reporting",
240
+ "sentry",
241
+ "analytics",
242
+ "breadcrumbs"
243
+ ],
244
+ "audience": [
245
+ "developer"
246
+ ]
247
+ },
248
+ "platforms": [
249
+ "native"
250
+ ],
251
+ "steps": [
252
+ "npx expo install @sentry/react-native if you report to Sentry, and set the DSN in your environment (genesis-native README, Observability).",
253
+ "Create the block with createObservability (@marktiderman/genesis-native/observability) at module scope, pass it to <GenesisNativeApp blocks>, and export observability.wrap(RootLayout)."
254
+ ],
255
+ "privacy": {
256
+ "collects": [
257
+ {
258
+ "type": "crash data",
259
+ "purposes": [
260
+ "app functionality"
261
+ ],
262
+ "linked": true,
263
+ "tracking": false
264
+ },
265
+ {
266
+ "type": "performance data",
267
+ "purposes": [
268
+ "app functionality"
269
+ ],
270
+ "linked": true,
271
+ "tracking": false
272
+ },
273
+ {
274
+ "type": "other diagnostic data",
275
+ "purposes": [
276
+ "app functionality"
277
+ ],
278
+ "linked": true,
279
+ "tracking": false
280
+ },
281
+ {
282
+ "type": "user id",
283
+ "purposes": [
284
+ "app functionality"
285
+ ],
286
+ "linked": true,
287
+ "tracking": false
288
+ }
289
+ ]
290
+ }
291
+ },
292
+ "devtools-overlay": {
293
+ "feature": {
294
+ "name": "Devtools overlay",
295
+ "summary": "An on-device overlay for development, preview and internal builds: what the app knows about itself, recent activity, a crash test.",
296
+ "keywords": [
297
+ "debug menu",
298
+ "devtools",
299
+ "shake to debug"
300
+ ],
301
+ "audience": [
302
+ "developer"
303
+ ]
304
+ },
305
+ "platforms": [
306
+ "native"
307
+ ],
308
+ "steps": [
309
+ "Create the block with createDevtools (@marktiderman/genesis-native/devtools), enabled only outside production builds, and pass it to <GenesisNativeApp blocks> (genesis-native README)."
310
+ ]
311
+ }
312
+ }
313
+ },
81
314
  "scripts": {
82
315
  "build": "tsup && node ../../scripts/check-exports-resolve.mjs",
83
316
  "clean": "rm -rf dist",