@marktiderman/genesis-native 0.3.0 → 0.4.1

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.
@@ -0,0 +1,74 @@
1
+ import { ConfigPlugin } from 'expo/config-plugins';
2
+ import { ExpoConfig } from 'expo/config';
3
+
4
+ declare const COMMUNICATION_ENTITLEMENT = "com.apple.developer.usernotifications.communication";
5
+ declare const SEND_MESSAGE_INTENT = "INSendMessageIntent";
6
+ declare const DEFAULT_TARGET_NAME = "GenesisNotificationService";
7
+ declare const DEFAULT_BUNDLE_SUFFIX = "NotificationService";
8
+ interface RichPushPluginOptions {
9
+ /** The extension's Xcode target and folder under ios/. Default `GenesisNotificationService`. */
10
+ targetName?: string;
11
+ /** Appended to the app's bundle id with a dot. Default `NotificationService`. */
12
+ bundleIdSuffix?: string;
13
+ /**
14
+ * Communication Notifications (the sender's photo, Focus): the entitlement and
15
+ * `NSUserActivityTypes`. Default true; false keeps image attachments only.
16
+ */
17
+ communication?: boolean;
18
+ /** The extension's minimum iOS. Default: the app target's. */
19
+ deploymentTarget?: string;
20
+ /**
21
+ * The hosts images may be downloaded from (`cdn.example.com`, or `example.com`
22
+ * for it and its subdomains). Unset, any https host: a sender who puts a URL
23
+ * a person chose (their avatar) in a push lets that host see each delivery.
24
+ */
25
+ imageHosts?: string[];
26
+ }
27
+ /** The extension's bundle id: the app's plus the suffix. */
28
+ declare function extensionBundleId(appBundleId: string, suffix?: string): string;
29
+ /** The extension's entitlements (also what EAS provisions it with). */
30
+ declare function extensionEntitlements(communication: boolean): Record<string, unknown>;
31
+ /**
32
+ * The config-time part, visible in `expo config`: the app's entitlement and
33
+ * `NSUserActivityTypes`, and the extension for EAS. Fills gaps and appends;
34
+ * never removes what the app set.
35
+ */
36
+ declare function applyRichPushConfig(config: ExpoConfig, options?: RichPushPluginOptions): ExpoConfig;
37
+ /** The extension's Info.plist. Versions follow the app's (App Store Connect rejects a mismatch). */
38
+ declare function extensionInfoPlist(displayName: string, options?: {
39
+ communication?: boolean;
40
+ imageHosts?: string[];
41
+ }): string;
42
+ declare function entitlementsPlist(entitlements: Record<string, unknown>): string;
43
+ /** This package's root, found from this file (src/plugin in tests, dist when built). */
44
+ declare function packageRoot(from?: string): string;
45
+ /** The extension's Swift source, shipped in this package. */
46
+ declare function serviceSwiftPath(root?: string): string;
47
+ /** Writes the extension's folder under ios/: the Swift, its Info.plist and its entitlements. */
48
+ declare function writeExtensionFiles(iosRoot: string, options: {
49
+ targetName: string;
50
+ displayName: string;
51
+ communication: boolean;
52
+ imageHosts?: string[];
53
+ swiftSource?: string;
54
+ }): void;
55
+ /** The parts of the `xcode` package's project this uses. */
56
+ type XcodeProject = any;
57
+ /**
58
+ * Adds the extension target to the project (once): its group and files, the
59
+ * sources, frameworks and resources phases, its build settings, a dependency
60
+ * from the app and the app's phase that embeds it.
61
+ */
62
+ declare function addNotificationServiceTarget(project: XcodeProject, options: {
63
+ targetName: string;
64
+ bundleId: string;
65
+ deploymentTarget?: string;
66
+ developmentTeam?: string;
67
+ marketingVersion?: string;
68
+ buildNumber?: string;
69
+ }): {
70
+ added: boolean;
71
+ };
72
+ declare const withRichPush: ConfigPlugin<RichPushPluginOptions | void>;
73
+
74
+ export { COMMUNICATION_ENTITLEMENT, DEFAULT_BUNDLE_SUFFIX, DEFAULT_TARGET_NAME, type RichPushPluginOptions, SEND_MESSAGE_INTENT, addNotificationServiceTarget, applyRichPushConfig, withRichPush as default, entitlementsPlist, extensionBundleId, extensionEntitlements, extensionInfoPlist, packageRoot, serviceSwiftPath, writeExtensionFiles };
@@ -0,0 +1,209 @@
1
+ import { existsSync, readFileSync, mkdirSync, copyFileSync, writeFileSync } from 'fs';
2
+ import * as path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { withDangerousMod, withXcodeProject } from 'expo/config-plugins';
5
+
6
+ // src/plugin/rich-push.ts
7
+ var COMMUNICATION_ENTITLEMENT = "com.apple.developer.usernotifications.communication";
8
+ var SEND_MESSAGE_INTENT = "INSendMessageIntent";
9
+ var DEFAULT_TARGET_NAME = "GenesisNotificationService";
10
+ var DEFAULT_BUNDLE_SUFFIX = "NotificationService";
11
+ var SWIFT_FILE = "NotificationService.swift";
12
+ function resolve(options) {
13
+ const targetName = options?.targetName ?? DEFAULT_TARGET_NAME;
14
+ const bundleIdSuffix = options?.bundleIdSuffix ?? DEFAULT_BUNDLE_SUFFIX;
15
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(targetName)) throw new Error(`rich-push-plugin: targetName "${targetName}" must be letters, digits and _`);
16
+ 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`);
17
+ const imageHosts = (options?.imageHosts ?? []).map((h) => h.trim().toLowerCase());
18
+ 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`);
19
+ return { targetName, bundleIdSuffix, communication: options?.communication ?? true, deploymentTarget: options?.deploymentTarget, imageHosts };
20
+ }
21
+ function extensionBundleId(appBundleId, suffix = DEFAULT_BUNDLE_SUFFIX) {
22
+ return `${appBundleId}.${suffix}`;
23
+ }
24
+ function extensionEntitlements(communication) {
25
+ return communication ? { [COMMUNICATION_ENTITLEMENT]: true } : {};
26
+ }
27
+ function applyRichPushConfig(config, options) {
28
+ const o = resolve(options);
29
+ const bundleId = config.ios?.bundleIdentifier;
30
+ if (!bundleId) throw new Error("rich-push-plugin: ios.bundleIdentifier is required (the extension's id is built from it)");
31
+ const ios = { ...config.ios };
32
+ if (o.communication) {
33
+ ios.entitlements = { ...ios.entitlements, [COMMUNICATION_ENTITLEMENT]: true };
34
+ const types = Array.isArray(ios.infoPlist?.NSUserActivityTypes) ? ios.infoPlist.NSUserActivityTypes : [];
35
+ ios.infoPlist = { ...ios.infoPlist, NSUserActivityTypes: types.includes(SEND_MESSAGE_INTENT) ? types : [...types, SEND_MESSAGE_INTENT] };
36
+ }
37
+ const extra = { ...config.extra };
38
+ const eas = { ...extra.eas };
39
+ const build = { ...eas.build };
40
+ const experimental = { ...build.experimental };
41
+ const easIos = { ...experimental.ios };
42
+ const existing = Array.isArray(easIos.appExtensions) ? easIos.appExtensions : [];
43
+ const entry = { targetName: o.targetName, bundleIdentifier: extensionBundleId(bundleId, o.bundleIdSuffix), entitlements: extensionEntitlements(o.communication) };
44
+ easIos.appExtensions = [...existing.filter((e) => e?.targetName !== o.targetName), entry];
45
+ extra.eas = { ...eas, build: { ...build, experimental: { ...experimental, ios: easIos } } };
46
+ return { ...config, ios, extra };
47
+ }
48
+ function extensionInfoPlist(displayName, options = {}) {
49
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
50
+ return `<?xml version="1.0" encoding="UTF-8"?>
51
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
52
+ <plist version="1.0">
53
+ <dict>
54
+ <key>CFBundleDevelopmentRegion</key>
55
+ <string>$(DEVELOPMENT_LANGUAGE)</string>
56
+ <key>CFBundleDisplayName</key>
57
+ <string>${esc(displayName)}</string>
58
+ <key>CFBundleExecutable</key>
59
+ <string>$(EXECUTABLE_NAME)</string>
60
+ <key>CFBundleIdentifier</key>
61
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
62
+ <key>CFBundleInfoDictionaryVersion</key>
63
+ <string>6.0</string>
64
+ <key>CFBundleName</key>
65
+ <string>$(PRODUCT_NAME)</string>
66
+ <key>CFBundlePackageType</key>
67
+ <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
68
+ <key>CFBundleShortVersionString</key>
69
+ <string>$(MARKETING_VERSION)</string>
70
+ <key>CFBundleVersion</key>
71
+ <string>$(CURRENT_PROJECT_VERSION)</string>
72
+ <key>NSExtension</key>
73
+ <dict>
74
+ <key>NSExtensionPointIdentifier</key>
75
+ <string>com.apple.usernotifications.service</string>
76
+ <key>NSExtensionPrincipalClass</key>
77
+ <string>$(PRODUCT_MODULE_NAME).NotificationService</string>${options.communication ? `
78
+ <key>NSExtensionAttributes</key>
79
+ <dict>
80
+ <key>IntentsSupported</key>
81
+ <array>
82
+ <string>${SEND_MESSAGE_INTENT}</string>
83
+ </array>
84
+ </dict>` : ""}
85
+ </dict>${options.imageHosts?.length ? `
86
+ <key>GenesisRichPushImageHosts</key>
87
+ <array>
88
+ ${options.imageHosts.map((h) => ` <string>${esc(h)}</string>`).join("\n")}
89
+ </array>` : ""}
90
+ </dict>
91
+ </plist>
92
+ `;
93
+ }
94
+ function entitlementsPlist(entitlements) {
95
+ const body = Object.entries(entitlements).map(([k, v]) => ` <key>${k}</key>
96
+ <${v ? "true" : "false"}/>`).join("\n");
97
+ return `<?xml version="1.0" encoding="UTF-8"?>
98
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
99
+ <plist version="1.0">
100
+ <dict>
101
+ ${body}
102
+ </dict>
103
+ </plist>
104
+ `;
105
+ }
106
+ function packageRoot(from = path.dirname(fileURLToPath(import.meta.url))) {
107
+ let dir = from;
108
+ for (; ; ) {
109
+ const pkg = path.join(dir, "package.json");
110
+ if (existsSync(pkg) && JSON.parse(readFileSync(pkg, "utf8")).name === "@marktiderman/genesis-native") return dir;
111
+ const parent = path.dirname(dir);
112
+ if (parent === dir) throw new Error("rich-push-plugin: can't find @marktiderman/genesis-native's folder");
113
+ dir = parent;
114
+ }
115
+ }
116
+ function serviceSwiftPath(root = packageRoot()) {
117
+ return path.join(root, "ios", "notification-service", SWIFT_FILE);
118
+ }
119
+ function writeExtensionFiles(iosRoot, options) {
120
+ const dir = path.join(iosRoot, options.targetName);
121
+ mkdirSync(dir, { recursive: true });
122
+ copyFileSync(options.swiftSource ?? serviceSwiftPath(), path.join(dir, SWIFT_FILE));
123
+ writeFileSync(path.join(dir, "Info.plist"), extensionInfoPlist(options.displayName, { communication: options.communication, imageHosts: options.imageHosts ?? [] }));
124
+ writeFileSync(path.join(dir, `${options.targetName}.entitlements`), entitlementsPlist(extensionEntitlements(options.communication)));
125
+ }
126
+ var unquote = (v) => typeof v === "string" ? v.replace(/^"(.*)"$/, "$1") : v;
127
+ function appSetting(project, key) {
128
+ const app = project.getFirstTarget();
129
+ const listId = app?.firstTarget?.buildConfigurationList;
130
+ const lists = project.pbxXCConfigurationList();
131
+ const configs = project.pbxXCBuildConfigurationSection();
132
+ for (const ref of lists[listId]?.buildConfigurations ?? []) {
133
+ const value = configs[ref.value]?.buildSettings?.[key];
134
+ if (value !== void 0) return String(unquote(value));
135
+ }
136
+ return void 0;
137
+ }
138
+ function addNotificationServiceTarget(project, options) {
139
+ const { targetName } = options;
140
+ if (project.pbxTargetByName(targetName) || project.pbxTargetByName(`"${targetName}"`)) return { added: false };
141
+ const objects = project.hash.project.objects;
142
+ objects.PBXTargetDependency ??= {};
143
+ objects.PBXContainerItemProxy ??= {};
144
+ const deploymentTarget = options.deploymentTarget ?? appSetting(project, "IPHONEOS_DEPLOYMENT_TARGET") ?? "15.1";
145
+ const marketingVersion = options.marketingVersion ?? appSetting(project, "MARKETING_VERSION") ?? "1.0";
146
+ const buildNumber = options.buildNumber ?? appSetting(project, "CURRENT_PROJECT_VERSION") ?? "1";
147
+ const developmentTeam = options.developmentTeam ?? appSetting(project, "DEVELOPMENT_TEAM");
148
+ const group = project.addPbxGroup([SWIFT_FILE], targetName, targetName);
149
+ project.addFile("Info.plist", group.uuid, { lastKnownFileType: "text.plist.xml" });
150
+ project.addFile(`${targetName}.entitlements`, group.uuid, { lastKnownFileType: "text.plist.entitlements" });
151
+ const mainGroup = project.getFirstProject().firstProject.mainGroup;
152
+ project.addToPbxGroup(group.uuid, mainGroup);
153
+ const target = project.addTarget(targetName, "app_extension", targetName, options.bundleId);
154
+ project.addBuildPhase([SWIFT_FILE], "PBXSourcesBuildPhase", "Sources", target.uuid);
155
+ project.addBuildPhase([], "PBXFrameworksBuildPhase", "Frameworks", target.uuid);
156
+ project.addBuildPhase([], "PBXResourcesBuildPhase", "Resources", target.uuid);
157
+ const configs = project.pbxXCBuildConfigurationSection();
158
+ const listId = target.pbxNativeTarget.buildConfigurationList;
159
+ for (const ref of project.pbxXCConfigurationList()[listId].buildConfigurations) {
160
+ const settings = configs[ref.value].buildSettings;
161
+ Object.assign(settings, {
162
+ INFOPLIST_FILE: `${targetName}/Info.plist`,
163
+ CODE_SIGN_ENTITLEMENTS: `${targetName}/${targetName}.entitlements`,
164
+ CODE_SIGN_STYLE: "Automatic",
165
+ IPHONEOS_DEPLOYMENT_TARGET: deploymentTarget,
166
+ TARGETED_DEVICE_FAMILY: '"1,2"',
167
+ SWIFT_VERSION: "5.0",
168
+ MARKETING_VERSION: marketingVersion,
169
+ CURRENT_PROJECT_VERSION: buildNumber,
170
+ GENERATE_INFOPLIST_FILE: "NO",
171
+ PRODUCT_BUNDLE_IDENTIFIER: `"${options.bundleId}"`,
172
+ SDKROOT: "iphoneos",
173
+ CLANG_ENABLE_MODULES: "YES",
174
+ SWIFT_EMIT_LOC_STRINGS: "YES",
175
+ ...developmentTeam ? { DEVELOPMENT_TEAM: developmentTeam } : {}
176
+ });
177
+ }
178
+ if (developmentTeam) project.addTargetAttribute("DevelopmentTeam", developmentTeam, target);
179
+ return { added: true };
180
+ }
181
+ var withRichPush = (config, options) => {
182
+ const o = resolve(options ?? void 0);
183
+ config = applyRichPushConfig(config, o);
184
+ const bundleId = extensionBundleId(config.ios.bundleIdentifier, o.bundleIdSuffix);
185
+ config = withDangerousMod(config, [
186
+ "ios",
187
+ (c) => {
188
+ writeExtensionFiles(c.modRequest.platformProjectRoot, { targetName: o.targetName, displayName: c.name, communication: o.communication, imageHosts: o.imageHosts });
189
+ return c;
190
+ }
191
+ ]);
192
+ config = withXcodeProject(config, (c) => {
193
+ addNotificationServiceTarget(c.modResults, {
194
+ targetName: o.targetName,
195
+ bundleId,
196
+ ...o.deploymentTarget ? { deploymentTarget: o.deploymentTarget } : {},
197
+ ...c.ios?.appleTeamId ? { developmentTeam: c.ios.appleTeamId } : {},
198
+ ...c.version ? { marketingVersion: c.version } : {},
199
+ ...c.ios?.buildNumber ? { buildNumber: c.ios.buildNumber } : {}
200
+ });
201
+ return c;
202
+ });
203
+ return config;
204
+ };
205
+ var rich_push_default = withRichPush;
206
+
207
+ export { COMMUNICATION_ENTITLEMENT, DEFAULT_BUNDLE_SUFFIX, DEFAULT_TARGET_NAME, SEND_MESSAGE_INTENT, addNotificationServiceTarget, applyRichPushConfig, rich_push_default as default, entitlementsPlist, extensionBundleId, extensionEntitlements, extensionInfoPlist, packageRoot, serviceSwiftPath, writeExtensionFiles };
208
+ //# sourceMappingURL=rich-push-plugin.js.map
209
+ //# sourceMappingURL=rich-push-plugin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/plugin/rich-push.ts"],"names":[],"mappings":"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","file":"rich-push-plugin.js","sourcesContent":["/**\n * `@marktiderman/genesis-native/rich-push-plugin`: the Expo config plugin for\n * iOS rich push. It adds a Notification Service Extension target (the Swift in\n * `ios/notification-service/`) that downloads `data.image` / `data.senderAvatar`\n * and turns `messages` pushes into Communication Notifications (the sender's\n * photo, Focus treats them as messages), and it sets what they need:\n *\n * - the app: `com.apple.developer.usernotifications.communication` and\n * `NSUserActivityTypes: [INSendMessageIntent]`;\n * - the extension: its own bundle id (`<app>.NotificationService`), Info.plist\n * and entitlements, embedded in the app;\n * - EAS: `extra.eas.build.experimental.ios.appExtensions`, so EAS Build makes\n * the extension's provisioning profile with the app's.\n *\n * plugins: [..., \"@marktiderman/genesis-cli\", \"@marktiderman/genesis-native/rich-push-plugin\"]\n *\n * List it after any plugin that changes the bundle id (the Genesis plugin's\n * APP_VARIANT suffix): the extension's id is the app's plus a suffix, read\n * when this plugin runs. Running it twice changes nothing.\n */\nimport { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport * as path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n// Expo's own copy (the app's `expo`), so the mods run on the same config-plugins as prebuild.\nimport { withDangerousMod, withXcodeProject, type ConfigPlugin } from \"expo/config-plugins\";\nimport type { ExpoConfig } from \"expo/config\";\n\nexport const COMMUNICATION_ENTITLEMENT = \"com.apple.developer.usernotifications.communication\";\nexport const SEND_MESSAGE_INTENT = \"INSendMessageIntent\";\nexport const DEFAULT_TARGET_NAME = \"GenesisNotificationService\";\nexport const DEFAULT_BUNDLE_SUFFIX = \"NotificationService\";\nconst SWIFT_FILE = \"NotificationService.swift\";\n\nexport interface RichPushPluginOptions {\n /** The extension's Xcode target and folder under ios/. Default `GenesisNotificationService`. */\n targetName?: string;\n /** Appended to the app's bundle id with a dot. Default `NotificationService`. */\n bundleIdSuffix?: string;\n /**\n * Communication Notifications (the sender's photo, Focus): the entitlement and\n * `NSUserActivityTypes`. Default true; false keeps image attachments only.\n */\n communication?: boolean;\n /** The extension's minimum iOS. Default: the app target's. */\n deploymentTarget?: string;\n /**\n * The hosts images may be downloaded from (`cdn.example.com`, or `example.com`\n * for it and its subdomains). Unset, any https host: a sender who puts a URL\n * a person chose (their avatar) in a push lets that host see each delivery.\n */\n imageHosts?: string[];\n}\n\ninterface Resolved {\n targetName: string;\n bundleIdSuffix: string;\n communication: boolean;\n deploymentTarget: string | undefined;\n imageHosts: string[];\n}\n\nfunction resolve(options: RichPushPluginOptions | undefined): Resolved {\n const targetName = options?.targetName ?? DEFAULT_TARGET_NAME;\n const bundleIdSuffix = options?.bundleIdSuffix ?? DEFAULT_BUNDLE_SUFFIX;\n if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(targetName)) throw new Error(`rich-push-plugin: targetName \"${targetName}\" must be letters, digits and _`);\n 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`);\n const imageHosts = (options?.imageHosts ?? []).map((h) => h.trim().toLowerCase());\n 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`);\n return { targetName, bundleIdSuffix, communication: options?.communication ?? true, deploymentTarget: options?.deploymentTarget, imageHosts };\n}\n\n/** The extension's bundle id: the app's plus the suffix. */\nexport function extensionBundleId(appBundleId: string, suffix = DEFAULT_BUNDLE_SUFFIX): string {\n return `${appBundleId}.${suffix}`;\n}\n\n/** The extension's entitlements (also what EAS provisions it with). */\nexport function extensionEntitlements(communication: boolean): Record<string, unknown> {\n return communication ? { [COMMUNICATION_ENTITLEMENT]: true } : {};\n}\n\n/**\n * The config-time part, visible in `expo config`: the app's entitlement and\n * `NSUserActivityTypes`, and the extension for EAS. Fills gaps and appends;\n * never removes what the app set.\n */\nexport function applyRichPushConfig(config: ExpoConfig, options?: RichPushPluginOptions): ExpoConfig {\n const o = resolve(options);\n const bundleId = config.ios?.bundleIdentifier;\n if (!bundleId) throw new Error(\"rich-push-plugin: ios.bundleIdentifier is required (the extension's id is built from it)\");\n const ios = { ...config.ios };\n if (o.communication) {\n ios.entitlements = { ...ios.entitlements, [COMMUNICATION_ENTITLEMENT]: true };\n const types = Array.isArray(ios.infoPlist?.NSUserActivityTypes) ? (ios.infoPlist!.NSUserActivityTypes as unknown[]) : [];\n ios.infoPlist = { ...ios.infoPlist, NSUserActivityTypes: types.includes(SEND_MESSAGE_INTENT) ? types : [...types, SEND_MESSAGE_INTENT] };\n }\n const extra = { ...config.extra };\n const eas = { ...(extra.eas as Record<string, unknown> | undefined) };\n const build = { ...(eas.build as Record<string, unknown> | undefined) };\n const experimental = { ...(build.experimental as Record<string, unknown> | undefined) };\n const easIos = { ...(experimental.ios as Record<string, unknown> | undefined) };\n const existing = Array.isArray(easIos.appExtensions) ? (easIos.appExtensions as { targetName?: string }[]) : [];\n const entry = { targetName: o.targetName, bundleIdentifier: extensionBundleId(bundleId, o.bundleIdSuffix), entitlements: extensionEntitlements(o.communication) };\n easIos.appExtensions = [...existing.filter((e) => e?.targetName !== o.targetName), entry];\n extra.eas = { ...eas, build: { ...build, experimental: { ...experimental, ios: easIos } } };\n return { ...config, ios, extra };\n}\n\n/** The extension's Info.plist. Versions follow the app's (App Store Connect rejects a mismatch). */\nexport function extensionInfoPlist(displayName: string, options: { communication?: boolean; imageHosts?: string[] } = {}): string {\n const esc = (s: string) => s.replace(/&/g, \"&amp;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\");\n return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n <key>CFBundleDevelopmentRegion</key>\n <string>$(DEVELOPMENT_LANGUAGE)</string>\n <key>CFBundleDisplayName</key>\n <string>${esc(displayName)}</string>\n <key>CFBundleExecutable</key>\n <string>$(EXECUTABLE_NAME)</string>\n <key>CFBundleIdentifier</key>\n <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>\n <key>CFBundleInfoDictionaryVersion</key>\n <string>6.0</string>\n <key>CFBundleName</key>\n <string>$(PRODUCT_NAME)</string>\n <key>CFBundlePackageType</key>\n <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>\n <key>CFBundleShortVersionString</key>\n <string>$(MARKETING_VERSION)</string>\n <key>CFBundleVersion</key>\n <string>$(CURRENT_PROJECT_VERSION)</string>\n <key>NSExtension</key>\n <dict>\n <key>NSExtensionPointIdentifier</key>\n <string>com.apple.usernotifications.service</string>\n <key>NSExtensionPrincipalClass</key>\n <string>$(PRODUCT_MODULE_NAME).NotificationService</string>${\n options.communication\n ? `\n <key>NSExtensionAttributes</key>\n <dict>\n <key>IntentsSupported</key>\n <array>\n <string>${SEND_MESSAGE_INTENT}</string>\n </array>\n </dict>`\n : \"\"\n }\n </dict>${\n options.imageHosts?.length\n ? `\n <key>GenesisRichPushImageHosts</key>\n <array>\n${options.imageHosts.map((h) => ` <string>${esc(h)}</string>`).join(\"\\n\")}\n </array>`\n : \"\"\n }\n</dict>\n</plist>\n`;\n}\n\nexport function entitlementsPlist(entitlements: Record<string, unknown>): string {\n const body = Object.entries(entitlements)\n .map(([k, v]) => ` <key>${k}</key>\\n <${v ? \"true\" : \"false\"}/>`)\n .join(\"\\n\");\n return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n${body}\n</dict>\n</plist>\n`;\n}\n\n/** This package's root, found from this file (src/plugin in tests, dist when built). */\nexport function packageRoot(from = path.dirname(fileURLToPath(import.meta.url))): string {\n let dir = from;\n for (;;) {\n const pkg = path.join(dir, \"package.json\");\n if (existsSync(pkg) && (JSON.parse(readFileSync(pkg, \"utf8\")) as { name?: string }).name === \"@marktiderman/genesis-native\") return dir;\n const parent = path.dirname(dir);\n if (parent === dir) throw new Error(\"rich-push-plugin: can't find @marktiderman/genesis-native's folder\");\n dir = parent;\n }\n}\n\n/** The extension's Swift source, shipped in this package. */\nexport function serviceSwiftPath(root = packageRoot()): string {\n return path.join(root, \"ios\", \"notification-service\", SWIFT_FILE);\n}\n\n/** Writes the extension's folder under ios/: the Swift, its Info.plist and its entitlements. */\nexport function writeExtensionFiles(\n iosRoot: string,\n options: { targetName: string; displayName: string; communication: boolean; imageHosts?: string[]; swiftSource?: string },\n): void {\n const dir = path.join(iosRoot, options.targetName);\n mkdirSync(dir, { recursive: true });\n copyFileSync(options.swiftSource ?? serviceSwiftPath(), path.join(dir, SWIFT_FILE));\n writeFileSync(path.join(dir, \"Info.plist\"), extensionInfoPlist(options.displayName, { communication: options.communication, imageHosts: options.imageHosts ?? [] }));\n writeFileSync(path.join(dir, `${options.targetName}.entitlements`), entitlementsPlist(extensionEntitlements(options.communication)));\n}\n\n/** The parts of the `xcode` package's project this uses. */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ntype XcodeProject = any;\n\nconst unquote = (v: unknown) => (typeof v === \"string\" ? v.replace(/^\"(.*)\"$/, \"$1\") : v);\n\n/** The app target's build setting (its first configuration that has it). */\nfunction appSetting(project: XcodeProject, key: string): string | undefined {\n const app = project.getFirstTarget();\n const listId = app?.firstTarget?.buildConfigurationList;\n const lists = project.pbxXCConfigurationList();\n const configs = project.pbxXCBuildConfigurationSection();\n for (const ref of lists[listId]?.buildConfigurations ?? []) {\n const value = configs[ref.value]?.buildSettings?.[key];\n if (value !== undefined) return String(unquote(value));\n }\n return undefined;\n}\n\n/**\n * Adds the extension target to the project (once): its group and files, the\n * sources, frameworks and resources phases, its build settings, a dependency\n * from the app and the app's phase that embeds it.\n */\nexport function addNotificationServiceTarget(\n project: XcodeProject,\n options: { targetName: string; bundleId: string; deploymentTarget?: string; developmentTeam?: string; marketingVersion?: string; buildNumber?: string },\n): { added: boolean } {\n const { targetName } = options;\n if (project.pbxTargetByName(targetName) || project.pbxTargetByName(`\"${targetName}\"`)) return { added: false };\n\n const objects = project.hash.project.objects;\n // The xcode package assumes these sections exist; Expo's template has none.\n objects.PBXTargetDependency ??= {};\n objects.PBXContainerItemProxy ??= {};\n\n const deploymentTarget = options.deploymentTarget ?? appSetting(project, \"IPHONEOS_DEPLOYMENT_TARGET\") ?? \"15.1\";\n const marketingVersion = options.marketingVersion ?? appSetting(project, \"MARKETING_VERSION\") ?? \"1.0\";\n const buildNumber = options.buildNumber ?? appSetting(project, \"CURRENT_PROJECT_VERSION\") ?? \"1\";\n const developmentTeam = options.developmentTeam ?? appSetting(project, \"DEVELOPMENT_TEAM\");\n\n // The Swift is compiled; the Info.plist and entitlements are build settings, so they are in the group only.\n const group = project.addPbxGroup([SWIFT_FILE], targetName, targetName);\n project.addFile(\"Info.plist\", group.uuid, { lastKnownFileType: \"text.plist.xml\" });\n project.addFile(`${targetName}.entitlements`, group.uuid, { lastKnownFileType: \"text.plist.entitlements\" });\n const mainGroup = project.getFirstProject().firstProject.mainGroup;\n project.addToPbxGroup(group.uuid, mainGroup);\n\n const target = project.addTarget(targetName, \"app_extension\", targetName, options.bundleId);\n project.addBuildPhase([SWIFT_FILE], \"PBXSourcesBuildPhase\", \"Sources\", target.uuid);\n project.addBuildPhase([], \"PBXFrameworksBuildPhase\", \"Frameworks\", target.uuid);\n project.addBuildPhase([], \"PBXResourcesBuildPhase\", \"Resources\", target.uuid);\n\n const configs = project.pbxXCBuildConfigurationSection();\n const listId = target.pbxNativeTarget.buildConfigurationList;\n for (const ref of project.pbxXCConfigurationList()[listId].buildConfigurations) {\n const settings = configs[ref.value].buildSettings;\n Object.assign(settings, {\n INFOPLIST_FILE: `${targetName}/Info.plist`,\n CODE_SIGN_ENTITLEMENTS: `${targetName}/${targetName}.entitlements`,\n CODE_SIGN_STYLE: \"Automatic\",\n IPHONEOS_DEPLOYMENT_TARGET: deploymentTarget,\n TARGETED_DEVICE_FAMILY: '\"1,2\"',\n SWIFT_VERSION: \"5.0\",\n MARKETING_VERSION: marketingVersion,\n CURRENT_PROJECT_VERSION: buildNumber,\n GENERATE_INFOPLIST_FILE: \"NO\",\n PRODUCT_BUNDLE_IDENTIFIER: `\"${options.bundleId}\"`,\n SDKROOT: \"iphoneos\",\n CLANG_ENABLE_MODULES: \"YES\",\n SWIFT_EMIT_LOC_STRINGS: \"YES\",\n ...(developmentTeam ? { DEVELOPMENT_TEAM: developmentTeam } : {}),\n });\n }\n if (developmentTeam) project.addTargetAttribute(\"DevelopmentTeam\", developmentTeam, target);\n return { added: true };\n}\n\nconst withRichPush: ConfigPlugin<RichPushPluginOptions | void> = (config, options) => {\n const o = resolve(options ?? undefined);\n config = applyRichPushConfig(config, o);\n const bundleId = extensionBundleId(config.ios!.bundleIdentifier!, o.bundleIdSuffix);\n\n config = withDangerousMod(config, [\n \"ios\",\n (c) => {\n writeExtensionFiles(c.modRequest.platformProjectRoot, { targetName: o.targetName, displayName: c.name, communication: o.communication, imageHosts: o.imageHosts });\n return c;\n },\n ]);\n config = withXcodeProject(config, (c) => {\n addNotificationServiceTarget(c.modResults, {\n targetName: o.targetName,\n bundleId,\n ...(o.deploymentTarget ? { deploymentTarget: o.deploymentTarget } : {}),\n ...(c.ios?.appleTeamId ? { developmentTeam: c.ios.appleTeamId } : {}),\n ...(c.version ? { marketingVersion: c.version } : {}),\n ...(c.ios?.buildNumber ? { buildNumber: c.ios.buildNumber } : {}),\n });\n return c;\n });\n return config;\n};\n\nexport default withRichPush;\n"]}
package/dist/updates.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { b3 as ApplicationLike, b4 as CLOCK_SKEW_MS, b5 as ChannelSwitchResult, b6 as ChannelSwitcher, b7 as ChannelSwitcherOptions, b8 as EvaluatePolicyOptions, b9 as ForceUpgradeProps, ba as ForceUpgradeScreen, bb as MaintenanceProps, bc as MaintenanceScreen, bd as PolicyClientLike, be as PolicyOrigin, bf as PolicyState, bg as PolicyStore, bh as PolicyStoreOptions, bi as ReleaseNote, bj as ReloadGuard, bk as SupabasePolicySourceOptions, bl as UPDATES_BLOCK_ID, bm as UpdateBanner, bn as UpdateBannerProps, bo as UpdateCheckState, bp as UpdateCheckStatus, bq as UpdateChecker, br as UpdateCheckerOptions, bs as UpdatePlatform, bt as UpdatePolicy, bu as UpdatePolicySource, bv as UpdateVerdict, bw as UpdatesAppStateLike, U as UpdatesBlock, bx as UpdatesErrorSource, by as UpdatesModuleLike, bz as UpdatesOnError, bA as UpdatesOptions, bB as UpdatesProvider, bC as UpdatesRoot, bD as UpdatesRootProps, bE as UpdatesSlots, bF as UpdatesStorageLike, V as VersionSignature, bG as VersionSignatureInput, bH as WEEK_MS, bI as WhatsNewOptions, bJ as WhatsNewProps, bK as WhatsNewSheet, bL as WhatsNewState, bM as WhatsNewStore, bN as createChannelSwitcher, bO as createPolicyStore, bP as createReloadGuard, bQ as createUpdateChecker, bR as createUpdates, bS as createWhatsNewStore, bT as evaluatePolicy, bU as notesSince, bV as sanitizePolicy, bW as staticPolicySource, bX as supabasePolicySource, bY as useBlockReload, bZ as useCanReload, b_ as useForceUpgrade, b$ as useMaintenance, c0 as useUpdateBanner, c1 as useUpdateCheck, c2 as useUpdatePolicy, c3 as useUpdateVerdict, c4 as useUpdates, c5 as useVersionSignature, c6 as useWhatsNew, c7 as versionSignature } from './blocks-a1YQ1Elf.js';
1
+ export { b5 as ApplicationLike, b6 as CLOCK_SKEW_MS, b7 as ChannelSwitchResult, b8 as ChannelSwitcher, b9 as ChannelSwitcherOptions, ba as EvaluatePolicyOptions, bb as ForceUpgradeProps, bc as ForceUpgradeScreen, bd as MaintenanceProps, be as MaintenanceScreen, bf as PolicyClientLike, bg as PolicyOrigin, bh as PolicyState, bi as PolicyStore, bj as PolicyStoreOptions, bk as ReleaseNote, bl as ReloadGuard, bm as SupabasePolicySourceOptions, bn as UPDATES_BLOCK_ID, bo as UpdateBanner, bp as UpdateBannerProps, bq as UpdateCheckState, br as UpdateCheckStatus, bs as UpdateChecker, bt as UpdateCheckerOptions, bu as UpdatePlatform, bv as UpdatePolicy, bw as UpdatePolicySource, bx as UpdateVerdict, by as UpdatesAppStateLike, U as UpdatesBlock, bz as UpdatesErrorSource, bA as UpdatesModuleLike, bB as UpdatesOnError, bC as UpdatesOptions, bD as UpdatesProvider, bE as UpdatesRoot, bF as UpdatesRootProps, bG as UpdatesSlots, bH as UpdatesStorageLike, V as VersionSignature, bI as VersionSignatureInput, bJ as WEEK_MS, bK as WhatsNewOptions, bL as WhatsNewProps, bM as WhatsNewSheet, bN as WhatsNewState, bO as WhatsNewStore, bP as createChannelSwitcher, bQ as createPolicyStore, bR as createReloadGuard, bS as createUpdateChecker, bT as createUpdates, bU as createWhatsNewStore, bV as evaluatePolicy, bW as notesSince, bX as sanitizePolicy, bY as staticPolicySource, bZ as supabasePolicySource, b_ as useBlockReload, b$ as useCanReload, c0 as useForceUpgrade, c1 as useMaintenance, c2 as useUpdateBanner, c3 as useUpdateCheck, c4 as useUpdatePolicy, c5 as useUpdateVerdict, c6 as useUpdates, c7 as useVersionSignature, c8 as useWhatsNew, c9 as versionSignature } from './blocks-C2RDpvaN.js';
2
2
  import 'react';
3
3
  import '@marktiderman/genesis-core/slots';
4
4
  import '@marktiderman/genesis-features/blocks';
@@ -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
+ }