voltra 2.2.0 → 2.3.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.
- package/build/cjs/apply/index.js +6 -1
- package/build/cjs/apply/index.js.map +1 -1
- package/build/cjs/config/normalize.js +150 -31
- package/build/cjs/config/normalize.js.map +1 -1
- package/build/cjs/config/perConfiguration.js +29 -0
- package/build/cjs/config/perConfiguration.js.map +1 -0
- package/build/cjs/config/serverUpdate.js +119 -0
- package/build/cjs/config/serverUpdate.js.map +1 -0
- package/build/cjs/config/widgetKind.js +25 -0
- package/build/cjs/config/widgetKind.js.map +1 -0
- package/build/cjs/dependencies/platformPackages.js +9 -0
- package/build/cjs/dependencies/platformPackages.js.map +1 -1
- package/build/cjs/discovery/ios.js +50 -31
- package/build/cjs/discovery/ios.js.map +1 -1
- package/build/cjs/discovery/metro.js +52 -0
- package/build/cjs/discovery/metro.js.map +1 -0
- package/build/cjs/index.js +6 -2
- package/build/cjs/index.js.map +1 -1
- package/build/cjs/platforms/android/generated.js +98 -40
- package/build/cjs/platforms/android/generated.js.map +1 -1
- package/build/cjs/platforms/android/widgetSizing.js +89 -0
- package/build/cjs/platforms/android/widgetSizing.js.map +1 -0
- package/build/cjs/platforms/ios/apply.js +21 -11
- package/build/cjs/platforms/ios/apply.js.map +1 -1
- package/build/cjs/platforms/ios/buildConfigurationValues.js +73 -0
- package/build/cjs/platforms/ios/buildConfigurationValues.js.map +1 -0
- package/build/cjs/platforms/ios/entitlements.js +34 -19
- package/build/cjs/platforms/ios/entitlements.js.map +1 -1
- package/build/cjs/platforms/ios/generated.js +79 -32
- package/build/cjs/platforms/ios/generated.js.map +1 -1
- package/build/cjs/platforms/ios/plist.js +22 -4
- package/build/cjs/platforms/ios/plist.js.map +1 -1
- package/build/cjs/platforms/ios/targetName.js.map +1 -1
- package/build/cjs/platforms/ios/xcodeTarget.js +166 -23
- package/build/cjs/platforms/ios/xcodeTarget.js.map +1 -1
- package/build/cjs/platforms/shared/widgetModule.js +18 -145
- package/build/cjs/platforms/shared/widgetModule.js.map +1 -1
- package/build/esm/apply/index.js +6 -1
- package/build/esm/apply/index.js.map +1 -1
- package/build/esm/config/normalize.js +150 -31
- package/build/esm/config/normalize.js.map +1 -1
- package/build/esm/config/perConfiguration.js +25 -0
- package/build/esm/config/perConfiguration.js.map +1 -0
- package/build/esm/config/serverUpdate.js +112 -0
- package/build/esm/config/serverUpdate.js.map +1 -0
- package/build/esm/config/widgetKind.js +20 -0
- package/build/esm/config/widgetKind.js.map +1 -0
- package/build/esm/dependencies/platformPackages.js +8 -0
- package/build/esm/dependencies/platformPackages.js.map +1 -1
- package/build/esm/discovery/ios.js +50 -31
- package/build/esm/discovery/ios.js.map +1 -1
- package/build/esm/discovery/metro.js +46 -0
- package/build/esm/discovery/metro.js.map +1 -0
- package/build/esm/index.js +1 -0
- package/build/esm/index.js.map +1 -1
- package/build/esm/platforms/android/generated.js +100 -42
- package/build/esm/platforms/android/generated.js.map +1 -1
- package/build/esm/platforms/android/widgetSizing.js +85 -0
- package/build/esm/platforms/android/widgetSizing.js.map +1 -0
- package/build/esm/platforms/ios/apply.js +21 -11
- package/build/esm/platforms/ios/apply.js.map +1 -1
- package/build/esm/platforms/ios/buildConfigurationValues.js +68 -0
- package/build/esm/platforms/ios/buildConfigurationValues.js.map +1 -0
- package/build/esm/platforms/ios/entitlements.js +34 -19
- package/build/esm/platforms/ios/entitlements.js.map +1 -1
- package/build/esm/platforms/ios/generated.js +80 -33
- package/build/esm/platforms/ios/generated.js.map +1 -1
- package/build/esm/platforms/ios/plist.js +22 -4
- package/build/esm/platforms/ios/plist.js.map +1 -1
- package/build/esm/platforms/ios/targetName.js.map +1 -1
- package/build/esm/platforms/ios/xcodeTarget.js +166 -23
- package/build/esm/platforms/ios/xcodeTarget.js.map +1 -1
- package/build/esm/platforms/shared/widgetModule.js +16 -107
- package/build/esm/platforms/shared/widgetModule.js.map +1 -1
- package/build/types/apply/index.d.ts.map +1 -1
- package/build/types/config/normalize.d.ts.map +1 -1
- package/build/types/config/perConfiguration.d.ts +18 -0
- package/build/types/config/perConfiguration.d.ts.map +1 -0
- package/build/types/config/serverUpdate.d.ts +80 -0
- package/build/types/config/serverUpdate.d.ts.map +1 -0
- package/build/types/config/types.d.ts +91 -30
- package/build/types/config/types.d.ts.map +1 -1
- package/build/types/config/widgetKind.d.ts +11 -0
- package/build/types/config/widgetKind.d.ts.map +1 -0
- package/build/types/dependencies/platformPackages.d.ts +1 -0
- package/build/types/dependencies/platformPackages.d.ts.map +1 -1
- package/build/types/discovery/ios.d.ts +10 -0
- package/build/types/discovery/ios.d.ts.map +1 -1
- package/build/types/discovery/metro.d.ts +21 -0
- package/build/types/discovery/metro.d.ts.map +1 -0
- package/build/types/index.d.ts +3 -1
- package/build/types/index.d.ts.map +1 -1
- package/build/types/platforms/android/generated.d.ts.map +1 -1
- package/build/types/platforms/android/widgetSizing.d.ts +50 -0
- package/build/types/platforms/android/widgetSizing.d.ts.map +1 -0
- package/build/types/platforms/ios/apply.d.ts.map +1 -1
- package/build/types/platforms/ios/buildConfigurationValues.d.ts +33 -0
- package/build/types/platforms/ios/buildConfigurationValues.d.ts.map +1 -0
- package/build/types/platforms/ios/entitlements.d.ts +4 -4
- package/build/types/platforms/ios/entitlements.d.ts.map +1 -1
- package/build/types/platforms/ios/generated.d.ts +14 -2
- package/build/types/platforms/ios/generated.d.ts.map +1 -1
- package/build/types/platforms/ios/plist.d.ts +3 -3
- package/build/types/platforms/ios/plist.d.ts.map +1 -1
- package/build/types/platforms/ios/podfile.d.ts +2 -2
- package/build/types/platforms/ios/podfile.d.ts.map +1 -1
- package/build/types/platforms/ios/targetName.d.ts +2 -2
- package/build/types/platforms/ios/targetName.d.ts.map +1 -1
- package/build/types/platforms/ios/xcodeTarget.d.ts +4 -2
- package/build/types/platforms/ios/xcodeTarget.d.ts.map +1 -1
- package/build/types/platforms/shared/widgetModule.d.ts +12 -3
- package/build/types/platforms/shared/widgetModule.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/apply/index.ts +6 -1
- package/src/config/normalize.node.test.ts +105 -0
- package/src/config/normalize.ts +217 -45
- package/src/config/perConfiguration.ts +49 -0
- package/src/config/serverUpdate.ts +160 -0
- package/src/config/types.ts +97 -30
- package/src/config/widgetKind.node.test.ts +27 -0
- package/src/config/widgetKind.ts +27 -0
- package/src/dependencies/platformPackages.ts +11 -0
- package/src/discovery/ios.ts +104 -60
- package/src/discovery/metro.node.test.ts +91 -0
- package/src/discovery/metro.ts +54 -0
- package/src/index.ts +8 -0
- package/src/platforms/android/generated.ts +144 -47
- package/src/platforms/android/widgetSizing.ts +112 -0
- package/src/platforms/ios/apply.ts +21 -11
- package/src/platforms/ios/buildConfigurationValues.ts +105 -0
- package/src/platforms/ios/entitlements.ts +55 -31
- package/src/platforms/ios/generated.node.test.ts +61 -0
- package/src/platforms/ios/generated.ts +137 -38
- package/src/platforms/ios/plist.ts +36 -7
- package/src/platforms/ios/podfile.ts +2 -2
- package/src/platforms/ios/targetName.ts +2 -2
- package/src/platforms/ios/xcodeTarget.ts +248 -43
- package/src/platforms/shared/widgetModule.ts +22 -140
package/src/config/normalize.ts
CHANGED
|
@@ -2,6 +2,11 @@ import path from 'node:path'
|
|
|
2
2
|
|
|
3
3
|
import { resolveFromRoot } from '../fs/path'
|
|
4
4
|
import { CLI_DEFAULTS } from './defaults'
|
|
5
|
+
import { isPerConfigurationMap } from './perConfiguration'
|
|
6
|
+
import { resolveServerUpdateInterval, resolveServerUpdateUrl, validateServerUpdateRefresh } from './serverUpdate'
|
|
7
|
+
import { iosWidgetKind } from './widgetKind'
|
|
8
|
+
|
|
9
|
+
import type { PerConfiguration } from './perConfiguration'
|
|
5
10
|
|
|
6
11
|
import type {
|
|
7
12
|
AndroidWidgetAppIntentConfig,
|
|
@@ -10,6 +15,7 @@ import type {
|
|
|
10
15
|
IOSWidgetConfig,
|
|
11
16
|
LoadedVoltraConfig,
|
|
12
17
|
NormalizedAndroidWidgetConfig,
|
|
18
|
+
NormalizedWidgetServerUpdateConfig,
|
|
13
19
|
NormalizedVoltraAndroidConfig,
|
|
14
20
|
NormalizedVoltraConfig,
|
|
15
21
|
NormalizedVoltraIOSConfig,
|
|
@@ -53,6 +59,33 @@ function assertOptionalString(value: unknown, context: string): asserts value is
|
|
|
53
59
|
}
|
|
54
60
|
}
|
|
55
61
|
|
|
62
|
+
function assertOptionalPerConfigurationString(
|
|
63
|
+
value: unknown,
|
|
64
|
+
context: string
|
|
65
|
+
): asserts value is PerConfiguration<string> | undefined {
|
|
66
|
+
if (value === undefined || typeof value === 'string') {
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!isPerConfigurationMap(value as PerConfiguration<unknown>)) {
|
|
71
|
+
throw new VoltraConfigNormalizationError(
|
|
72
|
+
`${context} must be a string, or an object of strings keyed by build configuration name`
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
77
|
+
|
|
78
|
+
if (entries.length === 0) {
|
|
79
|
+
throw new VoltraConfigNormalizationError(`${context} must not be an empty object`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const [buildConfigurationName, configurationValue] of entries) {
|
|
83
|
+
if (typeof configurationValue !== 'string' || !configurationValue.trim()) {
|
|
84
|
+
throw new VoltraConfigNormalizationError(`${context}.${buildConfigurationName} must be a non-empty string`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
56
89
|
function assertOptionalBoolean(value: unknown, context: string): asserts value is boolean | undefined {
|
|
57
90
|
if (value !== undefined && typeof value !== 'boolean') {
|
|
58
91
|
throw new VoltraConfigNormalizationError(`${context} must be a boolean`)
|
|
@@ -105,6 +138,26 @@ function resolveOptionalPathFromProjectRoot(projectRoot: string, filePath: strin
|
|
|
105
138
|
return resolvePathFromProjectRoot(projectRoot, filePath)
|
|
106
139
|
}
|
|
107
140
|
|
|
141
|
+
function resolveOptionalPerConfigurationPath(
|
|
142
|
+
projectRoot: string,
|
|
143
|
+
filePath: PerConfiguration<string> | undefined
|
|
144
|
+
): PerConfiguration<string> | undefined {
|
|
145
|
+
if (!filePath) {
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!isPerConfigurationMap(filePath)) {
|
|
150
|
+
return resolvePathFromProjectRoot(projectRoot, filePath)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return Object.fromEntries(
|
|
154
|
+
Object.entries(filePath).map(([buildConfigurationName, configurationPath]) => [
|
|
155
|
+
buildConfigurationName,
|
|
156
|
+
resolvePathFromProjectRoot(projectRoot, configurationPath),
|
|
157
|
+
])
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
108
161
|
function isAbsoluteWidgetPath(value: string): boolean {
|
|
109
162
|
return path.isAbsolute(value) || path.win32.isAbsolute(value) || /^[a-zA-Z]:[\\/]/.test(value)
|
|
110
163
|
}
|
|
@@ -294,42 +347,90 @@ function normalizeIOSAppIntent(
|
|
|
294
347
|
return { parameters }
|
|
295
348
|
}
|
|
296
349
|
|
|
297
|
-
|
|
298
|
-
|
|
350
|
+
/**
|
|
351
|
+
* `appIntent` only reaches the generated project through the `clientRendered` branches, so on a
|
|
352
|
+
* widget with no `entry` every parameter is dropped: no configuration defaults are emitted, the
|
|
353
|
+
* receiver stays payload-driven, and `getWidgetConfiguration` rejects it at runtime. Generate time
|
|
354
|
+
* is the only place that can still say so.
|
|
355
|
+
*/
|
|
356
|
+
function warnOnAppIntentWithoutEntry(
|
|
357
|
+
appIntent: { parameters: unknown[] } | undefined,
|
|
358
|
+
hasEntry: boolean,
|
|
299
359
|
context: string,
|
|
300
|
-
|
|
301
|
-
|
|
360
|
+
warnings: string[]
|
|
361
|
+
): void {
|
|
362
|
+
if (hasEntry || !appIntent || appIntent.parameters.length === 0) {
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
warnings.push(
|
|
367
|
+
`${context}.appIntent is ignored because the widget has no 'entry'. Configuration parameters ` +
|
|
368
|
+
`only apply to Dynamic Widgets. Add an 'entry' to make this one, or drop 'appIntent'.`
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
interface NormalizeServerUpdateOptions {
|
|
373
|
+
context: string
|
|
374
|
+
/** True when the widget has an `entry`, so the response is props rather than a payload. */
|
|
375
|
+
hasEntry: boolean
|
|
376
|
+
defaultIntervalMinutes: number
|
|
377
|
+
defaultRefresh: boolean
|
|
302
378
|
minimumIntervalMinutes: number
|
|
303
|
-
|
|
379
|
+
warnings: string[]
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function normalizeServerUpdate(
|
|
383
|
+
serverUpdate: { url?: string; intervalMinutes?: number; refresh?: boolean },
|
|
384
|
+
options: NormalizeServerUpdateOptions
|
|
385
|
+
): NormalizedWidgetServerUpdateConfig {
|
|
386
|
+
const { context, hasEntry, defaultIntervalMinutes, defaultRefresh, minimumIntervalMinutes, warnings } = options
|
|
387
|
+
|
|
304
388
|
assertObject(serverUpdate, context)
|
|
305
|
-
assertNonEmptyString(serverUpdate.url, `${context}.url`)
|
|
306
389
|
|
|
307
|
-
|
|
308
|
-
if (typeof serverUpdate.intervalMinutes !== 'number' || !Number.isFinite(serverUpdate.intervalMinutes)) {
|
|
309
|
-
throw new VoltraConfigNormalizationError(`${context}.intervalMinutes must be a number`)
|
|
310
|
-
}
|
|
390
|
+
const url = resolveServerUpdateUrl(serverUpdate.url, context)
|
|
311
391
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
392
|
+
if (url.kind === 'invalid') {
|
|
393
|
+
throw new VoltraConfigNormalizationError(url.error)
|
|
394
|
+
}
|
|
315
395
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
396
|
+
if (url.kind === 'insecure') {
|
|
397
|
+
warnings.push(url.warning)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const interval = resolveServerUpdateInterval({
|
|
401
|
+
intervalMinutes: serverUpdate.intervalMinutes,
|
|
402
|
+
context,
|
|
403
|
+
hasEntry,
|
|
404
|
+
defaultIntervalMinutes,
|
|
405
|
+
minimumIntervalMinutes,
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
if (interval.kind === 'invalid') {
|
|
409
|
+
throw new VoltraConfigNormalizationError(interval.error)
|
|
319
410
|
}
|
|
320
411
|
|
|
321
|
-
if (
|
|
322
|
-
|
|
412
|
+
if (interval.kind === 'clamped') {
|
|
413
|
+
warnings.push(interval.warning)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const refreshError = validateServerUpdateRefresh(serverUpdate.refresh, context)
|
|
417
|
+
|
|
418
|
+
if (refreshError) {
|
|
419
|
+
throw new VoltraConfigNormalizationError(refreshError)
|
|
323
420
|
}
|
|
324
421
|
|
|
325
422
|
return {
|
|
326
423
|
url: serverUpdate.url,
|
|
327
|
-
intervalMinutes:
|
|
424
|
+
intervalMinutes: interval.intervalMinutes,
|
|
328
425
|
refresh: serverUpdate.refresh ?? defaultRefresh,
|
|
329
426
|
}
|
|
330
427
|
}
|
|
331
428
|
|
|
332
|
-
function normalizeAndroidWidget(
|
|
429
|
+
function normalizeAndroidWidget(
|
|
430
|
+
projectRoot: string,
|
|
431
|
+
widget: AndroidWidgetConfig,
|
|
432
|
+
warnings: string[]
|
|
433
|
+
): NormalizedAndroidWidgetConfig {
|
|
333
434
|
assertObject(widget, 'android.widgets[]')
|
|
334
435
|
assertNonEmptyString(widget.id, 'android.widgets[].id')
|
|
335
436
|
assertValidWidgetId(widget.id, 'android.widgets[].id')
|
|
@@ -337,6 +438,15 @@ function normalizeAndroidWidget(projectRoot: string, widget: AndroidWidgetConfig
|
|
|
337
438
|
assertPositiveInteger(widget.targetCellHeight, `android.widgets[${widget.id}].targetCellHeight`)
|
|
338
439
|
assertOptionalPositiveInteger(widget.minCellWidth, `android.widgets[${widget.id}].minCellWidth`)
|
|
339
440
|
assertOptionalPositiveInteger(widget.minCellHeight, `android.widgets[${widget.id}].minCellHeight`)
|
|
441
|
+
assertOptionalPositiveInteger(widget.minWidth, `android.widgets[${widget.id}].minWidth`)
|
|
442
|
+
assertOptionalPositiveInteger(widget.minHeight, `android.widgets[${widget.id}].minHeight`)
|
|
443
|
+
assertOptionalPositiveInteger(widget.minResizeWidth, `android.widgets[${widget.id}].minResizeWidth`)
|
|
444
|
+
assertOptionalPositiveInteger(widget.minResizeHeight, `android.widgets[${widget.id}].minResizeHeight`)
|
|
445
|
+
assertOptionalPositiveInteger(widget.maxResizeWidth, `android.widgets[${widget.id}].maxResizeWidth`)
|
|
446
|
+
assertOptionalPositiveInteger(widget.maxResizeHeight, `android.widgets[${widget.id}].maxResizeHeight`)
|
|
447
|
+
|
|
448
|
+
const androidAppIntent = normalizeAndroidAppIntent(widget.appIntent, `android.widgets[${widget.id}].appIntent`)
|
|
449
|
+
warnOnAppIntentWithoutEntry(androidAppIntent, widget.entry !== undefined, `android.widgets[${widget.id}]`, warnings)
|
|
340
450
|
|
|
341
451
|
return {
|
|
342
452
|
...widget,
|
|
@@ -350,24 +460,33 @@ function normalizeAndroidWidget(projectRoot: string, widget: AndroidWidgetConfig
|
|
|
350
460
|
),
|
|
351
461
|
previewImage: resolveOptionalPathFromProjectRoot(projectRoot, widget.previewImage),
|
|
352
462
|
previewLayout: resolveOptionalPathFromProjectRoot(projectRoot, widget.previewLayout),
|
|
353
|
-
appIntent:
|
|
463
|
+
appIntent: androidAppIntent,
|
|
354
464
|
serverUpdate: widget.serverUpdate
|
|
355
|
-
? normalizeServerUpdate(
|
|
356
|
-
widget.serverUpdate
|
|
357
|
-
|
|
358
|
-
CLI_DEFAULTS.android.serverUpdateIntervalMinutes,
|
|
359
|
-
CLI_DEFAULTS.android.serverUpdateRefresh,
|
|
360
|
-
15
|
|
361
|
-
|
|
465
|
+
? normalizeServerUpdate(widget.serverUpdate, {
|
|
466
|
+
context: `android.widgets[${widget.id}].serverUpdate`,
|
|
467
|
+
hasEntry: widget.entry !== undefined,
|
|
468
|
+
defaultIntervalMinutes: CLI_DEFAULTS.android.serverUpdateIntervalMinutes,
|
|
469
|
+
defaultRefresh: CLI_DEFAULTS.android.serverUpdateRefresh,
|
|
470
|
+
minimumIntervalMinutes: 15,
|
|
471
|
+
warnings,
|
|
472
|
+
})
|
|
362
473
|
: undefined,
|
|
363
474
|
}
|
|
364
475
|
}
|
|
365
476
|
|
|
366
|
-
function normalizeIOSWidget(
|
|
477
|
+
function normalizeIOSWidget(
|
|
478
|
+
projectRoot: string,
|
|
479
|
+
widget: IOSWidgetConfig,
|
|
480
|
+
warnings: string[]
|
|
481
|
+
): NormalizedIOSWidgetConfig {
|
|
367
482
|
assertObject(widget, 'ios.widgets[]')
|
|
368
483
|
assertNonEmptyString(widget.id, 'ios.widgets[].id')
|
|
369
484
|
assertValidWidgetId(widget.id, 'ios.widgets[].id')
|
|
370
485
|
|
|
486
|
+
if (widget.kind !== undefined) {
|
|
487
|
+
assertNonEmptyString(widget.kind, `ios.widgets[${widget.id}].kind`)
|
|
488
|
+
}
|
|
489
|
+
|
|
371
490
|
if (widget.supportedFamilies !== undefined) {
|
|
372
491
|
if (!Array.isArray(widget.supportedFamilies)) {
|
|
373
492
|
throw new VoltraConfigNormalizationError(`ios.widgets[${widget.id}].supportedFamilies must be an array`)
|
|
@@ -382,6 +501,9 @@ function normalizeIOSWidget(projectRoot: string, widget: IOSWidgetConfig): Norma
|
|
|
382
501
|
}
|
|
383
502
|
}
|
|
384
503
|
|
|
504
|
+
const iosAppIntent = normalizeIOSAppIntent(widget.appIntent, `ios.widgets[${widget.id}].appIntent`)
|
|
505
|
+
warnOnAppIntentWithoutEntry(iosAppIntent, widget.entry !== undefined, `ios.widgets[${widget.id}]`, warnings)
|
|
506
|
+
|
|
385
507
|
return {
|
|
386
508
|
...widget,
|
|
387
509
|
displayName: normalizeLabel(widget.displayName, `ios.widgets[${widget.id}].displayName`),
|
|
@@ -393,15 +515,16 @@ function normalizeIOSWidget(projectRoot: string, widget: IOSWidgetConfig): Norma
|
|
|
393
515
|
widget.initialStatePath,
|
|
394
516
|
`ios.widgets[${widget.id}].initialStatePath`
|
|
395
517
|
),
|
|
396
|
-
appIntent:
|
|
518
|
+
appIntent: iosAppIntent,
|
|
397
519
|
serverUpdate: widget.serverUpdate
|
|
398
|
-
? normalizeServerUpdate(
|
|
399
|
-
widget.serverUpdate
|
|
400
|
-
|
|
401
|
-
CLI_DEFAULTS.ios.serverUpdateIntervalMinutes,
|
|
402
|
-
CLI_DEFAULTS.ios.serverUpdateRefresh,
|
|
403
|
-
1
|
|
404
|
-
|
|
520
|
+
? normalizeServerUpdate(widget.serverUpdate, {
|
|
521
|
+
context: `ios.widgets[${widget.id}].serverUpdate`,
|
|
522
|
+
hasEntry: widget.entry !== undefined,
|
|
523
|
+
defaultIntervalMinutes: CLI_DEFAULTS.ios.serverUpdateIntervalMinutes,
|
|
524
|
+
defaultRefresh: CLI_DEFAULTS.ios.serverUpdateRefresh,
|
|
525
|
+
minimumIntervalMinutes: 1,
|
|
526
|
+
warnings,
|
|
527
|
+
})
|
|
405
528
|
: undefined,
|
|
406
529
|
}
|
|
407
530
|
}
|
|
@@ -418,6 +541,25 @@ function assertUniqueWidgetIds(widgetIds: string[], context: string): void {
|
|
|
418
541
|
}
|
|
419
542
|
}
|
|
420
543
|
|
|
544
|
+
/**
|
|
545
|
+
* WidgetKit identifies a placed widget by extension bundle id + kind, so two widgets sharing a kind
|
|
546
|
+
* would fight over the same Home Screen instances. Compared after defaulting, which also catches a
|
|
547
|
+
* custom kind that collides with another widget's `Voltra_Widget_<id>`.
|
|
548
|
+
*/
|
|
549
|
+
function assertUniqueIOSWidgetKinds(widgets: Pick<IOSWidgetConfig, 'id' | 'kind'>[]): void {
|
|
550
|
+
const seen = new Set<string>()
|
|
551
|
+
|
|
552
|
+
for (const widget of widgets) {
|
|
553
|
+
const kind = iosWidgetKind(widget)
|
|
554
|
+
|
|
555
|
+
if (seen.has(kind)) {
|
|
556
|
+
throw new VoltraConfigNormalizationError(`Duplicate ios widget kind '${kind}'`)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
seen.add(kind)
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
421
563
|
function assertValidWidgetId(widgetId: string, context: string): void {
|
|
422
564
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(widgetId)) {
|
|
423
565
|
throw new VoltraConfigNormalizationError(
|
|
@@ -435,6 +577,7 @@ function assertValidIOSTargetName(targetName: string, context: string): void {
|
|
|
435
577
|
}
|
|
436
578
|
|
|
437
579
|
function normalizeAndroidConfig(
|
|
580
|
+
warnings: string[],
|
|
438
581
|
projectRoot: string,
|
|
439
582
|
config: LoadedVoltraConfig['config']['android']
|
|
440
583
|
): NormalizedVoltraAndroidConfig | undefined {
|
|
@@ -459,7 +602,7 @@ function normalizeAndroidConfig(
|
|
|
459
602
|
throw new VoltraConfigNormalizationError('android.widgets must be an array')
|
|
460
603
|
}
|
|
461
604
|
|
|
462
|
-
const widgets = (config.widgets ?? []).map((widget) => normalizeAndroidWidget(projectRoot, widget))
|
|
605
|
+
const widgets = (config.widgets ?? []).map((widget) => normalizeAndroidWidget(projectRoot, widget, warnings))
|
|
463
606
|
assertUniqueWidgetIds(
|
|
464
607
|
widgets.map((widget) => widget.id),
|
|
465
608
|
'android'
|
|
@@ -483,6 +626,7 @@ function normalizeAndroidConfig(
|
|
|
483
626
|
}
|
|
484
627
|
|
|
485
628
|
function normalizeIOSConfig(
|
|
629
|
+
warnings: string[],
|
|
486
630
|
projectRoot: string,
|
|
487
631
|
config: LoadedVoltraConfig['config']['ios']
|
|
488
632
|
): NormalizedVoltraIOSConfig | undefined {
|
|
@@ -492,12 +636,12 @@ function normalizeIOSConfig(
|
|
|
492
636
|
|
|
493
637
|
assertObject(config, 'ios')
|
|
494
638
|
assertOptionalBoolean(config.enablePushNotifications, 'ios.enablePushNotifications')
|
|
495
|
-
|
|
639
|
+
assertOptionalPerConfigurationString(config.groupIdentifier, 'ios.groupIdentifier')
|
|
496
640
|
assertOptionalString(config.deploymentTarget, 'ios.deploymentTarget')
|
|
497
641
|
assertOptionalString(config.targetName, 'ios.targetName')
|
|
498
642
|
assertOptionalStringArray(config.fonts, 'ios.fonts')
|
|
499
643
|
assertOptionalString(config.userImagesPath, 'ios.userImagesPath')
|
|
500
|
-
|
|
644
|
+
assertOptionalPerConfigurationString(config.keychainGroup, 'ios.keychainGroup')
|
|
501
645
|
|
|
502
646
|
if (config.project !== undefined) {
|
|
503
647
|
assertObject(config.project, 'ios.project')
|
|
@@ -505,7 +649,7 @@ function normalizeIOSConfig(
|
|
|
505
649
|
assertOptionalString(config.project.xcodeprojPath, 'ios.project.xcodeprojPath')
|
|
506
650
|
assertOptionalString(config.project.mainTargetName, 'ios.project.mainTargetName')
|
|
507
651
|
assertOptionalString(config.project.infoPlistPath, 'ios.project.infoPlistPath')
|
|
508
|
-
|
|
652
|
+
assertOptionalPerConfigurationString(config.project.entitlementsPath, 'ios.project.entitlementsPath')
|
|
509
653
|
assertOptionalString(config.project.podfilePath, 'ios.project.podfilePath')
|
|
510
654
|
}
|
|
511
655
|
|
|
@@ -517,11 +661,12 @@ function normalizeIOSConfig(
|
|
|
517
661
|
assertValidIOSTargetName(config.targetName, 'ios.targetName')
|
|
518
662
|
}
|
|
519
663
|
|
|
520
|
-
const widgets = (config.widgets ?? []).map((widget) => normalizeIOSWidget(projectRoot, widget))
|
|
664
|
+
const widgets = (config.widgets ?? []).map((widget) => normalizeIOSWidget(projectRoot, widget, warnings))
|
|
521
665
|
assertUniqueWidgetIds(
|
|
522
666
|
widgets.map((widget) => widget.id),
|
|
523
667
|
'ios'
|
|
524
668
|
)
|
|
669
|
+
assertUniqueIOSWidgetKinds(widgets)
|
|
525
670
|
|
|
526
671
|
return {
|
|
527
672
|
enablePushNotifications: config.enablePushNotifications ?? CLI_DEFAULTS.ios.enablePushNotifications,
|
|
@@ -537,7 +682,7 @@ function normalizeIOSConfig(
|
|
|
537
682
|
xcodeprojPath: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.xcodeprojPath),
|
|
538
683
|
mainTargetName: config.project?.mainTargetName,
|
|
539
684
|
infoPlistPath: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.infoPlistPath),
|
|
540
|
-
entitlementsPath:
|
|
685
|
+
entitlementsPath: resolveOptionalPerConfigurationPath(projectRoot, config.project?.entitlementsPath),
|
|
541
686
|
podfilePath: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.podfilePath),
|
|
542
687
|
},
|
|
543
688
|
}
|
|
@@ -552,11 +697,38 @@ export function normalizeVoltraConfig(loadedConfig: LoadedVoltraConfig): Normali
|
|
|
552
697
|
loadedConfig.config.projectRoot ?? loadedConfig.configDir
|
|
553
698
|
)
|
|
554
699
|
|
|
700
|
+
const warnings: string[] = []
|
|
701
|
+
const android = normalizeAndroidConfig(warnings, projectRoot, loadedConfig.config.android)
|
|
702
|
+
const ios = normalizeIOSConfig(warnings, projectRoot, loadedConfig.config.ios)
|
|
703
|
+
|
|
704
|
+
assertServerDrivenDynamicWidgetsAreSupported(ios)
|
|
705
|
+
|
|
555
706
|
return {
|
|
556
707
|
configPath: loadedConfig.configPath,
|
|
557
708
|
configDir: loadedConfig.configDir,
|
|
558
709
|
projectRoot,
|
|
559
|
-
android
|
|
560
|
-
ios
|
|
710
|
+
android,
|
|
711
|
+
ios,
|
|
712
|
+
warnings,
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* A Dynamic Widget commits fetched props to the App Group so the widget extension can read them,
|
|
718
|
+
* so a server-driven one without a `groupIdentifier` would fetch and have nowhere to put the
|
|
719
|
+
* result.
|
|
720
|
+
*/
|
|
721
|
+
function assertServerDrivenDynamicWidgetsAreSupported(ios: NormalizedVoltraIOSConfig | undefined): void {
|
|
722
|
+
if (ios === undefined || ios.groupIdentifier !== undefined) {
|
|
723
|
+
return
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
for (const widget of ios.widgets) {
|
|
727
|
+
if (widget.entry !== undefined && widget.serverUpdate !== undefined) {
|
|
728
|
+
throw new VoltraConfigNormalizationError(
|
|
729
|
+
`ios.widgets[${widget.id}] has both entry and serverUpdate, which requires ios.groupIdentifier ` +
|
|
730
|
+
'so fetched props can be shared with the widget extension.'
|
|
731
|
+
)
|
|
732
|
+
}
|
|
561
733
|
}
|
|
562
734
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A value that is either shared by every Xcode build configuration, or given once per build
|
|
3
|
+
* configuration name for apps that ship several environments from the same project.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* groupIdentifier: 'group.com.example.app'
|
|
7
|
+
* groupIdentifier: { Debug: 'group.com.example.app.dev', Release: 'group.com.example.app' }
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
export type PerConfiguration<TValue> = TValue | Record<string, TValue>
|
|
11
|
+
|
|
12
|
+
export function isPerConfigurationMap<TValue>(
|
|
13
|
+
value: PerConfiguration<TValue> | undefined
|
|
14
|
+
): value is Record<string, TValue> {
|
|
15
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Pairs a per-build-configuration value with the project's real build configurations, in the
|
|
20
|
+
* project's own order. Keys that name no build configuration, and build configurations the value
|
|
21
|
+
* does not cover, are reported before anything is written to the project.
|
|
22
|
+
*/
|
|
23
|
+
export function getValuesByBuildConfiguration<TValue>(
|
|
24
|
+
value: Record<string, TValue>,
|
|
25
|
+
buildConfigurationNames: string[],
|
|
26
|
+
context: string,
|
|
27
|
+
createError: (message: string) => Error
|
|
28
|
+
): Map<string, TValue> {
|
|
29
|
+
const knownNames = `This project has: ${buildConfigurationNames.join(', ')}.`
|
|
30
|
+
const unknownNames = Object.keys(value).filter((name) => !buildConfigurationNames.includes(name))
|
|
31
|
+
|
|
32
|
+
if (unknownNames.length > 0) {
|
|
33
|
+
throw createError(`${context} names unknown build configurations: ${unknownNames.join(', ')}. ${knownNames}`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const valuesByBuildConfiguration = new Map<string, TValue>()
|
|
37
|
+
|
|
38
|
+
for (const buildConfigurationName of buildConfigurationNames) {
|
|
39
|
+
const configurationValue = value[buildConfigurationName]
|
|
40
|
+
|
|
41
|
+
if (configurationValue === undefined) {
|
|
42
|
+
throw createError(`${context} has no value for build configuration '${buildConfigurationName}'. ${knownNames}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
valuesByBuildConfiguration.set(buildConfigurationName, configurationValue)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return valuesByBuildConfiguration
|
|
49
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config rules for `serverUpdate`, shared by the Expo plugins and the `voltra` CLI.
|
|
3
|
+
*
|
|
4
|
+
* `serverUpdate` marks a widget as server-driven for both render engines. On a widget with
|
|
5
|
+
* `entry` the device fetches a JSON object and hands it to the bundled JS as props; without
|
|
6
|
+
* `entry` the server returns a full Voltra payload. Widgets without `entry` keep exactly the
|
|
7
|
+
* rules they had before ADR 0002, so no existing config breaks.
|
|
8
|
+
*
|
|
9
|
+
* `url` is optional. `serverUpdate: {}` means "server-driven, URL supplied at runtime" through
|
|
10
|
+
* `setWidgetServerUpdate`, which covers per-tenant backends whose URL is only known after login.
|
|
11
|
+
*
|
|
12
|
+
* These helpers report problems instead of throwing so each caller can raise its own error type.
|
|
13
|
+
*
|
|
14
|
+
* The `@use-voltra/expo-plugin` package holds the other copy of this module, shared by the
|
|
15
|
+
* Expo config plugins; keep the two in sync.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Interval floor and default for a widget with `entry`. WorkManager cannot run periodic work
|
|
20
|
+
* more often than every 15 minutes, and WidgetKit stretches timelines requested closer together
|
|
21
|
+
* than five minutes, so a smaller number would only mislead.
|
|
22
|
+
*/
|
|
23
|
+
export const DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES = 15
|
|
24
|
+
|
|
25
|
+
/** Hosts allowed over plain `http`, for talking to a dev server from a simulator or emulator. */
|
|
26
|
+
const LOCAL_HTTP_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '10.0.2.2', '10.0.3.2'])
|
|
27
|
+
|
|
28
|
+
/** Outcome of resolving `serverUpdate.intervalMinutes` against a platform's floor. */
|
|
29
|
+
export type ServerUpdateIntervalResolution =
|
|
30
|
+
| { kind: 'ok'; intervalMinutes: number }
|
|
31
|
+
| { kind: 'clamped'; intervalMinutes: number; warning: string }
|
|
32
|
+
| { kind: 'invalid'; error: string }
|
|
33
|
+
|
|
34
|
+
export interface ResolveServerUpdateIntervalOptions {
|
|
35
|
+
/** Raw `intervalMinutes` from app.json, if the widget set one. */
|
|
36
|
+
intervalMinutes: unknown
|
|
37
|
+
/** Config path used in messages, e.g. `android.widgets[portfolio].serverUpdate`. */
|
|
38
|
+
context: string
|
|
39
|
+
/** True when the widget has an `entry` and therefore renders on device. */
|
|
40
|
+
hasEntry: boolean
|
|
41
|
+
/** Interval used when the widget does not set one. Ignored for widgets with `entry`. */
|
|
42
|
+
defaultIntervalMinutes: number
|
|
43
|
+
/** Platform floor for payload widgets. Ignored for widgets with `entry`. */
|
|
44
|
+
minimumIntervalMinutes: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolves the interval a widget should be scheduled on.
|
|
49
|
+
*
|
|
50
|
+
* A widget with `entry` is clamped up to {@link DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES}
|
|
51
|
+
* with a warning, because a shorter interval is not something either platform can honour. A
|
|
52
|
+
* payload widget keeps the platform's existing rule and is rejected below the floor.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveServerUpdateInterval(
|
|
55
|
+
options: ResolveServerUpdateIntervalOptions
|
|
56
|
+
): ServerUpdateIntervalResolution {
|
|
57
|
+
const { intervalMinutes, context, hasEntry, defaultIntervalMinutes, minimumIntervalMinutes } = options
|
|
58
|
+
const fallback = hasEntry ? DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES : defaultIntervalMinutes
|
|
59
|
+
|
|
60
|
+
if (intervalMinutes === undefined) {
|
|
61
|
+
return { kind: 'ok', intervalMinutes: fallback }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (typeof intervalMinutes !== 'number' || !Number.isFinite(intervalMinutes)) {
|
|
65
|
+
return { kind: 'invalid', error: `${context}.intervalMinutes must be a number` }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!Number.isInteger(intervalMinutes)) {
|
|
69
|
+
return { kind: 'invalid', error: `${context}.intervalMinutes must be an integer` }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (hasEntry) {
|
|
73
|
+
if (intervalMinutes < DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES) {
|
|
74
|
+
return {
|
|
75
|
+
kind: 'clamped',
|
|
76
|
+
intervalMinutes: DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES,
|
|
77
|
+
warning:
|
|
78
|
+
`${context}.intervalMinutes is ${intervalMinutes}, below the ` +
|
|
79
|
+
`${DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES} minute floor for widgets with an entry. ` +
|
|
80
|
+
`Using ${DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES}.`,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { kind: 'ok', intervalMinutes }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (intervalMinutes < minimumIntervalMinutes) {
|
|
88
|
+
return { kind: 'invalid', error: `${context}.intervalMinutes must be at least ${minimumIntervalMinutes}` }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { kind: 'ok', intervalMinutes }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Outcome of checking `serverUpdate.url`. */
|
|
95
|
+
export type ServerUpdateUrlResolution =
|
|
96
|
+
| { kind: 'ok' }
|
|
97
|
+
| { kind: 'insecure'; warning: string }
|
|
98
|
+
| { kind: 'invalid'; error: string }
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Checks `serverUpdate.url`. An absent URL is fine — the app supplies it at runtime through
|
|
102
|
+
* `setWidgetServerUpdate`.
|
|
103
|
+
*
|
|
104
|
+
* A URL that is not an absolute `http`/`https` URL is rejected: no platform HTTP stack here
|
|
105
|
+
* accepts one, so such a config could only ever have failed to fetch. Plain `http` to a
|
|
106
|
+
* non-local host is reported as insecure rather than rejected — App Transport Security and
|
|
107
|
+
* Android's cleartext policy already block it in a release build, and rejecting it outright
|
|
108
|
+
* would break configs that point at a LAN dev server.
|
|
109
|
+
*/
|
|
110
|
+
export function resolveServerUpdateUrl(url: unknown, context: string): ServerUpdateUrlResolution {
|
|
111
|
+
if (url === undefined) {
|
|
112
|
+
return { kind: 'ok' }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (typeof url !== 'string' || !url.trim()) {
|
|
116
|
+
return { kind: 'invalid', error: `${context}.url must be a non-empty string` }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let parsed: URL
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
parsed = new URL(url)
|
|
123
|
+
} catch {
|
|
124
|
+
return { kind: 'invalid', error: `${context}.url must be an absolute http(s) URL, received '${url}'` }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (parsed.protocol === 'https:') {
|
|
128
|
+
return { kind: 'ok' }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (parsed.protocol !== 'http:') {
|
|
132
|
+
return { kind: 'invalid', error: `${context}.url must be an absolute http(s) URL, received '${url}'` }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (isLocalHttpHost(parsed.hostname)) {
|
|
136
|
+
return { kind: 'ok' }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
kind: 'insecure',
|
|
141
|
+
warning:
|
|
142
|
+
`${context}.url uses plain http ('${url}'). Release builds block cleartext traffic, so the ` +
|
|
143
|
+
'widget will not fetch outside a development build. Use https, or a local dev host ' +
|
|
144
|
+
`(${[...LOCAL_HTTP_HOSTS].join(', ')}).`,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** True for the hosts Voltra allows over plain `http` — dev servers reachable from a simulator. */
|
|
149
|
+
export function isLocalHttpHost(hostname: string): boolean {
|
|
150
|
+
return LOCAL_HTTP_HOSTS.has(hostname.replace(/^\[|\]$/g, ''))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Validates `serverUpdate.refresh`. Returns an error message, or `undefined` when it is fine. */
|
|
154
|
+
export function validateServerUpdateRefresh(refresh: unknown, context: string): string | undefined {
|
|
155
|
+
if (refresh !== undefined && typeof refresh !== 'boolean') {
|
|
156
|
+
return `${context}.refresh must be a boolean`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return undefined
|
|
160
|
+
}
|