@craft-native/ios 0.0.451 → 0.0.467

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 CHANGED
@@ -77,6 +77,15 @@ use their own temporary namespace and are erased on the next native launch after
77
77
  an interruption or process termination, including the earlier recorder's M4A files.
78
78
  Picked files and downloads retain their separate lifetime.
79
79
 
80
+ For cancellation diagnostics, the native `voice-recording` log records the
81
+ removal queue delay, removal duration, ownership check and whether the path still
82
+ exists, without including the path or audio content. The blocked-encoder unit
83
+ test retains the same receipt alongside each file-absence predicate observation
84
+ in its XCResult. Its original two-second cleanup bound still applies before the
85
+ encoder is released. These observations distinguish delayed removal from a
86
+ missed observation; a later passing run alone does not explain issue #448's
87
+ original timeout.
88
+
80
89
  ### Device Access
81
90
 
82
91
  - **Camera & Gallery** - Take photos or pick from gallery
@@ -1423,3 +1432,57 @@ to the next family rather than silently replacing a requested mono font with san
1423
1432
  The simulator driver requires a successful HTTP response from each control-server `exec` hook. A failed or unavailable hook fails XCTest and stops the journey before its next command. A successful hook may return a message or HTTP 204 with no body. Legacy shell responses (`exit <code> ...`) must report exactly `0`; nonzero or malformed exit statuses and the legacy `no such hook` response fail the journey.
1424
1433
 
1425
1434
  `longpressid` establishes keyboard focus before holding a native text field or text view, so the gesture opens its edit menu. Typing into an already focused control preserves its current selection. Other elements keep their ordinary long-press behavior. The real native text-editing regression runs with `CRAFT_DRIVER_SIMULATOR=<owned-uuid> CRAFT_DRIVER_TEXT_RESULT=<retained-path>.xcresult bun packages/ios/scripts/test-driver-text.ts`; it uses the existing selection and typing commands to edit and clear Unicode values.
1435
+
1436
+ The delayed-focus fixture observes the complete value with the existing bounded
1437
+ `valueid` check after one tap and one typing call. Cold runs reproduced empty
1438
+ and intermediate `"Gr"` accessibility values while native callbacks completed
1439
+ `"Grüße"`; an immediate equality assertion raced that propagation. The fixture
1440
+ also verifies that a focused field whose delegate rejects input fails the value
1441
+ check, so waiting cannot turn missing text into a pass.
1442
+
1443
+ It retains `native-focus-after-type` and `native-rejected-input` XCTest attachments
1444
+ and prints `FOCUS_EVIDENCE` with the first observed value, native focus requests,
1445
+ keyboard notifications, replacement ranges, and editing callbacks. The event
1446
+ recorder stays in memory and is read on demand, without publishing accessibility
1447
+ updates during typing. These receipts distinguish focus, input delivery, and
1448
+ value observation; they do not establish the cause of the original external
1449
+ browser checkout failure.
1450
+
1451
+ ### Constraining native path parameters
1452
+
1453
+ `nativeScreenConstraints` keeps a parameter route from claiming paths owned by
1454
+ website pages. Rules use the exact `nativeScreens` route key and named parameter:
1455
+
1456
+ ```ts
1457
+ nativeScreens: { '/:username': 'Profile', '/me': 'Account' },
1458
+ nativeScreenConstraints: {
1459
+ '/:username': {
1460
+ username: { pattern: '[a-z0-9]+(?:-[a-z0-9]+)*', exclude: ['terms', 'privacy'], normalize: 'lowercase' },
1461
+ },
1462
+ },
1463
+ ```
1464
+
1465
+ Patterns match the complete decoded value case-sensitively. Optional
1466
+ `normalize: 'lowercase'` lowercases decoded parameters and excluded values before
1467
+ validation, independently of the device locale; matched parameters carry that
1468
+ normalized value. This avoids the incompatible Unicode case-folding behavior of
1469
+ regex engines. Use regular expressions supported by JavaScript, Foundation and
1470
+ Kotlin; invalid patterns fail closed. Constraints run before query parameters are added, so a query
1471
+ cannot replace a path parameter. Static routes still win. Unmatched paths retain
1472
+ the shell's normal website fallback. Invalid configuration is rejected when the
1473
+ project is generated. The browser-safe `craft-native/mobile` entry exports
1474
+ `matchNativePath(screens, path, constraints)` for the app's own resolver.
1475
+
1476
+ ### Boolean tap failure evidence
1477
+
1478
+ The shared simulator driver's `tapid` emits a `BOOLEAN_TAP` JSON record for
1479
+ boolean-valued controls. It includes the enabled state and value before/after
1480
+ the single gesture, plus its initial frame and the flip verdict. The existing
1481
+ 1.5-second observation deadline remains unchanged; disabled controls still fail
1482
+ instead of being retried or silently accepted.
1483
+
1484
+ This matters for #449: the original HQ agreement source disables its switches
1485
+ while policy loading is pending, while the journey checks presence/scrolling.
1486
+ That is a hypothesis to verify against pre-tap state, not an established cause of
1487
+ the original dropped tap. The record distinguishes a disabled target from an
1488
+ enabled control whose value failed to change without altering either scenario.
package/dist/cli.js CHANGED
@@ -5,6 +5,214 @@
5
5
  import { parseArgs } from "util";
6
6
  import { resolve as resolve3 } from "path";
7
7
 
8
+ // ../typescript/src/native-notifications.ts
9
+ function nativeNotificationProblems(categories, nativeApp, renderer) {
10
+ if (categories === undefined)
11
+ return [];
12
+ if (!Array.isArray(categories))
13
+ return ["notificationCategories must be an array."];
14
+ const problems = [];
15
+ const categoryIds = new Set;
16
+ const object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
17
+ const text = (value) => typeof value === "string" && value.trim().length > 0;
18
+ for (const [index, category] of categories.entries()) {
19
+ const field = `notificationCategories[${index}]`;
20
+ if (!object(category) || !text(category.id)) {
21
+ problems.push(`${field} needs a nonempty id.`);
22
+ continue;
23
+ }
24
+ if (categoryIds.has(category.id))
25
+ problems.push(`${field}.id must be unique.`);
26
+ categoryIds.add(category.id);
27
+ if (!Array.isArray(category.actions) || category.actions.length < 1 || category.actions.length > 3) {
28
+ problems.push(`${field}.actions must contain 1\u20133 actions for both platforms.`);
29
+ continue;
30
+ }
31
+ const actionIds = new Set;
32
+ for (const [position, action] of category.actions.entries()) {
33
+ const path = `${field}.actions[${position}]`;
34
+ if (!object(action) || !text(action.id) || !text(action.title)) {
35
+ problems.push(`${path} needs a nonempty id and title.`);
36
+ continue;
37
+ }
38
+ if (action.id.startsWith("com.apple.") || actionIds.has(action.id))
39
+ problems.push(`${path}.id must be unique and must not use Apple's reserved prefix.`);
40
+ actionIds.add(action.id);
41
+ for (const flag of ["destructive", "foreground", "authenticationRequired"]) {
42
+ if (action[flag] !== undefined && typeof action[flag] !== "boolean")
43
+ problems.push(`${path}.${flag} must be a boolean.`);
44
+ }
45
+ if (!nativeApp?.trim() && (action.foreground !== true || renderer === "native"))
46
+ problems.push(`${path} requires nativeApp to receive native/background action events.`);
47
+ if (action.textInput !== undefined && (!object(action.textInput) || !text(action.textInput.buttonTitle) || action.textInput.placeholder !== undefined && typeof action.textInput.placeholder !== "string")) {
48
+ problems.push(`${path}.textInput needs a buttonTitle and an optional string placeholder.`);
49
+ }
50
+ }
51
+ }
52
+ return problems;
53
+ }
54
+
55
+ // ../typescript/src/native-routing.ts
56
+ function applyNativeParameterConstraints(params, rules) {
57
+ if (rules === undefined)
58
+ return true;
59
+ if (!rules || typeof rules !== "object" || Array.isArray(rules))
60
+ return false;
61
+ for (const [name, rule] of Object.entries(rules)) {
62
+ let value = params[name];
63
+ if (typeof value !== "string" || !rule || typeof rule !== "object" || Array.isArray(rule))
64
+ return false;
65
+ if (Object.keys(rule).some((key) => !["pattern", "exclude", "normalize"].includes(key)))
66
+ return false;
67
+ if (rule.normalize !== undefined && rule.normalize !== "lowercase")
68
+ return false;
69
+ if (rule.normalize === "lowercase")
70
+ value = value.toLowerCase();
71
+ if (rule.exclude !== undefined) {
72
+ if (!Array.isArray(rule.exclude) || rule.exclude.some((item) => typeof item !== "string"))
73
+ return false;
74
+ if (rule.exclude.some((item) => (rule.normalize === "lowercase" ? item.toLowerCase() : item) === value))
75
+ return false;
76
+ }
77
+ if (rule.pattern !== undefined) {
78
+ if (typeof rule.pattern !== "string")
79
+ return false;
80
+ try {
81
+ const matched = new RegExp(`^(?:${rule.pattern})$`).exec(value);
82
+ if (!matched || matched[0] !== value)
83
+ return false;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+ params[name] = value;
89
+ }
90
+ return true;
91
+ }
92
+ function nativeRouteConstraintProblems(screens, constraints) {
93
+ const problems = [];
94
+ if (constraints === undefined)
95
+ return problems;
96
+ if (!constraints || typeof constraints !== "object" || Array.isArray(constraints))
97
+ return ["nativeScreenConstraints must name route parameter constraints."];
98
+ for (const [route, rules] of Object.entries(constraints)) {
99
+ const prefix = `nativeScreenConstraints[${JSON.stringify(route)}]`;
100
+ if (!Object.prototype.hasOwnProperty.call(screens ?? {}, route))
101
+ problems.push(`${prefix} names no native screen route.`);
102
+ if (!rules || typeof rules !== "object" || Array.isArray(rules)) {
103
+ problems.push(`${prefix} must name parameter constraints.`);
104
+ continue;
105
+ }
106
+ const names = route.split("/").filter((part) => part.startsWith(":")).map((part) => part.slice(1));
107
+ for (const [name, rule] of Object.entries(rules)) {
108
+ const field = `${prefix}[${JSON.stringify(name)}]`;
109
+ if (!names.includes(name))
110
+ problems.push(`${field} names no path parameter.`);
111
+ if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
112
+ problems.push(`${field} must be a parameter constraint.`);
113
+ continue;
114
+ }
115
+ if (Object.keys(rule).some((key) => !["pattern", "exclude", "normalize"].includes(key)))
116
+ problems.push(`${field} has an unknown constraint field.`);
117
+ if (rule.exclude !== undefined && (!Array.isArray(rule.exclude) || rule.exclude.some((value) => typeof value !== "string")))
118
+ problems.push(`${field}.exclude must contain strings.`);
119
+ if (rule.normalize !== undefined && rule.normalize !== "lowercase")
120
+ problems.push(`${field}.normalize must be lowercase.`);
121
+ if (rule.pattern !== undefined) {
122
+ try {
123
+ if (typeof rule.pattern !== "string")
124
+ throw new Error("not a string");
125
+ new RegExp(`^(?:${rule.pattern})$`);
126
+ } catch {
127
+ problems.push(`${field}.pattern must be a valid regular expression.`);
128
+ }
129
+ }
130
+ }
131
+ }
132
+ return problems;
133
+ }
134
+ function normalizeNativePath(raw) {
135
+ if (typeof raw !== "string")
136
+ return null;
137
+ let path = raw;
138
+ const cut = path.search(/[?#]/);
139
+ if (cut >= 0)
140
+ path = path.slice(0, cut);
141
+ if (!path.startsWith("/"))
142
+ return null;
143
+ path = path.replace(/\/{2,}/g, "/");
144
+ if (path.length > 1 && path.endsWith("/"))
145
+ path = path.slice(0, -1);
146
+ return path;
147
+ }
148
+ function routeSegments(path) {
149
+ return path === "/" ? [] : path.slice(1).split("/");
150
+ }
151
+ function matchNativePath(nativeScreens, raw, constraints) {
152
+ if (constraints !== undefined && (!constraints || typeof constraints !== "object" || Array.isArray(constraints)))
153
+ return null;
154
+ const path = normalizeNativePath(raw);
155
+ if (path === null)
156
+ return null;
157
+ const routes = Object.entries(nativeScreens ?? {}).map(([pattern, screen]) => ({ key: pattern, pattern: normalizeNativePath(pattern), screen })).filter((route) => route.pattern !== null && !!route.screen).map((route) => ({ ...route, segments: routeSegments(route.pattern) }));
158
+ const wild = (segments) => segments.at(-1) === "*" ? 1 : 0;
159
+ const fixed = (segments) => segments.filter((s) => s !== "*" && !s.startsWith(":")).length;
160
+ routes.sort((a, b) => wild(a.segments) - wild(b.segments) || fixed(b.segments) - fixed(a.segments) || b.segments.length - a.segments.length || (a.pattern < b.pattern ? -1 : a.pattern > b.pattern ? 1 : 0));
161
+ const decode = (value) => {
162
+ try {
163
+ return decodeURIComponent(value);
164
+ } catch {
165
+ return value;
166
+ }
167
+ };
168
+ const parts = routeSegments(path);
169
+ for (const route of routes) {
170
+ const params = {};
171
+ let matched = true;
172
+ for (let i = 0;i < route.segments.length; i++) {
173
+ const segment = route.segments[i];
174
+ if (segment === "*") {
175
+ params["*"] = parts.slice(i).join("/");
176
+ break;
177
+ }
178
+ if (i >= parts.length) {
179
+ matched = false;
180
+ break;
181
+ }
182
+ if (segment.startsWith(":")) {
183
+ if (!parts[i]) {
184
+ matched = false;
185
+ break;
186
+ }
187
+ params[segment.slice(1)] = decode(parts[i]);
188
+ } else if (segment !== parts[i]) {
189
+ matched = false;
190
+ break;
191
+ }
192
+ }
193
+ if (!matched)
194
+ continue;
195
+ if (route.segments.at(-1) !== "*" && route.segments.length !== parts.length)
196
+ continue;
197
+ if (!applyNativeParameterConstraints(params, constraints?.[route.key]))
198
+ continue;
199
+ const query = raw.indexOf("?");
200
+ if (query >= 0) {
201
+ for (const item of raw.slice(query + 1).split("#")[0].split("&")) {
202
+ if (!item)
203
+ continue;
204
+ const [rawKey = "", ...rest] = item.split("=");
205
+ const key = decode(rawKey);
206
+ if (!key || key in params)
207
+ continue;
208
+ params[key] = decode(rest.join("=").replace(/\+/g, " "));
209
+ }
210
+ }
211
+ return { path, screen: route.screen, params };
212
+ }
213
+ return null;
214
+ }
215
+
8
216
  // src/index.ts
9
217
  import { gzipSync } from "zlib";
10
218
 
@@ -285,83 +493,6 @@ function resolveCraftVersion(startDir = import.meta.dir) {
285
493
  return "0.0.0";
286
494
  }
287
495
  var CRAFT_VERSION = resolveCraftVersion();
288
- function normalizeNativePath(raw) {
289
- if (typeof raw !== "string")
290
- return null;
291
- let path = raw;
292
- const cut = path.search(/[?#]/);
293
- if (cut >= 0)
294
- path = path.slice(0, cut);
295
- if (!path.startsWith("/"))
296
- return null;
297
- path = path.replace(/\/{2,}/g, "/");
298
- if (path.length > 1 && path.endsWith("/"))
299
- path = path.slice(0, -1);
300
- return path;
301
- }
302
- function routeSegments(path) {
303
- return path === "/" ? [] : path.slice(1).split("/");
304
- }
305
- function matchNativePath(nativeScreens, raw) {
306
- const path = normalizeNativePath(raw);
307
- if (path === null)
308
- return null;
309
- const routes = Object.entries(nativeScreens ?? {}).map(([pattern, screen]) => ({ pattern: normalizeNativePath(pattern), screen })).filter((route) => route.pattern !== null && !!route.screen).map((route) => ({ ...route, segments: routeSegments(route.pattern) }));
310
- const wild = (segments) => segments.at(-1) === "*" ? 1 : 0;
311
- const fixed = (segments) => segments.filter((s) => s !== "*" && !s.startsWith(":")).length;
312
- routes.sort((a, b) => wild(a.segments) - wild(b.segments) || fixed(b.segments) - fixed(a.segments) || b.segments.length - a.segments.length || (a.pattern < b.pattern ? -1 : a.pattern > b.pattern ? 1 : 0));
313
- const decode = (value) => {
314
- try {
315
- return decodeURIComponent(value);
316
- } catch {
317
- return value;
318
- }
319
- };
320
- const parts = routeSegments(path);
321
- for (const route of routes) {
322
- const params = {};
323
- let matched = true;
324
- for (let i = 0;i < route.segments.length; i++) {
325
- const segment = route.segments[i];
326
- if (segment === "*") {
327
- params["*"] = parts.slice(i).join("/");
328
- break;
329
- }
330
- if (i >= parts.length) {
331
- matched = false;
332
- break;
333
- }
334
- if (segment.startsWith(":")) {
335
- if (!parts[i]) {
336
- matched = false;
337
- break;
338
- }
339
- params[segment.slice(1)] = decode(parts[i]);
340
- } else if (segment !== parts[i]) {
341
- matched = false;
342
- break;
343
- }
344
- }
345
- if (!matched)
346
- continue;
347
- if (route.segments.at(-1) !== "*" && route.segments.length !== parts.length)
348
- continue;
349
- const query = raw.indexOf("?");
350
- if (query >= 0) {
351
- for (const item of raw.slice(query + 1).split("#")[0].split("&")) {
352
- if (!item)
353
- continue;
354
- const [rawKey = "", ...rest] = item.split("=");
355
- const key = decode(rawKey);
356
- if (!key || key in params)
357
- continue;
358
- params[key] = decode(rest.join("=").replace(/\+/g, " "));
359
- }
360
- }
361
- return { path, screen: route.screen, params };
362
- }
363
- return null;
364
- }
365
496
  function isHybrid(config) {
366
497
  return (config.renderer ?? "web") === "web" && Object.keys(config.nativeScreens ?? {}).length > 0;
367
498
  }
@@ -382,8 +513,9 @@ function nativeStartPath(config) {
382
513
  }
383
514
  }
384
515
  function hybridConfigProblems(config) {
385
- const problems = [];
516
+ const problems = nativeNotificationProblems(config.notificationCategories, config.nativeApp, config.renderer);
386
517
  const screens = Object.entries(config.nativeScreens ?? {});
518
+ problems.push(...nativeRouteConstraintProblems(config.nativeScreens, config.nativeScreenConstraints));
387
519
  for (const [pattern, screen] of screens) {
388
520
  if (normalizeNativePath(pattern) !== pattern || /[?#]/.test(pattern))
389
521
  problems.push(`nativeScreens path "${pattern}" must be an absolute path with no query, hash or trailing slash.`);
@@ -407,11 +539,11 @@ function hybridConfigProblems(config) {
407
539
  }
408
540
  if (hasNativeRoutes(config)) {
409
541
  for (const id of ids) {
410
- if (!matchNativePath(config.nativeScreens, id))
542
+ if (!matchNativePath(config.nativeScreens, id, config.nativeScreenConstraints))
411
543
  problems.push(`Tab "${id}" is not a nativeScreens path; with renderer: "native" every tab must be a native screen.`);
412
544
  }
413
545
  const start = nativeStartPath(config);
414
- if (!matchNativePath(config.nativeScreens, start))
546
+ if (!matchNativePath(config.nativeScreens, start, config.nativeScreenConstraints))
415
547
  problems.push(`The start path "${start}" (from devServerURL) is not a nativeScreens path; with renderer: "native" the app must start on a native screen.`);
416
548
  }
417
549
  if (config.nativeApp !== undefined) {
@@ -691,6 +823,78 @@ function validateHealthKitBackground(config) {
691
823
  if (!["immediate", "hourly", "daily", "weekly"].includes(config.healthKitBackgroundFrequency ?? "hourly"))
692
824
  throw new Error("healthKitBackgroundFrequency must be immediate, hourly, daily or weekly");
693
825
  }
826
+ function validateNotificationAttachments(config) {
827
+ if (config.enableNotificationAttachments !== undefined && typeof config.enableNotificationAttachments !== "boolean")
828
+ throw new Error("enableNotificationAttachments must be a boolean");
829
+ if (config.enableNotificationAttachments && config.enablePushNotifications !== true)
830
+ throw new Error("enableNotificationAttachments requires enablePushNotifications");
831
+ }
832
+ var notificationServiceInclude = ` - path: CraftNotificationService.yml
833
+ `;
834
+ function syncNotificationService(output, config) {
835
+ validateNotificationAttachments(config);
836
+ const spec = join4(output, "CraftNotificationService.yml");
837
+ if (!config.enableNotificationAttachments) {
838
+ writeFileSync2(spec, `{}
839
+ `);
840
+ return;
841
+ }
842
+ const name = productName(config.appName);
843
+ const folder = join4(output, "NotificationService");
844
+ mkdirSync(folder, { recursive: true });
845
+ const source = readFileSync2(join4(TEMPLATES_DIR, "CraftNotificationService.swift"), "utf8");
846
+ const destination = join4(folder, "CraftNotificationService.swift");
847
+ if (!existsSync2(destination) || readFileSync2(destination, "utf8") !== source)
848
+ writeFileSync2(destination, source);
849
+ writeFileSync2(join4(folder, "Info.plist"), `<?xml version="1.0" encoding="UTF-8"?>
850
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
851
+ <plist version="1.0"><dict>
852
+ <key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>
853
+ <key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
854
+ <key>CFBundleName</key><string>$(PRODUCT_NAME)</string>
855
+ <key>CFBundlePackageType</key><string>XPC!</string>
856
+ <key>CFBundleShortVersionString</key><string>$(MARKETING_VERSION)</string>
857
+ <key>CFBundleVersion</key><string>$(CURRENT_PROJECT_VERSION)</string>
858
+ <key>NSExtension</key><dict>
859
+ <key>NSExtensionPointIdentifier</key><string>com.apple.usernotifications.service</string>
860
+ <key>NSExtensionPrincipalClass</key><string>$(PRODUCT_MODULE_NAME).CraftNotificationService</string>
861
+ </dict>
862
+ </dict></plist>
863
+ `);
864
+ writeFileSync2(spec, `targets:
865
+ ${name}:
866
+ dependencies:
867
+ - target: ${name}NotificationService
868
+ embed: true
869
+ ${name}NotificationService:
870
+ type: app-extension
871
+ platform: iOS
872
+ deploymentTarget: "${config.iosVersion || "15.0"}"
873
+ sources:
874
+ - NotificationService
875
+ settings:
876
+ INFOPLIST_FILE: NotificationService/Info.plist
877
+ PRODUCT_NAME: "$(TARGET_NAME)"
878
+ PRODUCT_BUNDLE_IDENTIFIER: ${config.bundleId}.notificationservice
879
+ SWIFT_VERSION: "5.0"
880
+ APPLICATION_EXTENSION_API_ONLY: YES
881
+ SKIP_INSTALL: YES
882
+ `);
883
+ const project = join4(output, "project.yml");
884
+ if (existsSync2(project)) {
885
+ const text = readFileSync2(project, "utf8");
886
+ const include = Bun.YAML.parse(text).include;
887
+ const includes = Array.isArray(include) ? include : [include];
888
+ const present = includes.some((entry) => entry === "CraftNotificationService.yml" || entry && typeof entry === "object" && ("path" in entry) && entry.path === "CraftNotificationService.yml");
889
+ if (!present) {
890
+ if (/^include:/m.test(text) && !/^include:[ \t]*\r?\n[ \t]+-/m.test(text))
891
+ throw new Error("Add a CraftNotificationService.yml entry to the project.yml include list");
892
+ writeFileSync2(project, /^include:/m.test(text) ? text.replace(/^include:[ \t]*\r?\n/m, `include:
893
+ ${notificationServiceInclude}`) : `include:
894
+ ${notificationServiceInclude}${text}`);
895
+ }
896
+ }
897
+ }
694
898
  function renderEntitlements(config) {
695
899
  validateHealthKitBackground(config);
696
900
  const entries = [];
@@ -1161,6 +1365,7 @@ function productName(name) {
1161
1365
  return /^\d/.test(joined) ? `App${joined}` : joined;
1162
1366
  }
1163
1367
  function writeShell(output, config) {
1368
+ validateNotificationAttachments(config);
1164
1369
  if (!config.appName || !/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(config.bundleId))
1165
1370
  throw new Error("The iOS shell needs an appName and a valid bundleId in craft.config.json.");
1166
1371
  const writeSource = (path, source) => {
@@ -1192,6 +1397,7 @@ function writeShell(output, config) {
1192
1397
  writeSource(join4(output, "Sources", "CraftNativeUI.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftNativeUI.swift"), "utf8"));
1193
1398
  writeSource(join4(output, "Sources", "CraftNativeServices.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftNativeServices.swift"), "utf8"));
1194
1399
  writeSource(join4(output, "Sources", "CraftNativeFiles.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftNativeFiles.swift"), "utf8"));
1400
+ writeSource(join4(output, "Sources", "CraftNativeMediaOwnership.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftNativeMediaOwnership.swift"), "utf8"));
1195
1401
  writeSource(join4(output, "Sources", "CraftVoiceRecording.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftVoiceRecording.swift"), "utf8"));
1196
1402
  writeSource(join4(output, "Sources", "CraftWebAudio.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftWebAudio.swift"), "utf8"));
1197
1403
  writeSource(join4(output, "Sources", "CraftHybrid.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftHybrid.swift"), "utf8"));
@@ -1208,6 +1414,7 @@ function writeShell(output, config) {
1208
1414
  const source = readFileSync2(join4(TEMPLATES_DIR, template), "utf8").replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{HEALTH_KIT_ENABLED\}\}/g, config.enableHealthKit ? "true" : "false");
1209
1415
  writeSource(join4(output, folder, filename), source);
1210
1416
  }
1417
+ syncNotificationService(output, config);
1211
1418
  }
1212
1419
  function refreshUsageDescriptions(output, config) {
1213
1420
  const path = join4(output, "Info.plist");
@@ -1248,6 +1455,7 @@ async function init(options) {
1248
1455
  ...given
1249
1456
  };
1250
1457
  validateHealthKitBackground(config);
1458
+ validateNotificationAttachments(config);
1251
1459
  if (config.renderer !== "web" && config.renderer !== "native") {
1252
1460
  throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
1253
1461
  }
@@ -1349,7 +1557,8 @@ ${plistArray(nativeFontFiles, 2)}
1349
1557
  ${nativeDependencies.join(`
1350
1558
  `)}` : "").replace(/\{\{NATIVE_TARGETS\}\}/g, nativeTargets.join(`
1351
1559
  `));
1352
- writeFileSync2(join4(output, "project.yml"), projectYml);
1560
+ writeFileSync2(join4(output, "project.yml"), `include:
1561
+ ${notificationServiceInclude}${projectYml}`);
1353
1562
  writeFileSync2(join4(output, "Craft.entitlements"), renderEntitlements(config));
1354
1563
  writeFileSync2(join4(output, "PrivacyInfo.xcprivacy"), renderPrivacyManifest(config));
1355
1564
  renderAssetCatalog(output, config);
@@ -1399,6 +1608,7 @@ async function build(options) {
1399
1608
  throw new Error(`No craft.config.json found in ${output}. Run 'craft ios init' first.`);
1400
1609
  }
1401
1610
  const config = JSON.parse(readFileSync2(configPath, "utf-8"));
1611
+ validateNotificationAttachments(config);
1402
1612
  if (config.renderer !== undefined && config.renderer !== "web" && config.renderer !== "native") {
1403
1613
  throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
1404
1614
  }