@craft-native/ios 0.0.449 → 0.0.464
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 +66 -1
- package/dist/cli.js +290 -81
- package/dist/index.d.ts +1 -685
- package/dist/index.js +292 -81
- package/dist/internal/ios/src/index.d.ts +684 -0
- package/dist/internal/typescript/src/native-notifications.d.ts +17 -0
- package/dist/internal/typescript/src/native-routing.d.ts +26 -0
- package/package.json +1 -1
- package/templates/CraftApp.swift +118 -16
- package/templates/CraftHybrid.swift +75 -5
- package/templates/CraftNativeActions.swift +84 -13
- package/templates/CraftNativeFiles.swift +83 -1
- package/templates/CraftNativeScreen.swift +130 -4
- package/templates/CraftNotificationService.swift +199 -0
- package/templates/CraftVoiceRecording.swift +29 -1
- package/templates/testing/DriverTests.swift.template +61 -6
- /package/dist/{app-shortcuts.d.ts → internal/ios/src/app-shortcuts.d.ts} +0 -0
- /package/dist/{runtime-profile.d.ts → internal/ios/src/runtime-profile.d.ts} +0 -0
- /package/dist/{source-audit.d.ts → internal/ios/src/source-audit.d.ts} +0 -0
- /package/dist/{testing.d.ts → internal/ios/src/testing.d.ts} +0 -0
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
|
|
@@ -1420,4 +1429,60 @@ when available. `monospace`/`ui-monospace`, `serif`/`ui-serif` and
|
|
|
1420
1429
|
weight, italic style and system text scaling. A missing custom face falls through
|
|
1421
1430
|
to the next family rather than silently replacing a requested mono font with sans.
|
|
1422
1431
|
|
|
1423
|
-
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.
|
|
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.
|
|
1433
|
+
|
|
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) => {
|
|
@@ -1208,6 +1413,7 @@ function writeShell(output, config) {
|
|
|
1208
1413
|
const source = readFileSync2(join4(TEMPLATES_DIR, template), "utf8").replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{HEALTH_KIT_ENABLED\}\}/g, config.enableHealthKit ? "true" : "false");
|
|
1209
1414
|
writeSource(join4(output, folder, filename), source);
|
|
1210
1415
|
}
|
|
1416
|
+
syncNotificationService(output, config);
|
|
1211
1417
|
}
|
|
1212
1418
|
function refreshUsageDescriptions(output, config) {
|
|
1213
1419
|
const path = join4(output, "Info.plist");
|
|
@@ -1248,6 +1454,7 @@ async function init(options) {
|
|
|
1248
1454
|
...given
|
|
1249
1455
|
};
|
|
1250
1456
|
validateHealthKitBackground(config);
|
|
1457
|
+
validateNotificationAttachments(config);
|
|
1251
1458
|
if (config.renderer !== "web" && config.renderer !== "native") {
|
|
1252
1459
|
throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
|
|
1253
1460
|
}
|
|
@@ -1349,7 +1556,8 @@ ${plistArray(nativeFontFiles, 2)}
|
|
|
1349
1556
|
${nativeDependencies.join(`
|
|
1350
1557
|
`)}` : "").replace(/\{\{NATIVE_TARGETS\}\}/g, nativeTargets.join(`
|
|
1351
1558
|
`));
|
|
1352
|
-
writeFileSync2(join4(output, "project.yml"),
|
|
1559
|
+
writeFileSync2(join4(output, "project.yml"), `include:
|
|
1560
|
+
${notificationServiceInclude}${projectYml}`);
|
|
1353
1561
|
writeFileSync2(join4(output, "Craft.entitlements"), renderEntitlements(config));
|
|
1354
1562
|
writeFileSync2(join4(output, "PrivacyInfo.xcprivacy"), renderPrivacyManifest(config));
|
|
1355
1563
|
renderAssetCatalog(output, config);
|
|
@@ -1399,6 +1607,7 @@ async function build(options) {
|
|
|
1399
1607
|
throw new Error(`No craft.config.json found in ${output}. Run 'craft ios init' first.`);
|
|
1400
1608
|
}
|
|
1401
1609
|
const config = JSON.parse(readFileSync2(configPath, "utf-8"));
|
|
1610
|
+
validateNotificationAttachments(config);
|
|
1402
1611
|
if (config.renderer !== undefined && config.renderer !== "web" && config.renderer !== "native") {
|
|
1403
1612
|
throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
|
|
1404
1613
|
}
|