voltra 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/build/cjs/apply/index.js +4 -1
  2. package/build/cjs/apply/index.js.map +1 -1
  3. package/build/cjs/config/normalize.js +131 -29
  4. package/build/cjs/config/normalize.js.map +1 -1
  5. package/build/cjs/config/perConfiguration.js +29 -0
  6. package/build/cjs/config/perConfiguration.js.map +1 -0
  7. package/build/cjs/config/serverUpdate.js +119 -0
  8. package/build/cjs/config/serverUpdate.js.map +1 -0
  9. package/build/cjs/config/widgetKind.js +25 -0
  10. package/build/cjs/config/widgetKind.js.map +1 -0
  11. package/build/cjs/dependencies/platformPackages.js +9 -0
  12. package/build/cjs/dependencies/platformPackages.js.map +1 -1
  13. package/build/cjs/discovery/ios.js +50 -31
  14. package/build/cjs/discovery/ios.js.map +1 -1
  15. package/build/cjs/index.js +6 -2
  16. package/build/cjs/index.js.map +1 -1
  17. package/build/cjs/platforms/android/generated.js +98 -40
  18. package/build/cjs/platforms/android/generated.js.map +1 -1
  19. package/build/cjs/platforms/android/widgetSizing.js +89 -0
  20. package/build/cjs/platforms/android/widgetSizing.js.map +1 -0
  21. package/build/cjs/platforms/ios/apply.js +21 -11
  22. package/build/cjs/platforms/ios/apply.js.map +1 -1
  23. package/build/cjs/platforms/ios/buildConfigurationValues.js +73 -0
  24. package/build/cjs/platforms/ios/buildConfigurationValues.js.map +1 -0
  25. package/build/cjs/platforms/ios/entitlements.js +34 -19
  26. package/build/cjs/platforms/ios/entitlements.js.map +1 -1
  27. package/build/cjs/platforms/ios/generated.js +79 -32
  28. package/build/cjs/platforms/ios/generated.js.map +1 -1
  29. package/build/cjs/platforms/ios/plist.js +22 -4
  30. package/build/cjs/platforms/ios/plist.js.map +1 -1
  31. package/build/cjs/platforms/ios/targetName.js.map +1 -1
  32. package/build/cjs/platforms/ios/xcodeTarget.js +166 -23
  33. package/build/cjs/platforms/ios/xcodeTarget.js.map +1 -1
  34. package/build/cjs/platforms/shared/widgetModule.js +18 -145
  35. package/build/cjs/platforms/shared/widgetModule.js.map +1 -1
  36. package/build/esm/apply/index.js +4 -1
  37. package/build/esm/apply/index.js.map +1 -1
  38. package/build/esm/config/normalize.js +131 -29
  39. package/build/esm/config/normalize.js.map +1 -1
  40. package/build/esm/config/perConfiguration.js +25 -0
  41. package/build/esm/config/perConfiguration.js.map +1 -0
  42. package/build/esm/config/serverUpdate.js +112 -0
  43. package/build/esm/config/serverUpdate.js.map +1 -0
  44. package/build/esm/config/widgetKind.js +20 -0
  45. package/build/esm/config/widgetKind.js.map +1 -0
  46. package/build/esm/dependencies/platformPackages.js +8 -0
  47. package/build/esm/dependencies/platformPackages.js.map +1 -1
  48. package/build/esm/discovery/ios.js +50 -31
  49. package/build/esm/discovery/ios.js.map +1 -1
  50. package/build/esm/index.js +1 -0
  51. package/build/esm/index.js.map +1 -1
  52. package/build/esm/platforms/android/generated.js +100 -42
  53. package/build/esm/platforms/android/generated.js.map +1 -1
  54. package/build/esm/platforms/android/widgetSizing.js +85 -0
  55. package/build/esm/platforms/android/widgetSizing.js.map +1 -0
  56. package/build/esm/platforms/ios/apply.js +21 -11
  57. package/build/esm/platforms/ios/apply.js.map +1 -1
  58. package/build/esm/platforms/ios/buildConfigurationValues.js +68 -0
  59. package/build/esm/platforms/ios/buildConfigurationValues.js.map +1 -0
  60. package/build/esm/platforms/ios/entitlements.js +34 -19
  61. package/build/esm/platforms/ios/entitlements.js.map +1 -1
  62. package/build/esm/platforms/ios/generated.js +80 -33
  63. package/build/esm/platforms/ios/generated.js.map +1 -1
  64. package/build/esm/platforms/ios/plist.js +22 -4
  65. package/build/esm/platforms/ios/plist.js.map +1 -1
  66. package/build/esm/platforms/ios/targetName.js.map +1 -1
  67. package/build/esm/platforms/ios/xcodeTarget.js +166 -23
  68. package/build/esm/platforms/ios/xcodeTarget.js.map +1 -1
  69. package/build/esm/platforms/shared/widgetModule.js +16 -107
  70. package/build/esm/platforms/shared/widgetModule.js.map +1 -1
  71. package/build/types/apply/index.d.ts.map +1 -1
  72. package/build/types/config/normalize.d.ts.map +1 -1
  73. package/build/types/config/perConfiguration.d.ts +18 -0
  74. package/build/types/config/perConfiguration.d.ts.map +1 -0
  75. package/build/types/config/serverUpdate.d.ts +80 -0
  76. package/build/types/config/serverUpdate.d.ts.map +1 -0
  77. package/build/types/config/types.d.ts +91 -30
  78. package/build/types/config/types.d.ts.map +1 -1
  79. package/build/types/config/widgetKind.d.ts +11 -0
  80. package/build/types/config/widgetKind.d.ts.map +1 -0
  81. package/build/types/dependencies/platformPackages.d.ts +1 -0
  82. package/build/types/dependencies/platformPackages.d.ts.map +1 -1
  83. package/build/types/discovery/ios.d.ts +10 -0
  84. package/build/types/discovery/ios.d.ts.map +1 -1
  85. package/build/types/index.d.ts +3 -1
  86. package/build/types/index.d.ts.map +1 -1
  87. package/build/types/platforms/android/generated.d.ts.map +1 -1
  88. package/build/types/platforms/android/widgetSizing.d.ts +50 -0
  89. package/build/types/platforms/android/widgetSizing.d.ts.map +1 -0
  90. package/build/types/platforms/ios/apply.d.ts.map +1 -1
  91. package/build/types/platforms/ios/buildConfigurationValues.d.ts +33 -0
  92. package/build/types/platforms/ios/buildConfigurationValues.d.ts.map +1 -0
  93. package/build/types/platforms/ios/entitlements.d.ts +4 -4
  94. package/build/types/platforms/ios/entitlements.d.ts.map +1 -1
  95. package/build/types/platforms/ios/generated.d.ts +14 -2
  96. package/build/types/platforms/ios/generated.d.ts.map +1 -1
  97. package/build/types/platforms/ios/plist.d.ts +3 -3
  98. package/build/types/platforms/ios/plist.d.ts.map +1 -1
  99. package/build/types/platforms/ios/podfile.d.ts +2 -2
  100. package/build/types/platforms/ios/podfile.d.ts.map +1 -1
  101. package/build/types/platforms/ios/targetName.d.ts +2 -2
  102. package/build/types/platforms/ios/targetName.d.ts.map +1 -1
  103. package/build/types/platforms/ios/xcodeTarget.d.ts +4 -2
  104. package/build/types/platforms/ios/xcodeTarget.d.ts.map +1 -1
  105. package/build/types/platforms/shared/widgetModule.d.ts +12 -3
  106. package/build/types/platforms/shared/widgetModule.d.ts.map +1 -1
  107. package/package.json +5 -5
  108. package/src/apply/index.ts +4 -1
  109. package/src/config/normalize.node.test.ts +61 -0
  110. package/src/config/normalize.ts +188 -44
  111. package/src/config/perConfiguration.ts +49 -0
  112. package/src/config/serverUpdate.ts +160 -0
  113. package/src/config/types.ts +97 -30
  114. package/src/config/widgetKind.node.test.ts +27 -0
  115. package/src/config/widgetKind.ts +27 -0
  116. package/src/dependencies/platformPackages.ts +11 -0
  117. package/src/discovery/ios.ts +104 -60
  118. package/src/index.ts +8 -0
  119. package/src/platforms/android/generated.ts +144 -47
  120. package/src/platforms/android/widgetSizing.ts +112 -0
  121. package/src/platforms/ios/apply.ts +21 -11
  122. package/src/platforms/ios/buildConfigurationValues.ts +105 -0
  123. package/src/platforms/ios/entitlements.ts +55 -31
  124. package/src/platforms/ios/generated.node.test.ts +61 -0
  125. package/src/platforms/ios/generated.ts +137 -38
  126. package/src/platforms/ios/plist.ts +36 -7
  127. package/src/platforms/ios/podfile.ts +2 -2
  128. package/src/platforms/ios/targetName.ts +2 -2
  129. package/src/platforms/ios/xcodeTarget.ts +248 -43
  130. package/src/platforms/shared/widgetModule.ts +22 -140
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  import type { CLI_DEFAULTS } from './defaults'
2
+ import type { PerConfiguration } from './perConfiguration'
2
3
 
3
4
  export type VoltraPlatform = 'android' | 'ios'
4
5
 
@@ -15,8 +16,11 @@ export type WidgetLabel = string | WidgetLocalizedValue
15
16
  export type WidgetInitialStatePath = string | WidgetLocalizedValue
16
17
 
17
18
  export interface AndroidWidgetServerUpdateConfig {
18
- /** Server endpoint that returns widget state updates. */
19
- url: string
19
+ /**
20
+ * Server endpoint that returns widget state updates. Optional — omit it to mark the widget
21
+ * server-driven and supply the URL at runtime with `setWidgetServerUpdate`.
22
+ */
23
+ url?: string
20
24
  /** Refresh interval, in minutes, for fetching server updates. */
21
25
  intervalMinutes?: number
22
26
  /** Whether fetched updates should trigger an immediate widget refresh. */
@@ -44,14 +48,32 @@ export interface AndroidWidgetConfig {
44
48
  displayName: WidgetLabel
45
49
  /** User-facing widget description shown by the launcher. */
46
50
  description: WidgetLabel
47
- /** Minimum widget width in dp. */
51
+ /** Minimum widget width in dp. Only affects Android 11 and older. */
48
52
  minWidth?: number
49
- /** Minimum widget height in dp. */
53
+ /** Minimum widget height in dp. Only affects Android 11 and older. */
50
54
  minHeight?: number
51
- /** Minimum widget width in launcher grid cells. */
55
+ /**
56
+ * Minimum widget width in launcher grid cells.
57
+ *
58
+ * @deprecated Use `minWidth` instead. The value is approximated in dp for Android 11 and
59
+ * older.
60
+ */
52
61
  minCellWidth?: number
53
- /** Minimum widget height in launcher grid cells. */
62
+ /**
63
+ * Minimum widget height in launcher grid cells.
64
+ *
65
+ * @deprecated Use `minHeight` instead. The value is approximated in dp for Android 11 and
66
+ * older.
67
+ */
54
68
  minCellHeight?: number
69
+ /** Minimum width, in dp, the widget can be resized down to. */
70
+ minResizeWidth?: number
71
+ /** Minimum height, in dp, the widget can be resized down to. */
72
+ minResizeHeight?: number
73
+ /** Maximum width, in dp, the widget can be resized up to. Honoured on Android 12 and newer. */
74
+ maxResizeWidth?: number
75
+ /** Maximum height, in dp, the widget can be resized up to. Honoured on Android 12 and newer. */
76
+ maxResizeHeight?: number
55
77
  /** Default widget width in launcher grid cells. */
56
78
  targetCellWidth: number
57
79
  /** Default widget height in launcher grid cells. */
@@ -84,8 +106,11 @@ export type IOSWidgetFamily =
84
106
  | 'accessoryInline'
85
107
 
86
108
  export interface IOSWidgetServerUpdateConfig {
87
- /** Server endpoint that returns widget state updates. */
88
- url: string
109
+ /**
110
+ * Server endpoint that returns widget state updates. Optional — omit it to mark the widget
111
+ * server-driven and supply the URL at runtime with `setWidgetServerUpdate`.
112
+ */
113
+ url?: string
89
114
  /** Refresh interval, in minutes, for fetching server updates. */
90
115
  intervalMinutes?: number
91
116
  /** Whether fetched updates should trigger an immediate widget refresh. */
@@ -109,6 +134,12 @@ export interface IOSWidgetAppIntentConfig {
109
134
  export interface IOSWidgetConfig {
110
135
  /** Stable widget identifier used in generated files and registrations. */
111
136
  id: string
137
+ /**
138
+ * WidgetKit `kind` of the generated widget. Defaults to `Voltra_Widget_<id>`.
139
+ * Pin it to the kind of a pre-Voltra widget so already placed instances survive the migration
140
+ * (WidgetKit identifies a placed widget by extension bundle id + kind).
141
+ */
142
+ kind?: string
112
143
  /** User-facing widget name shown in iOS widget configuration UI. */
113
144
  displayName: WidgetLabel
114
145
  /** User-facing widget description shown in iOS widget configuration UI. */
@@ -145,8 +176,11 @@ export interface IOSProjectOverrides {
145
176
  mainTargetName?: string
146
177
  /** Explicit path to the app target Info.plist file. */
147
178
  infoPlistPath?: string
148
- /** Explicit path to the main app entitlements file. */
149
- entitlementsPath?: string
179
+ /**
180
+ * Explicit path to the main app entitlements file, or one path per Xcode build configuration
181
+ * name when each environment has its own.
182
+ */
183
+ entitlementsPath?: PerConfiguration<string>
150
184
  /** Explicit path to the Podfile. */
151
185
  podfilePath?: string
152
186
  }
@@ -175,8 +209,11 @@ export interface VoltraAndroidConfig {
175
209
  export interface VoltraIOSConfig {
176
210
  /** Whether to enable push-notification-related iOS setup for widgets and Live Activities. */
177
211
  enablePushNotifications?: boolean
178
- /** App Group identifier used to share data between the app and widget extension. */
179
- groupIdentifier?: string
212
+ /**
213
+ * App Group identifier used to share data between the app and widget extension, or one per Xcode
214
+ * build configuration name when each environment has its own.
215
+ */
216
+ groupIdentifier?: PerConfiguration<string>
180
217
  /** iOS widgets to generate and register. */
181
218
  widgets?: IOSWidgetConfig[]
182
219
  /** Minimum iOS deployment target for generated widget targets. */
@@ -187,8 +224,11 @@ export interface VoltraIOSConfig {
187
224
  fonts?: string[]
188
225
  /** Directory containing user-provided images for iOS widgets. */
189
226
  userImagesPath?: string
190
- /** Keychain access group shared by the app and extension. */
191
- keychainGroup?: string
227
+ /**
228
+ * Keychain access group shared by the app and extension, or one per Xcode build configuration
229
+ * name when each environment has its own.
230
+ */
231
+ keychainGroup?: PerConfiguration<string>
192
232
  /** Native iOS project discovery overrides. */
193
233
  project?: IOSProjectOverrides
194
234
  }
@@ -211,28 +251,27 @@ export interface LoadedVoltraConfig {
211
251
  configDir: string
212
252
  }
213
253
 
214
- export interface NormalizedAndroidWidgetServerUpdateConfig {
215
- /** Server endpoint that returns widget state updates. */
216
- url: string
254
+ /**
255
+ * Build-time server-update defaults after normalization. The device treats these as the lowest
256
+ * settings layer; `setWidgetServerUpdate` overrides `url` and `intervalMinutes` at runtime.
257
+ */
258
+ export interface NormalizedWidgetServerUpdateConfig {
259
+ /** Server endpoint, when app.json set one. Absent means "URL supplied at runtime". */
260
+ url?: string
217
261
  /** Refresh interval, in minutes, for fetching server updates. */
218
262
  intervalMinutes: number
219
- /** Whether fetched updates should trigger an immediate widget refresh. */
263
+ /** Whether the widget draws a refresh button. Build-time only: it is generated UI structure. */
220
264
  refresh: boolean
221
265
  }
222
266
 
267
+ export type NormalizedAndroidWidgetServerUpdateConfig = NormalizedWidgetServerUpdateConfig
268
+
223
269
  export interface NormalizedAndroidWidgetConfig extends Omit<AndroidWidgetConfig, 'serverUpdate'> {
224
270
  /** Server-driven update settings after defaults have been applied. */
225
271
  serverUpdate?: NormalizedAndroidWidgetServerUpdateConfig
226
272
  }
227
273
 
228
- export interface NormalizedIOSWidgetServerUpdateConfig {
229
- /** Server endpoint that returns widget state updates. */
230
- url: string
231
- /** Refresh interval, in minutes, for fetching server updates. */
232
- intervalMinutes: number
233
- /** Whether fetched updates should trigger an immediate widget refresh. */
234
- refresh: boolean
235
- }
274
+ export type NormalizedIOSWidgetServerUpdateConfig = NormalizedWidgetServerUpdateConfig
236
275
 
237
276
  export interface NormalizedIOSWidgetConfig extends Omit<IOSWidgetConfig, 'serverUpdate' | 'supportedFamilies'> {
238
277
  /** Supported iOS widget families after defaults have been applied. */
@@ -261,8 +300,8 @@ export interface NormalizedIOSProjectConfig {
261
300
  mainTargetName?: string
262
301
  /** Absolute path to the Info.plist file, if overridden. */
263
302
  infoPlistPath?: string
264
- /** Absolute path to the entitlements file, if overridden. */
265
- entitlementsPath?: string
303
+ /** Absolute path to the entitlements file, or one per build configuration name, if overridden. */
304
+ entitlementsPath?: PerConfiguration<string>
266
305
  /** Absolute path to the Podfile, if overridden. */
267
306
  podfilePath?: string
268
307
  }
@@ -284,7 +323,7 @@ export interface NormalizedVoltraIOSConfig {
284
323
  /** Whether iOS push-notification-related setup should be applied. */
285
324
  enablePushNotifications: boolean
286
325
  /** App Group identifier used to share data between the app and extension. */
287
- groupIdentifier?: string
326
+ groupIdentifier?: PerConfiguration<string>
288
327
  /** iOS widgets after validation and normalization. */
289
328
  widgets: NormalizedIOSWidgetConfig[]
290
329
  /** Effective iOS deployment target for generated widget targets. */
@@ -296,11 +335,37 @@ export interface NormalizedVoltraIOSConfig {
296
335
  /** Absolute path to the iOS user images directory. */
297
336
  userImagesPath: string
298
337
  /** Keychain access group shared by the app and extension. */
299
- keychainGroup?: string
338
+ keychainGroup?: PerConfiguration<string>
300
339
  /** Normalized iOS native project discovery overrides. */
301
340
  project: NormalizedIOSProjectConfig
302
341
  }
303
342
 
343
+ /**
344
+ * iOS project overrides after per-build-configuration values have been resolved against the Xcode
345
+ * project.
346
+ */
347
+ export type ResolvedIOSProjectConfig = Omit<NormalizedIOSProjectConfig, 'entitlementsPath'> & {
348
+ /** Absolute path to the entitlements file of the default build configuration, if overridden. */
349
+ entitlementsPath?: string
350
+ }
351
+
352
+ /**
353
+ * iOS config as the platform mutators consume it: every per-build-configuration value has been
354
+ * collapsed to a single string, either the configured value or a reference to a build setting
355
+ * Voltra writes per build configuration.
356
+ */
357
+ export type ResolvedVoltraIOSConfig = Omit<
358
+ NormalizedVoltraIOSConfig,
359
+ 'groupIdentifier' | 'keychainGroup' | 'project'
360
+ > & {
361
+ /** App Group identifier used to share data between the app and extension. */
362
+ groupIdentifier?: string
363
+ /** Keychain access group shared by the app and extension. */
364
+ keychainGroup?: string
365
+ /** Resolved iOS native project discovery overrides. */
366
+ project: ResolvedIOSProjectConfig
367
+ }
368
+
304
369
  export interface NormalizedVoltraConfig {
305
370
  /** Absolute path to the loaded config file, when the config came from a file. */
306
371
  configPath?: string
@@ -312,6 +377,8 @@ export interface NormalizedVoltraConfig {
312
377
  android?: NormalizedVoltraAndroidConfig
313
378
  /** Normalized iOS-specific Voltra configuration. */
314
379
  ios?: NormalizedVoltraIOSConfig
380
+ /** Non-fatal config problems, surfaced in the `voltra apply` summary. */
381
+ warnings?: string[]
315
382
  }
316
383
 
317
384
  export type CliDefaults = typeof CLI_DEFAULTS
@@ -0,0 +1,27 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, test } from 'node:test'
3
+
4
+ import { iosWidgetKind, iosWidgetKindOverrides } from './widgetKind.ts'
5
+
6
+ describe('iosWidgetKind', () => {
7
+ test('defaults to the prefixed widget id', () => {
8
+ assert.equal(iosWidgetKind({ id: 'weather' }), 'Voltra_Widget_weather')
9
+ })
10
+
11
+ test('uses the pinned kind when the widget has one', () => {
12
+ assert.equal(iosWidgetKind({ id: 'streak', kind: 'StreakWidget' }), 'StreakWidget')
13
+ })
14
+ })
15
+
16
+ describe('iosWidgetKindOverrides', () => {
17
+ test('is undefined when no widget pins a kind, so the Info.plist key stays absent', () => {
18
+ assert.equal(iosWidgetKindOverrides([{ id: 'weather' }, { id: 'streak' }]), undefined)
19
+ assert.equal(iosWidgetKindOverrides([]), undefined)
20
+ })
21
+
22
+ test('maps only the widgets that pin a kind', () => {
23
+ const overrides = iosWidgetKindOverrides([{ id: 'weather' }, { id: 'streak', kind: 'StreakWidget' }])
24
+
25
+ assert.deepEqual(overrides, { streak: 'StreakWidget' })
26
+ })
27
+ })
@@ -0,0 +1,27 @@
1
+ import type { IOSWidgetConfig } from './types'
2
+
3
+ /** Default prefix of a widget's WidgetKit `kind`: `Voltra_Widget_<id>`. Mirrors `VoltraStorageKeys.widgetKindPrefix`. */
4
+ export const IOS_WIDGET_KIND_PREFIX = 'Voltra_Widget_'
5
+
6
+ /** WidgetKit `kind` of a widget: the `kind` option when set, else `Voltra_Widget_<id>`. */
7
+ export function iosWidgetKind(widget: Pick<IOSWidgetConfig, 'id' | 'kind'>): string {
8
+ return widget.kind ?? `${IOS_WIDGET_KIND_PREFIX}${widget.id}`
9
+ }
10
+
11
+ /**
12
+ * `{ id: kind }` for the widgets that pin a custom `kind`, or undefined when none does.
13
+ * Written to both Info.plists (`Voltra_WidgetKinds`) so native code maps ids to kinds and back.
14
+ */
15
+ export function iosWidgetKindOverrides(
16
+ widgets: Pick<IOSWidgetConfig, 'id' | 'kind'>[]
17
+ ): Record<string, string> | undefined {
18
+ const overrides: Record<string, string> = {}
19
+
20
+ for (const widget of widgets) {
21
+ if (widget.kind !== undefined) {
22
+ overrides[widget.id] = widget.kind
23
+ }
24
+ }
25
+
26
+ return Object.keys(overrides).length > 0 ? overrides : undefined
27
+ }
@@ -36,6 +36,17 @@ export function requirePlatformPackage<TPackage>(projectRoot: string, platform:
36
36
  return createProjectRequire(projectRoot)(getPlatformPackageName(platform)) as TPackage
37
37
  }
38
38
 
39
+ export function getPlatformClientPackageVersion(projectRoot: string, platform: VoltraPlatform): string {
40
+ const packageName = getPlatformClientPackageName(platform)
41
+ const manifest = createProjectRequire(projectRoot)(`${packageName}/package.json`) as { version?: unknown }
42
+
43
+ if (typeof manifest.version !== 'string' || manifest.version.length === 0) {
44
+ throw new Error(`Package ${packageName} does not declare a valid version.`)
45
+ }
46
+
47
+ return manifest.version
48
+ }
49
+
39
50
  export function getMissingPlatformPackageMessage(
40
51
  platform: VoltraPlatform,
41
52
  packageNames = getRequiredPlatformPackageNames(platform)