@marktiderman/genesis-native 0.3.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.
- package/README.md +117 -1
- package/dist/{block-C5-noMhB.d.ts → block-Onqx7NiK.d.ts} +1 -1
- package/dist/{blocks-a1YQ1Elf.d.ts → blocks-C2RDpvaN.d.ts} +59 -4
- package/dist/chunk-ONAXQHNG.js +23 -0
- package/dist/chunk-ONAXQHNG.js.map +1 -0
- package/dist/devtools.d.ts +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/observability.d.ts +3 -3
- package/dist/push-payload.d.ts +43 -0
- package/dist/push-payload.js +3 -0
- package/dist/push-payload.js.map +1 -0
- package/dist/push.d.ts +21 -3
- package/dist/push.js +77 -4
- package/dist/push.js.map +1 -1
- package/dist/rich-push-plugin.d.ts +74 -0
- package/dist/rich-push-plugin.js +209 -0
- package/dist/rich-push-plugin.js.map +1 -0
- package/dist/updates.d.ts +1 -1
- package/ios/notification-service/NotificationService.swift +309 -0
- package/package.json +21 -5
- package/src/plugin/rich-push.ts +312 -0
- package/src/push/backend.ts +10 -2
- package/src/push/block.tsx +61 -2
- package/src/push/index.ts +4 -1
- package/src/push/modules.ts +13 -1
- package/src/push/payload.ts +61 -0
- package/src/push/registration.ts +37 -1
- package/src/push/routing.ts +30 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marktiderman/genesis-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -42,6 +42,11 @@
|
|
|
42
42
|
"react-native": "./src/push/index.ts",
|
|
43
43
|
"import": "./dist/push.js"
|
|
44
44
|
},
|
|
45
|
+
"./push-payload": {
|
|
46
|
+
"types": "./dist/push-payload.d.ts",
|
|
47
|
+
"import": "./dist/push-payload.js",
|
|
48
|
+
"default": "./dist/push-payload.js"
|
|
49
|
+
},
|
|
45
50
|
"./observability": {
|
|
46
51
|
"types": "./dist/observability.d.ts",
|
|
47
52
|
"react-native": "./src/observability/index.ts",
|
|
@@ -56,11 +61,17 @@
|
|
|
56
61
|
"types": "./dist/dev-warnings.d.ts",
|
|
57
62
|
"react-native": "./src/dev-warnings.ts",
|
|
58
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"
|
|
59
69
|
}
|
|
60
70
|
},
|
|
61
71
|
"files": [
|
|
62
72
|
"dist",
|
|
63
73
|
"src",
|
|
74
|
+
"ios",
|
|
64
75
|
"!src/**/__tests__",
|
|
65
76
|
"README.md"
|
|
66
77
|
],
|
|
@@ -68,12 +79,13 @@
|
|
|
68
79
|
"@marktiderman/genesis-core": "^1.7.0",
|
|
69
80
|
"@marktiderman/genesis-design-system": "^1.5.0",
|
|
70
81
|
"@marktiderman/genesis-features": "^0.3.0",
|
|
71
|
-
"@marktiderman/genesis-ui-native": "^2.
|
|
82
|
+
"@marktiderman/genesis-ui-native": "^2.4.0"
|
|
72
83
|
},
|
|
73
84
|
"peerDependencies": {
|
|
74
85
|
"@tanstack/query-async-storage-persister": "^5.0.0",
|
|
75
86
|
"@tanstack/query-persist-client-core": "^5.0.0",
|
|
76
87
|
"@tanstack/react-query": "^5.0.0",
|
|
88
|
+
"expo": ">=54",
|
|
77
89
|
"react": "^19.0.0",
|
|
78
90
|
"react-native": ">=0.76.0",
|
|
79
91
|
"react-native-safe-area-context": ">=4.0.0"
|
|
@@ -88,9 +100,15 @@
|
|
|
88
100
|
"@tanstack/query-persist-client-core": {
|
|
89
101
|
"optional": true
|
|
90
102
|
},
|
|
103
|
+
"expo": {
|
|
104
|
+
"optional": true
|
|
105
|
+
},
|
|
91
106
|
"expo-application": {
|
|
92
107
|
"optional": true
|
|
93
108
|
},
|
|
109
|
+
"expo-notifications": {
|
|
110
|
+
"optional": true
|
|
111
|
+
},
|
|
94
112
|
"expo-observe": {
|
|
95
113
|
"optional": true
|
|
96
114
|
},
|
|
@@ -99,9 +117,6 @@
|
|
|
99
117
|
},
|
|
100
118
|
"expo-updates": {
|
|
101
119
|
"optional": true
|
|
102
|
-
},
|
|
103
|
-
"expo-notifications": {
|
|
104
|
-
"optional": true
|
|
105
120
|
}
|
|
106
121
|
},
|
|
107
122
|
"devDependencies": {
|
|
@@ -111,6 +126,7 @@
|
|
|
111
126
|
"@tanstack/react-query": "^5.75.0",
|
|
112
127
|
"@testing-library/react": "^16.3.2",
|
|
113
128
|
"@types/react": "^19.2.14",
|
|
129
|
+
"expo": "~58.0.19",
|
|
114
130
|
"happy-dom": "^20.9.0",
|
|
115
131
|
"react": "^19.2.5",
|
|
116
132
|
"react-dom": "^19.2.5",
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@marktiderman/genesis-native/rich-push-plugin`: the Expo config plugin for
|
|
3
|
+
* iOS rich push. It adds a Notification Service Extension target (the Swift in
|
|
4
|
+
* `ios/notification-service/`) that downloads `data.image` / `data.senderAvatar`
|
|
5
|
+
* and turns `messages` pushes into Communication Notifications (the sender's
|
|
6
|
+
* photo, Focus treats them as messages), and it sets what they need:
|
|
7
|
+
*
|
|
8
|
+
* - the app: `com.apple.developer.usernotifications.communication` and
|
|
9
|
+
* `NSUserActivityTypes: [INSendMessageIntent]`;
|
|
10
|
+
* - the extension: its own bundle id (`<app>.NotificationService`), Info.plist
|
|
11
|
+
* and entitlements, embedded in the app;
|
|
12
|
+
* - EAS: `extra.eas.build.experimental.ios.appExtensions`, so EAS Build makes
|
|
13
|
+
* the extension's provisioning profile with the app's.
|
|
14
|
+
*
|
|
15
|
+
* plugins: [..., "@marktiderman/genesis-cli", "@marktiderman/genesis-native/rich-push-plugin"]
|
|
16
|
+
*
|
|
17
|
+
* List it after any plugin that changes the bundle id (the Genesis plugin's
|
|
18
|
+
* APP_VARIANT suffix): the extension's id is the app's plus a suffix, read
|
|
19
|
+
* when this plugin runs. Running it twice changes nothing.
|
|
20
|
+
*/
|
|
21
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
// Expo's own copy (the app's `expo`), so the mods run on the same config-plugins as prebuild.
|
|
25
|
+
import { withDangerousMod, withXcodeProject, type ConfigPlugin } from "expo/config-plugins";
|
|
26
|
+
import type { ExpoConfig } from "expo/config";
|
|
27
|
+
|
|
28
|
+
export const COMMUNICATION_ENTITLEMENT = "com.apple.developer.usernotifications.communication";
|
|
29
|
+
export const SEND_MESSAGE_INTENT = "INSendMessageIntent";
|
|
30
|
+
export const DEFAULT_TARGET_NAME = "GenesisNotificationService";
|
|
31
|
+
export const DEFAULT_BUNDLE_SUFFIX = "NotificationService";
|
|
32
|
+
const SWIFT_FILE = "NotificationService.swift";
|
|
33
|
+
|
|
34
|
+
export interface RichPushPluginOptions {
|
|
35
|
+
/** The extension's Xcode target and folder under ios/. Default `GenesisNotificationService`. */
|
|
36
|
+
targetName?: string;
|
|
37
|
+
/** Appended to the app's bundle id with a dot. Default `NotificationService`. */
|
|
38
|
+
bundleIdSuffix?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Communication Notifications (the sender's photo, Focus): the entitlement and
|
|
41
|
+
* `NSUserActivityTypes`. Default true; false keeps image attachments only.
|
|
42
|
+
*/
|
|
43
|
+
communication?: boolean;
|
|
44
|
+
/** The extension's minimum iOS. Default: the app target's. */
|
|
45
|
+
deploymentTarget?: string;
|
|
46
|
+
/**
|
|
47
|
+
* The hosts images may be downloaded from (`cdn.example.com`, or `example.com`
|
|
48
|
+
* for it and its subdomains). Unset, any https host: a sender who puts a URL
|
|
49
|
+
* a person chose (their avatar) in a push lets that host see each delivery.
|
|
50
|
+
*/
|
|
51
|
+
imageHosts?: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface Resolved {
|
|
55
|
+
targetName: string;
|
|
56
|
+
bundleIdSuffix: string;
|
|
57
|
+
communication: boolean;
|
|
58
|
+
deploymentTarget: string | undefined;
|
|
59
|
+
imageHosts: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolve(options: RichPushPluginOptions | undefined): Resolved {
|
|
63
|
+
const targetName = options?.targetName ?? DEFAULT_TARGET_NAME;
|
|
64
|
+
const bundleIdSuffix = options?.bundleIdSuffix ?? DEFAULT_BUNDLE_SUFFIX;
|
|
65
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(targetName)) throw new Error(`rich-push-plugin: targetName "${targetName}" must be letters, digits and _`);
|
|
66
|
+
if (!/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*$/.test(bundleIdSuffix)) throw new Error(`rich-push-plugin: bundleIdSuffix "${bundleIdSuffix}" isn't a bundle id part`);
|
|
67
|
+
const imageHosts = (options?.imageHosts ?? []).map((h) => h.trim().toLowerCase());
|
|
68
|
+
for (const h of imageHosts) if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(h)) throw new Error(`rich-push-plugin: imageHosts "${h}" isn't a host name`);
|
|
69
|
+
return { targetName, bundleIdSuffix, communication: options?.communication ?? true, deploymentTarget: options?.deploymentTarget, imageHosts };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The extension's bundle id: the app's plus the suffix. */
|
|
73
|
+
export function extensionBundleId(appBundleId: string, suffix = DEFAULT_BUNDLE_SUFFIX): string {
|
|
74
|
+
return `${appBundleId}.${suffix}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The extension's entitlements (also what EAS provisions it with). */
|
|
78
|
+
export function extensionEntitlements(communication: boolean): Record<string, unknown> {
|
|
79
|
+
return communication ? { [COMMUNICATION_ENTITLEMENT]: true } : {};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The config-time part, visible in `expo config`: the app's entitlement and
|
|
84
|
+
* `NSUserActivityTypes`, and the extension for EAS. Fills gaps and appends;
|
|
85
|
+
* never removes what the app set.
|
|
86
|
+
*/
|
|
87
|
+
export function applyRichPushConfig(config: ExpoConfig, options?: RichPushPluginOptions): ExpoConfig {
|
|
88
|
+
const o = resolve(options);
|
|
89
|
+
const bundleId = config.ios?.bundleIdentifier;
|
|
90
|
+
if (!bundleId) throw new Error("rich-push-plugin: ios.bundleIdentifier is required (the extension's id is built from it)");
|
|
91
|
+
const ios = { ...config.ios };
|
|
92
|
+
if (o.communication) {
|
|
93
|
+
ios.entitlements = { ...ios.entitlements, [COMMUNICATION_ENTITLEMENT]: true };
|
|
94
|
+
const types = Array.isArray(ios.infoPlist?.NSUserActivityTypes) ? (ios.infoPlist!.NSUserActivityTypes as unknown[]) : [];
|
|
95
|
+
ios.infoPlist = { ...ios.infoPlist, NSUserActivityTypes: types.includes(SEND_MESSAGE_INTENT) ? types : [...types, SEND_MESSAGE_INTENT] };
|
|
96
|
+
}
|
|
97
|
+
const extra = { ...config.extra };
|
|
98
|
+
const eas = { ...(extra.eas as Record<string, unknown> | undefined) };
|
|
99
|
+
const build = { ...(eas.build as Record<string, unknown> | undefined) };
|
|
100
|
+
const experimental = { ...(build.experimental as Record<string, unknown> | undefined) };
|
|
101
|
+
const easIos = { ...(experimental.ios as Record<string, unknown> | undefined) };
|
|
102
|
+
const existing = Array.isArray(easIos.appExtensions) ? (easIos.appExtensions as { targetName?: string }[]) : [];
|
|
103
|
+
const entry = { targetName: o.targetName, bundleIdentifier: extensionBundleId(bundleId, o.bundleIdSuffix), entitlements: extensionEntitlements(o.communication) };
|
|
104
|
+
easIos.appExtensions = [...existing.filter((e) => e?.targetName !== o.targetName), entry];
|
|
105
|
+
extra.eas = { ...eas, build: { ...build, experimental: { ...experimental, ios: easIos } } };
|
|
106
|
+
return { ...config, ios, extra };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The extension's Info.plist. Versions follow the app's (App Store Connect rejects a mismatch). */
|
|
110
|
+
export function extensionInfoPlist(displayName: string, options: { communication?: boolean; imageHosts?: string[] } = {}): string {
|
|
111
|
+
const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
112
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
113
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
114
|
+
<plist version="1.0">
|
|
115
|
+
<dict>
|
|
116
|
+
<key>CFBundleDevelopmentRegion</key>
|
|
117
|
+
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
|
118
|
+
<key>CFBundleDisplayName</key>
|
|
119
|
+
<string>${esc(displayName)}</string>
|
|
120
|
+
<key>CFBundleExecutable</key>
|
|
121
|
+
<string>$(EXECUTABLE_NAME)</string>
|
|
122
|
+
<key>CFBundleIdentifier</key>
|
|
123
|
+
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
|
124
|
+
<key>CFBundleInfoDictionaryVersion</key>
|
|
125
|
+
<string>6.0</string>
|
|
126
|
+
<key>CFBundleName</key>
|
|
127
|
+
<string>$(PRODUCT_NAME)</string>
|
|
128
|
+
<key>CFBundlePackageType</key>
|
|
129
|
+
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
|
130
|
+
<key>CFBundleShortVersionString</key>
|
|
131
|
+
<string>$(MARKETING_VERSION)</string>
|
|
132
|
+
<key>CFBundleVersion</key>
|
|
133
|
+
<string>$(CURRENT_PROJECT_VERSION)</string>
|
|
134
|
+
<key>NSExtension</key>
|
|
135
|
+
<dict>
|
|
136
|
+
<key>NSExtensionPointIdentifier</key>
|
|
137
|
+
<string>com.apple.usernotifications.service</string>
|
|
138
|
+
<key>NSExtensionPrincipalClass</key>
|
|
139
|
+
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>${
|
|
140
|
+
options.communication
|
|
141
|
+
? `
|
|
142
|
+
<key>NSExtensionAttributes</key>
|
|
143
|
+
<dict>
|
|
144
|
+
<key>IntentsSupported</key>
|
|
145
|
+
<array>
|
|
146
|
+
<string>${SEND_MESSAGE_INTENT}</string>
|
|
147
|
+
</array>
|
|
148
|
+
</dict>`
|
|
149
|
+
: ""
|
|
150
|
+
}
|
|
151
|
+
</dict>${
|
|
152
|
+
options.imageHosts?.length
|
|
153
|
+
? `
|
|
154
|
+
<key>GenesisRichPushImageHosts</key>
|
|
155
|
+
<array>
|
|
156
|
+
${options.imageHosts.map((h) => ` <string>${esc(h)}</string>`).join("\n")}
|
|
157
|
+
</array>`
|
|
158
|
+
: ""
|
|
159
|
+
}
|
|
160
|
+
</dict>
|
|
161
|
+
</plist>
|
|
162
|
+
`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function entitlementsPlist(entitlements: Record<string, unknown>): string {
|
|
166
|
+
const body = Object.entries(entitlements)
|
|
167
|
+
.map(([k, v]) => ` <key>${k}</key>\n <${v ? "true" : "false"}/>`)
|
|
168
|
+
.join("\n");
|
|
169
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
170
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
171
|
+
<plist version="1.0">
|
|
172
|
+
<dict>
|
|
173
|
+
${body}
|
|
174
|
+
</dict>
|
|
175
|
+
</plist>
|
|
176
|
+
`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** This package's root, found from this file (src/plugin in tests, dist when built). */
|
|
180
|
+
export function packageRoot(from = path.dirname(fileURLToPath(import.meta.url))): string {
|
|
181
|
+
let dir = from;
|
|
182
|
+
for (;;) {
|
|
183
|
+
const pkg = path.join(dir, "package.json");
|
|
184
|
+
if (existsSync(pkg) && (JSON.parse(readFileSync(pkg, "utf8")) as { name?: string }).name === "@marktiderman/genesis-native") return dir;
|
|
185
|
+
const parent = path.dirname(dir);
|
|
186
|
+
if (parent === dir) throw new Error("rich-push-plugin: can't find @marktiderman/genesis-native's folder");
|
|
187
|
+
dir = parent;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The extension's Swift source, shipped in this package. */
|
|
192
|
+
export function serviceSwiftPath(root = packageRoot()): string {
|
|
193
|
+
return path.join(root, "ios", "notification-service", SWIFT_FILE);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Writes the extension's folder under ios/: the Swift, its Info.plist and its entitlements. */
|
|
197
|
+
export function writeExtensionFiles(
|
|
198
|
+
iosRoot: string,
|
|
199
|
+
options: { targetName: string; displayName: string; communication: boolean; imageHosts?: string[]; swiftSource?: string },
|
|
200
|
+
): void {
|
|
201
|
+
const dir = path.join(iosRoot, options.targetName);
|
|
202
|
+
mkdirSync(dir, { recursive: true });
|
|
203
|
+
copyFileSync(options.swiftSource ?? serviceSwiftPath(), path.join(dir, SWIFT_FILE));
|
|
204
|
+
writeFileSync(path.join(dir, "Info.plist"), extensionInfoPlist(options.displayName, { communication: options.communication, imageHosts: options.imageHosts ?? [] }));
|
|
205
|
+
writeFileSync(path.join(dir, `${options.targetName}.entitlements`), entitlementsPlist(extensionEntitlements(options.communication)));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The parts of the `xcode` package's project this uses. */
|
|
209
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
210
|
+
type XcodeProject = any;
|
|
211
|
+
|
|
212
|
+
const unquote = (v: unknown) => (typeof v === "string" ? v.replace(/^"(.*)"$/, "$1") : v);
|
|
213
|
+
|
|
214
|
+
/** The app target's build setting (its first configuration that has it). */
|
|
215
|
+
function appSetting(project: XcodeProject, key: string): string | undefined {
|
|
216
|
+
const app = project.getFirstTarget();
|
|
217
|
+
const listId = app?.firstTarget?.buildConfigurationList;
|
|
218
|
+
const lists = project.pbxXCConfigurationList();
|
|
219
|
+
const configs = project.pbxXCBuildConfigurationSection();
|
|
220
|
+
for (const ref of lists[listId]?.buildConfigurations ?? []) {
|
|
221
|
+
const value = configs[ref.value]?.buildSettings?.[key];
|
|
222
|
+
if (value !== undefined) return String(unquote(value));
|
|
223
|
+
}
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Adds the extension target to the project (once): its group and files, the
|
|
229
|
+
* sources, frameworks and resources phases, its build settings, a dependency
|
|
230
|
+
* from the app and the app's phase that embeds it.
|
|
231
|
+
*/
|
|
232
|
+
export function addNotificationServiceTarget(
|
|
233
|
+
project: XcodeProject,
|
|
234
|
+
options: { targetName: string; bundleId: string; deploymentTarget?: string; developmentTeam?: string; marketingVersion?: string; buildNumber?: string },
|
|
235
|
+
): { added: boolean } {
|
|
236
|
+
const { targetName } = options;
|
|
237
|
+
if (project.pbxTargetByName(targetName) || project.pbxTargetByName(`"${targetName}"`)) return { added: false };
|
|
238
|
+
|
|
239
|
+
const objects = project.hash.project.objects;
|
|
240
|
+
// The xcode package assumes these sections exist; Expo's template has none.
|
|
241
|
+
objects.PBXTargetDependency ??= {};
|
|
242
|
+
objects.PBXContainerItemProxy ??= {};
|
|
243
|
+
|
|
244
|
+
const deploymentTarget = options.deploymentTarget ?? appSetting(project, "IPHONEOS_DEPLOYMENT_TARGET") ?? "15.1";
|
|
245
|
+
const marketingVersion = options.marketingVersion ?? appSetting(project, "MARKETING_VERSION") ?? "1.0";
|
|
246
|
+
const buildNumber = options.buildNumber ?? appSetting(project, "CURRENT_PROJECT_VERSION") ?? "1";
|
|
247
|
+
const developmentTeam = options.developmentTeam ?? appSetting(project, "DEVELOPMENT_TEAM");
|
|
248
|
+
|
|
249
|
+
// The Swift is compiled; the Info.plist and entitlements are build settings, so they are in the group only.
|
|
250
|
+
const group = project.addPbxGroup([SWIFT_FILE], targetName, targetName);
|
|
251
|
+
project.addFile("Info.plist", group.uuid, { lastKnownFileType: "text.plist.xml" });
|
|
252
|
+
project.addFile(`${targetName}.entitlements`, group.uuid, { lastKnownFileType: "text.plist.entitlements" });
|
|
253
|
+
const mainGroup = project.getFirstProject().firstProject.mainGroup;
|
|
254
|
+
project.addToPbxGroup(group.uuid, mainGroup);
|
|
255
|
+
|
|
256
|
+
const target = project.addTarget(targetName, "app_extension", targetName, options.bundleId);
|
|
257
|
+
project.addBuildPhase([SWIFT_FILE], "PBXSourcesBuildPhase", "Sources", target.uuid);
|
|
258
|
+
project.addBuildPhase([], "PBXFrameworksBuildPhase", "Frameworks", target.uuid);
|
|
259
|
+
project.addBuildPhase([], "PBXResourcesBuildPhase", "Resources", target.uuid);
|
|
260
|
+
|
|
261
|
+
const configs = project.pbxXCBuildConfigurationSection();
|
|
262
|
+
const listId = target.pbxNativeTarget.buildConfigurationList;
|
|
263
|
+
for (const ref of project.pbxXCConfigurationList()[listId].buildConfigurations) {
|
|
264
|
+
const settings = configs[ref.value].buildSettings;
|
|
265
|
+
Object.assign(settings, {
|
|
266
|
+
INFOPLIST_FILE: `${targetName}/Info.plist`,
|
|
267
|
+
CODE_SIGN_ENTITLEMENTS: `${targetName}/${targetName}.entitlements`,
|
|
268
|
+
CODE_SIGN_STYLE: "Automatic",
|
|
269
|
+
IPHONEOS_DEPLOYMENT_TARGET: deploymentTarget,
|
|
270
|
+
TARGETED_DEVICE_FAMILY: '"1,2"',
|
|
271
|
+
SWIFT_VERSION: "5.0",
|
|
272
|
+
MARKETING_VERSION: marketingVersion,
|
|
273
|
+
CURRENT_PROJECT_VERSION: buildNumber,
|
|
274
|
+
GENERATE_INFOPLIST_FILE: "NO",
|
|
275
|
+
PRODUCT_BUNDLE_IDENTIFIER: `"${options.bundleId}"`,
|
|
276
|
+
SDKROOT: "iphoneos",
|
|
277
|
+
CLANG_ENABLE_MODULES: "YES",
|
|
278
|
+
SWIFT_EMIT_LOC_STRINGS: "YES",
|
|
279
|
+
...(developmentTeam ? { DEVELOPMENT_TEAM: developmentTeam } : {}),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (developmentTeam) project.addTargetAttribute("DevelopmentTeam", developmentTeam, target);
|
|
283
|
+
return { added: true };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const withRichPush: ConfigPlugin<RichPushPluginOptions | void> = (config, options) => {
|
|
287
|
+
const o = resolve(options ?? undefined);
|
|
288
|
+
config = applyRichPushConfig(config, o);
|
|
289
|
+
const bundleId = extensionBundleId(config.ios!.bundleIdentifier!, o.bundleIdSuffix);
|
|
290
|
+
|
|
291
|
+
config = withDangerousMod(config, [
|
|
292
|
+
"ios",
|
|
293
|
+
(c) => {
|
|
294
|
+
writeExtensionFiles(c.modRequest.platformProjectRoot, { targetName: o.targetName, displayName: c.name, communication: o.communication, imageHosts: o.imageHosts });
|
|
295
|
+
return c;
|
|
296
|
+
},
|
|
297
|
+
]);
|
|
298
|
+
config = withXcodeProject(config, (c) => {
|
|
299
|
+
addNotificationServiceTarget(c.modResults, {
|
|
300
|
+
targetName: o.targetName,
|
|
301
|
+
bundleId,
|
|
302
|
+
...(o.deploymentTarget ? { deploymentTarget: o.deploymentTarget } : {}),
|
|
303
|
+
...(c.ios?.appleTeamId ? { developmentTeam: c.ios.appleTeamId } : {}),
|
|
304
|
+
...(c.version ? { marketingVersion: c.version } : {}),
|
|
305
|
+
...(c.ios?.buildNumber ? { buildNumber: c.ios.buildNumber } : {}),
|
|
306
|
+
});
|
|
307
|
+
return c;
|
|
308
|
+
});
|
|
309
|
+
return config;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
export default withRichPush;
|
package/src/push/backend.ts
CHANGED
|
@@ -31,8 +31,16 @@ export const DEFAULT_PREFERENCES: NotificationPreferences = {
|
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
export interface PushBackend {
|
|
34
|
-
/**
|
|
35
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Keep this device's token against the signed-in person. `installationId`
|
|
36
|
+
* is this install's random id (kept on the phone across sign-ins): a
|
|
37
|
+
* backend that checks it moves a token another login holds only when the
|
|
38
|
+
* id matches (the same phone). `supabasePushBackend` (migration 0005)
|
|
39
|
+
* doesn't send it; genesis-notifications' backend does.
|
|
40
|
+
*/
|
|
41
|
+
register(token: string, platform: string, appVersion: string | null, installationId?: string): Promise<void>;
|
|
42
|
+
/** Send `installationId` to `register` (the block makes and keeps one). Default false. */
|
|
43
|
+
readonly usesInstallationId?: boolean;
|
|
36
44
|
/** Forget this device's token; works signed out (holding the token is the proof). */
|
|
37
45
|
forget(token: string): Promise<void>;
|
|
38
46
|
/** The signed-in person's preferences, or `null` before they have saved any. */
|
package/src/push/block.tsx
CHANGED
|
@@ -65,6 +65,20 @@ export interface PushChannel {
|
|
|
65
65
|
importance?: ChannelImportance;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/** A notification category and its buttons. */
|
|
69
|
+
export interface PushDeviceCategory {
|
|
70
|
+
identifier: string;
|
|
71
|
+
actions: readonly {
|
|
72
|
+
id: string;
|
|
73
|
+
title: string;
|
|
74
|
+
textInput?: { placeholder?: string; submitTitle?: string };
|
|
75
|
+
opensApp?: boolean;
|
|
76
|
+
destructive?: boolean;
|
|
77
|
+
requiresAuth?: boolean;
|
|
78
|
+
}[];
|
|
79
|
+
previewPlaceholder?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
// A type, not an interface, so it fits the slots contract's Record constraint.
|
|
69
83
|
export type PushSlots = {
|
|
70
84
|
/** The soft pre-prompt, shown after `usePushPrompt().offer()`. Default `PushPromptSheet`. */
|
|
@@ -86,6 +100,15 @@ export interface PushOptions {
|
|
|
86
100
|
categories?: readonly NotificationCategory[];
|
|
87
101
|
/** More Android channels. A `default` channel is always made. */
|
|
88
102
|
channels?: readonly PushChannel[];
|
|
103
|
+
/**
|
|
104
|
+
* Notification categories with action buttons (Reply, Mark as read),
|
|
105
|
+
* registered on the device when the block starts. A push names one in its
|
|
106
|
+
* `categoryId`; a tap on a button reaches `onResponse` (`notificationAction`
|
|
107
|
+
* reads it). genesis-notifications' registry makes these (`deviceCategories()`).
|
|
108
|
+
*/
|
|
109
|
+
deviceCategories?: readonly PushDeviceCategory[];
|
|
110
|
+
/** Makes this install's id, for a backend that uses one (`usesInstallationId`). Default `crypto.randomUUID`; pass expo-crypto's on Hermes. */
|
|
111
|
+
randomUUID?: () => string;
|
|
89
112
|
/** AsyncStorage: this device's token (to forget it after sign-out), the prompt's "Not now"s. */
|
|
90
113
|
storage?: PushStorageLike;
|
|
91
114
|
/** `AppState`: read the permission again on coming to the front (the person may have changed it in Settings). */
|
|
@@ -195,6 +218,31 @@ export function createPush(options: PushOptions): PushBlock {
|
|
|
195
218
|
return channelsMade;
|
|
196
219
|
};
|
|
197
220
|
|
|
221
|
+
let categoriesMade = false;
|
|
222
|
+
const registerDeviceCategories = async () => {
|
|
223
|
+
const set = notifications?.setNotificationCategoryAsync;
|
|
224
|
+
if (categoriesMade || !notifications || !set || !options.deviceCategories?.length) return;
|
|
225
|
+
categoriesMade = true;
|
|
226
|
+
for (const c of options.deviceCategories) {
|
|
227
|
+
try {
|
|
228
|
+
await set.call(
|
|
229
|
+
notifications,
|
|
230
|
+
c.identifier,
|
|
231
|
+
c.actions.map((a) => ({
|
|
232
|
+
identifier: a.id,
|
|
233
|
+
buttonTitle: a.title,
|
|
234
|
+
...(a.textInput ? { textInput: { placeholder: a.textInput.placeholder ?? "", submitButtonTitle: a.textInput.submitTitle ?? a.title } } : {}),
|
|
235
|
+
options: { opensAppToForeground: a.opensApp ?? false, isDestructive: a.destructive ?? false, isAuthenticationRequired: a.requiresAuth ?? false },
|
|
236
|
+
})),
|
|
237
|
+
c.previewPlaceholder ? { previewPlaceholder: c.previewPlaceholder } : undefined,
|
|
238
|
+
);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
categoriesMade = false;
|
|
241
|
+
report(onError, error, "categories");
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
198
246
|
const permission = createPermissionStore({ notifications, request: options.permissions, beforeRequest: ensureChannels, onError });
|
|
199
247
|
const registration = createRegistration({
|
|
200
248
|
notifications,
|
|
@@ -207,6 +255,7 @@ export function createPush(options: PushOptions): PushBlock {
|
|
|
207
255
|
ensureChannels,
|
|
208
256
|
isCurrent: (id) => userId === id,
|
|
209
257
|
now,
|
|
258
|
+
randomUUID: options.randomUUID,
|
|
210
259
|
onError,
|
|
211
260
|
});
|
|
212
261
|
const preferences = createPreferencesStore({ backend: options.backend, categories, onError });
|
|
@@ -334,9 +383,19 @@ export function createPush(options: PushOptions): PushBlock {
|
|
|
334
383
|
});
|
|
335
384
|
void registration.flush();
|
|
336
385
|
void permission.refresh();
|
|
337
|
-
|
|
386
|
+
void registerDeviceCategories();
|
|
387
|
+
// Fetching the Expo token fetches the device token, and expo-notifications tells this listener
|
|
388
|
+
// every time, changed or not: forcing a registration on each one re-registered in a loop. The
|
|
389
|
+
// same token again is ignored; the first one seen only confirms the registration (which
|
|
390
|
+
// registers when the token differs from the saved one); a different one is a rotation.
|
|
391
|
+
let deviceToken: unknown;
|
|
392
|
+
const tokenSubscription = notifications.addPushTokenListener((token) => {
|
|
393
|
+
const next = (token as { data?: unknown } | null)?.data;
|
|
394
|
+
const seen = deviceToken;
|
|
395
|
+
deviceToken = next;
|
|
396
|
+
if (!userId || next === seen) return;
|
|
338
397
|
// The OS rotated the device token: fetch the Expo token again and register it.
|
|
339
|
-
|
|
398
|
+
void registration.sync(userId, { force: seen !== undefined });
|
|
340
399
|
});
|
|
341
400
|
const appSubscription = options.appState?.addEventListener("change", (state) => {
|
|
342
401
|
if (state !== "active") return;
|
package/src/push/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ export {
|
|
|
28
28
|
useSuppressNotifications,
|
|
29
29
|
type PushBlock,
|
|
30
30
|
type PushChannel,
|
|
31
|
+
type PushDeviceCategory,
|
|
31
32
|
type PushOptions,
|
|
32
33
|
type PushRootProps,
|
|
33
34
|
type PushSlots,
|
|
@@ -84,12 +85,14 @@ export {
|
|
|
84
85
|
type PreferencesStatus,
|
|
85
86
|
type PreferencesStore,
|
|
86
87
|
} from "./preferences";
|
|
87
|
-
export {
|
|
88
|
+
export { richPushMessage, isRichPushUrl, RICH_PUSH_IMAGE_FIELDS, type RichPushData, type RichPushMessageLike } from "./payload";
|
|
89
|
+
export { pathForResponse, notificationData, notificationAction, type NotificationAction } from "./routing";
|
|
88
90
|
export { parseClock, formatClock, clockOf, dateAt, deviceTimeZone, minutesIn, isQuietAt } from "./quiet-hours";
|
|
89
91
|
export type {
|
|
90
92
|
ChannelInputLike,
|
|
91
93
|
NotificationBehaviorLike,
|
|
92
94
|
NotificationLike,
|
|
95
|
+
NotificationActionLike,
|
|
93
96
|
NotificationResponseLike,
|
|
94
97
|
NotificationsModuleLike,
|
|
95
98
|
PermissionRequestLike,
|
package/src/push/modules.ts
CHANGED
|
@@ -37,6 +37,16 @@ export interface NotificationLike {
|
|
|
37
37
|
export interface NotificationResponseLike {
|
|
38
38
|
notification: NotificationLike;
|
|
39
39
|
actionIdentifier: string;
|
|
40
|
+
/** What the person typed, for an action with a text input (Reply). */
|
|
41
|
+
userText?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `NotificationAction` (expo-notifications 58): one button of a notification category. */
|
|
45
|
+
export interface NotificationActionLike {
|
|
46
|
+
identifier: string;
|
|
47
|
+
buttonTitle: string;
|
|
48
|
+
textInput?: { placeholder: string; submitButtonTitle: string };
|
|
49
|
+
options?: { isAuthenticationRequired?: boolean; isDestructive?: boolean; opensAppToForeground?: boolean };
|
|
40
50
|
}
|
|
41
51
|
|
|
42
52
|
/** `NotificationBehavior` without the deprecated `shouldShowAlert`. */
|
|
@@ -73,6 +83,8 @@ export interface NotificationsModuleLike {
|
|
|
73
83
|
getLastNotificationResponse(): NotificationResponseLike | null;
|
|
74
84
|
clearLastNotificationResponse(): void;
|
|
75
85
|
setBadgeCountAsync(count: number): Promise<boolean>;
|
|
86
|
+
/** Registers a notification category (its action buttons). Optional: older fakes leave it out. */
|
|
87
|
+
setNotificationCategoryAsync?(identifier: string, actions: NotificationActionLike[], options?: { previewPlaceholder?: string }): Promise<unknown>;
|
|
76
88
|
getBadgeCountAsync(): Promise<number>;
|
|
77
89
|
/** `"expo.modules.notifications.actions.DEFAULT"`: the person tapped the notification itself. */
|
|
78
90
|
DEFAULT_ACTION_IDENTIFIER: string;
|
|
@@ -91,7 +103,7 @@ export interface PushAppStateLike {
|
|
|
91
103
|
}
|
|
92
104
|
|
|
93
105
|
/** Where a reported error came from. */
|
|
94
|
-
export type PushErrorSource = "permission" | "register" | "forget" | "preferences" | "badge" | "channels" | "routing" | "storage" | "setup";
|
|
106
|
+
export type PushErrorSource = "permission" | "register" | "forget" | "preferences" | "badge" | "channels" | "categories" | "routing" | "storage" | "setup";
|
|
95
107
|
|
|
96
108
|
export type PushOnError = (error: Error, source: PushErrorSource) => void;
|
|
97
109
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich push payloads: what a sender adds to an Expo push message so the iOS
|
|
3
|
+
* Notification Service Extension (`@marktiderman/genesis-native/rich-push-plugin`)
|
|
4
|
+
* runs on it. No React, no React Native, no Node APIs: a server, an edge
|
|
5
|
+
* function or Deno imports it from `@marktiderman/genesis-native/push-payload`.
|
|
6
|
+
*
|
|
7
|
+
* await expo.send([richPushMessage({ to, title, body, data: { url } }, { image })]);
|
|
8
|
+
*
|
|
9
|
+
* iOS runs the extension only when the push says `mutable-content: 1`, which
|
|
10
|
+
* Expo's push service sets from `mutableContent: true`. Without it the
|
|
11
|
+
* notification arrives as sent and the extension never sees it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The image fields the extension reads from `data`. */
|
|
15
|
+
export interface RichPushData {
|
|
16
|
+
/** An https image to attach (a photo in the message). Anything but https is dropped. */
|
|
17
|
+
image?: string | null | undefined;
|
|
18
|
+
/** The sender's photo, https. On a `messages` push it is the sender's avatar; on any other it is attached. */
|
|
19
|
+
senderAvatar?: string | null | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The part of an Expo push message this touches. */
|
|
23
|
+
export interface RichPushMessageLike {
|
|
24
|
+
data?: Record<string, unknown> | undefined;
|
|
25
|
+
mutableContent?: boolean | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const RICH_PUSH_IMAGE_FIELDS = ["image", "senderAvatar"] as const;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The extension downloads only https URLs (no credentials, at most 1024
|
|
32
|
+
* characters: APNs takes 4 KB in all); this is the same check. The plugin's
|
|
33
|
+
* `imageHosts`, when set, narrows it on the phone.
|
|
34
|
+
*/
|
|
35
|
+
export function isRichPushUrl(value: unknown): value is string {
|
|
36
|
+
if (typeof value !== "string" || value.length > 1024) return false;
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL(value);
|
|
39
|
+
return url.protocol === "https:" && url.hostname.length > 0 && !url.username && !url.password;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The message with `mutableContent: true` and the image fields in its `data`.
|
|
47
|
+
* A field given in `rich` replaces the one in `data`; one that isn't an https
|
|
48
|
+
* URL is left out (the extension would ignore it). `data`'s other fields are
|
|
49
|
+
* kept as they are.
|
|
50
|
+
*/
|
|
51
|
+
export function richPushMessage<M extends RichPushMessageLike>(
|
|
52
|
+
message: M,
|
|
53
|
+
rich: RichPushData = {},
|
|
54
|
+
): Omit<M, "mutableContent" | "data"> & { mutableContent: true; data: Record<string, unknown> } {
|
|
55
|
+
const data: Record<string, unknown> = { ...(message.data ?? {}) };
|
|
56
|
+
for (const key of RICH_PUSH_IMAGE_FIELDS) {
|
|
57
|
+
if (key in rich) data[key] = rich[key];
|
|
58
|
+
if (key in data && !isRichPushUrl(data[key])) delete data[key];
|
|
59
|
+
}
|
|
60
|
+
return { ...message, mutableContent: true, data };
|
|
61
|
+
}
|