vxrn 2.0.0-beta.98.1 → 2.6.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 (210) hide show
  1. package/dist/backgroundComputation.mjs +145 -0
  2. package/dist/backgroundComputation.mjs.map +1 -0
  3. package/dist/backgroundComputation.native.js +163 -0
  4. package/dist/backgroundComputation.native.js.map +1 -0
  5. package/dist/backgroundComputationNative.mjs +16 -0
  6. package/dist/backgroundComputationNative.mjs.map +1 -0
  7. package/dist/backgroundComputationNative.native.js +18 -0
  8. package/dist/backgroundComputationNative.native.js.map +1 -0
  9. package/dist/config/getBaseViteConfigOnly.mjs +6 -10
  10. package/dist/config/getBaseViteConfigOnly.mjs.map +1 -1
  11. package/dist/config/getBaseViteConfigOnly.native.js +6 -10
  12. package/dist/config/getBaseViteConfigOnly.native.js.map +1 -1
  13. package/dist/config/getOptimizeDeps.mjs +2 -1
  14. package/dist/config/getOptimizeDeps.mjs.map +1 -1
  15. package/dist/config/getOptimizeDeps.native.js +2 -1
  16. package/dist/config/getOptimizeDeps.native.js.map +1 -1
  17. package/dist/config/getViteServerConfig.mjs +13 -0
  18. package/dist/config/getViteServerConfig.mjs.map +1 -1
  19. package/dist/config/getViteServerConfig.native.js +15 -1
  20. package/dist/config/getViteServerConfig.native.js.map +1 -1
  21. package/dist/constants/defaults.mjs +4 -0
  22. package/dist/constants/defaults.mjs.map +1 -1
  23. package/dist/constants/defaults.native.js +4 -0
  24. package/dist/constants/defaults.native.js.map +1 -1
  25. package/dist/exports/build.native.js +1 -1
  26. package/dist/exports/build.native.js.map +1 -1
  27. package/dist/exports/clean.native.js +1 -1
  28. package/dist/exports/clean.native.js.map +1 -1
  29. package/dist/exports/createServer.native.js +2 -2
  30. package/dist/exports/createServer.native.js.map +1 -1
  31. package/dist/exports/dev.mjs +3 -0
  32. package/dist/exports/dev.mjs.map +1 -1
  33. package/dist/exports/dev.native.js +6 -3
  34. package/dist/exports/dev.native.js.map +1 -1
  35. package/dist/exports/loadEnv.native.js +1 -1
  36. package/dist/exports/loadEnv.native.js.map +1 -1
  37. package/dist/exports/patch.native.js +1 -1
  38. package/dist/exports/patch.native.js.map +1 -1
  39. package/dist/exports/prebuild.mjs +0 -22
  40. package/dist/exports/prebuild.mjs.map +1 -1
  41. package/dist/exports/prebuild.native.js +1 -23
  42. package/dist/exports/prebuild.native.js.map +1 -1
  43. package/dist/exports/prebuildWithoutExpo.mjs +1133 -182
  44. package/dist/exports/prebuildWithoutExpo.mjs.map +1 -1
  45. package/dist/exports/prebuildWithoutExpo.native.js +1606 -244
  46. package/dist/exports/prebuildWithoutExpo.native.js.map +1 -1
  47. package/dist/exports/runAndroid.native.js +1 -1
  48. package/dist/exports/runAndroid.native.js.map +1 -1
  49. package/dist/exports/runIos.native.js +1 -1
  50. package/dist/exports/runIos.native.js.map +1 -1
  51. package/dist/exports/serve.native.js +1 -1
  52. package/dist/exports/serve.native.js.map +1 -1
  53. package/dist/exports/serveStaticAssets.native.js +1 -1
  54. package/dist/exports/serveStaticAssets.native.js.map +1 -1
  55. package/dist/index.js +4 -1
  56. package/dist/index.mjs +4 -1
  57. package/dist/index.native.js +4 -1
  58. package/dist/native-source-contract.mjs +3 -0
  59. package/dist/native-source-contract.native.js +3 -0
  60. package/dist/patches/builtInDepPatches.mjs +0 -13
  61. package/dist/patches/builtInDepPatches.mjs.map +1 -1
  62. package/dist/patches/builtInDepPatches.native.js +0 -13
  63. package/dist/patches/builtInDepPatches.native.js.map +1 -1
  64. package/dist/plugins/autoDepOptimizePlugin.mjs +3 -2
  65. package/dist/plugins/autoDepOptimizePlugin.mjs.map +1 -1
  66. package/dist/plugins/autoDepOptimizePlugin.native.js +6 -3
  67. package/dist/plugins/autoDepOptimizePlugin.native.js.map +1 -1
  68. package/dist/plugins/backgroundComputationPlugin.mjs +27 -0
  69. package/dist/plugins/backgroundComputationPlugin.mjs.map +1 -0
  70. package/dist/plugins/backgroundComputationPlugin.native.js +32 -0
  71. package/dist/plugins/backgroundComputationPlugin.native.js.map +1 -0
  72. package/dist/plugins/reactNativeCommonJsPlugin.native.js +3 -3
  73. package/dist/plugins/reactNativeCommonJsPlugin.native.js.map +1 -1
  74. package/dist/plugins/reactNativeDevAssetPlugin.mjs +2 -1
  75. package/dist/plugins/reactNativeDevAssetPlugin.mjs.map +1 -1
  76. package/dist/plugins/reactNativeDevAssetPlugin.native.js +3 -2
  77. package/dist/plugins/reactNativeDevAssetPlugin.native.js.map +1 -1
  78. package/dist/plugins/reactNativeDevServer.mjs +87 -24
  79. package/dist/plugins/reactNativeDevServer.mjs.map +1 -1
  80. package/dist/plugins/reactNativeDevServer.native.js +162 -45
  81. package/dist/plugins/reactNativeDevServer.native.js.map +1 -1
  82. package/dist/plugins/workletImportsPlugin.mjs +9 -30
  83. package/dist/plugins/workletImportsPlugin.mjs.map +1 -1
  84. package/dist/plugins/workletImportsPlugin.native.js +10 -35
  85. package/dist/plugins/workletImportsPlugin.native.js.map +1 -1
  86. package/dist/rn-commands/bundle/buildBundle.mjs +3 -3
  87. package/dist/rn-commands/bundle/buildBundle.mjs.map +1 -1
  88. package/dist/rn-commands/bundle/buildBundle.native.js +3 -3
  89. package/dist/rn-commands/bundle/buildBundle.native.js.map +1 -1
  90. package/dist/runtime/hmr-client.native.js +3 -7
  91. package/dist/runtime/hmr-client.native.js.map +1 -1
  92. package/dist/runtime/hmr-runtime.native.js +32 -40
  93. package/dist/runtime/hmr-runtime.native.js.map +1 -1
  94. package/dist/serve/node.native.js +2 -2
  95. package/dist/serve/node.native.js.map +1 -1
  96. package/dist/swift-package-id.mjs +3 -0
  97. package/dist/swift-package-id.native.js +3 -0
  98. package/dist/user-interface/index.native.js +6 -6
  99. package/dist/user-interface/index.native.js.map +1 -1
  100. package/dist/utils/compileNativeDevBytecode.mjs +93 -0
  101. package/dist/utils/compileNativeDevBytecode.mjs.map +1 -0
  102. package/dist/utils/compileNativeDevBytecode.native.js +98 -0
  103. package/dist/utils/compileNativeDevBytecode.native.js.map +1 -0
  104. package/dist/utils/createNativeDevEngine.mjs +227 -20
  105. package/dist/utils/createNativeDevEngine.mjs.map +1 -1
  106. package/dist/utils/createNativeDevEngine.native.js +249 -37
  107. package/dist/utils/createNativeDevEngine.native.js.map +1 -1
  108. package/dist/utils/execSync.native.js +1 -1
  109. package/dist/utils/execSync.native.js.map +1 -1
  110. package/dist/utils/getServerEntry.native.js +2 -2
  111. package/dist/utils/getServerEntry.native.js.map +1 -1
  112. package/dist/utils/nativeRun.mjs +32 -16
  113. package/dist/utils/nativeRun.mjs.map +1 -1
  114. package/dist/utils/nativeRun.native.js +43 -18
  115. package/dist/utils/nativeRun.native.js.map +1 -1
  116. package/dist/utils/nativeSourceContract.mjs +1823 -0
  117. package/dist/utils/nativeSourceContract.mjs.map +1 -0
  118. package/dist/utils/nativeSourceContract.native.js +2425 -0
  119. package/dist/utils/nativeSourceContract.native.js.map +1 -0
  120. package/dist/utils/patches.native.js +15 -24
  121. package/dist/utils/patches.native.js.map +1 -1
  122. package/dist/utils/printServerUrls.native.js +1 -1
  123. package/dist/utils/printServerUrls.native.js.map +1 -1
  124. package/dist/utils/scanDepsToOptimize.mjs +7 -1
  125. package/dist/utils/scanDepsToOptimize.mjs.map +1 -1
  126. package/dist/utils/scanDepsToOptimize.native.js +27 -2
  127. package/dist/utils/scanDepsToOptimize.native.js.map +1 -1
  128. package/dist/utils/swiftPackageId.mjs +31 -2
  129. package/dist/utils/swiftPackageId.mjs.map +1 -1
  130. package/dist/utils/swiftPackageId.native.js +50 -2
  131. package/dist/utils/swiftPackageId.native.js.map +1 -1
  132. package/dist/vxrn-vite-plugin.native.js +1 -1
  133. package/dist/vxrn-vite-plugin.native.js.map +1 -1
  134. package/expo-plugin.cjs +792 -2
  135. package/native-project-patches.cjs +201 -10
  136. package/package.json +34 -15
  137. package/src/backgroundComputation.ts +220 -0
  138. package/src/backgroundComputationNative.ts +23 -0
  139. package/src/config/getBaseViteConfigOnly.ts +8 -9
  140. package/src/config/getOptimizeDeps.ts +3 -1
  141. package/src/config/getViteServerConfig.ts +16 -0
  142. package/src/config/glbAssets.test.ts +228 -0
  143. package/src/constants/defaults.ts +6 -0
  144. package/src/exports/dev.ts +5 -1
  145. package/src/exports/expoPlugin.test.mjs +337 -15
  146. package/src/exports/prebuild.ts +0 -31
  147. package/src/exports/prebuildWithoutExpo.test.ts +1623 -46
  148. package/src/exports/prebuildWithoutExpo.ts +1606 -216
  149. package/src/index.ts +8 -0
  150. package/src/native-source-contract.ts +16 -0
  151. package/src/patches/builtInDepPatches.ts +0 -21
  152. package/src/plugins/autoDepOptimizePlugin.ts +7 -2
  153. package/src/plugins/backgroundComputationPlugin.test.ts +77 -0
  154. package/src/plugins/backgroundComputationPlugin.ts +29 -0
  155. package/src/plugins/reactNativeDevAssetPlugin.ts +5 -1
  156. package/src/plugins/reactNativeDevServer.test.ts +74 -0
  157. package/src/plugins/reactNativeDevServer.ts +145 -28
  158. package/src/plugins/rnCodegenPlugin.test.ts +30 -0
  159. package/src/plugins/workletImportsPlugin.ts +19 -37
  160. package/src/rn-commands/bundle/buildBundle.ts +5 -3
  161. package/src/swift-package-id.ts +1 -0
  162. package/src/utils/compileNativeDevBytecode.test.ts +168 -0
  163. package/src/utils/compileNativeDevBytecode.ts +117 -0
  164. package/src/utils/createNativeDevEngine.test.ts +643 -18
  165. package/src/utils/createNativeDevEngine.ts +278 -48
  166. package/src/utils/nativeRun.test.ts +125 -23
  167. package/src/utils/nativeRun.ts +51 -23
  168. package/src/utils/nativeSourceArtifacts.test.ts +39 -0
  169. package/src/utils/nativeSourceContract.test.ts +600 -0
  170. package/src/utils/nativeSourceContract.ts +1897 -0
  171. package/src/utils/scanDepsToOptimize.test.ts +80 -0
  172. package/src/utils/scanDepsToOptimize.ts +18 -7
  173. package/src/utils/swiftPackageId.ts +25 -1
  174. package/types/backgroundComputation.d.ts +28 -0
  175. package/types/backgroundComputation.d.ts.map +1 -0
  176. package/types/backgroundComputationNative.d.ts +2 -0
  177. package/types/backgroundComputationNative.d.ts.map +1 -0
  178. package/types/config/getBaseViteConfigOnly.d.ts.map +1 -1
  179. package/types/config/getOptimizeDeps.d.ts.map +1 -1
  180. package/types/config/getViteServerConfig.d.ts.map +1 -1
  181. package/types/constants/defaults.d.ts.map +1 -1
  182. package/types/exports/dev.d.ts.map +1 -1
  183. package/types/exports/prebuild.d.ts.map +1 -1
  184. package/types/exports/prebuildWithoutExpo.d.ts +6 -0
  185. package/types/exports/prebuildWithoutExpo.d.ts.map +1 -1
  186. package/types/index.d.ts +2 -0
  187. package/types/index.d.ts.map +1 -1
  188. package/types/native-source-contract.d.ts +2 -0
  189. package/types/native-source-contract.d.ts.map +1 -0
  190. package/types/patches/builtInDepPatches.d.ts.map +1 -1
  191. package/types/plugins/autoDepOptimizePlugin.d.ts.map +1 -1
  192. package/types/plugins/backgroundComputationPlugin.d.ts +3 -0
  193. package/types/plugins/backgroundComputationPlugin.d.ts.map +1 -0
  194. package/types/plugins/reactNativeDevAssetPlugin.d.ts.map +1 -1
  195. package/types/plugins/reactNativeDevServer.d.ts.map +1 -1
  196. package/types/plugins/workletImportsPlugin.d.ts.map +1 -1
  197. package/types/rn-commands/bundle/buildBundle.d.ts.map +1 -1
  198. package/types/swift-package-id.d.ts +2 -0
  199. package/types/swift-package-id.d.ts.map +1 -0
  200. package/types/utils/compileNativeDevBytecode.d.ts +3 -0
  201. package/types/utils/compileNativeDevBytecode.d.ts.map +1 -0
  202. package/types/utils/createNativeDevEngine.d.ts +7 -22
  203. package/types/utils/createNativeDevEngine.d.ts.map +1 -1
  204. package/types/utils/nativeRun.d.ts.map +1 -1
  205. package/types/utils/nativeSourceContract.d.ts +121 -0
  206. package/types/utils/nativeSourceContract.d.ts.map +1 -0
  207. package/types/utils/scanDepsToOptimize.d.ts +1 -0
  208. package/types/utils/scanDepsToOptimize.d.ts.map +1 -1
  209. package/types/utils/swiftPackageId.d.ts +1 -0
  210. package/types/utils/swiftPackageId.d.ts.map +1 -1
@@ -1,21 +1,27 @@
1
1
  import { execFileSync } from 'node:child_process'
2
+ import { createRequire } from 'node:module'
2
3
  import {
3
4
  mkdtempSync,
4
5
  mkdirSync,
5
6
  readdirSync,
7
+ existsSync,
6
8
  readFileSync,
7
9
  realpathSync,
10
+ rmSync,
8
11
  statSync,
9
12
  writeFileSync,
10
13
  } from 'node:fs'
11
14
  import { tmpdir } from 'node:os'
12
- import { join, relative } from 'node:path'
15
+ import { dirname, join, relative } from 'node:path'
13
16
  import { fileURLToPath } from 'node:url'
14
17
  import { describe, expect, it } from 'vitest'
15
18
  import sharp from 'sharp'
19
+ import { swiftPackageDirectories } from '../utils/swiftPackageId'
16
20
  import {
17
21
  applyAndroidDependencyPatches,
22
+ enableAppComposeIntegration,
18
23
  generateForPlatform,
24
+ generateKotlinSources,
19
25
  getNativeDependencyInventory,
20
26
  renderPrebuildFile,
21
27
  renderSceneDelegateSwift,
@@ -29,6 +35,7 @@ const app = {
29
35
  scheme: ['myapp', 'myapp-dev'],
30
36
  notifications: {},
31
37
  imagePicker: { camera: 'Capture photos & videos' },
38
+ speech: { recognition: 'Dictate notes', microphone: 'Record dictation' },
32
39
  ios: {
33
40
  bundleId: 'dev.one.myapp',
34
41
  tablet: true,
@@ -42,6 +49,16 @@ const app = {
42
49
  android: { applicationId: 'dev.one.myapp', minSdk: 28 },
43
50
  } satisfies PrebuildAppConfig
44
51
 
52
+ const templateRnDelegate = readFileSync(
53
+ fileURLToPath(
54
+ new URL(
55
+ '../../../../node_modules/@react-native-community/template/template/ios/HelloWorld/AppDelegate.swift',
56
+ import.meta.url
57
+ )
58
+ ),
59
+ 'utf8'
60
+ ).split('class ReactNativeDelegate:')[1]
61
+
45
62
  describe('native.app prebuild validation', () => {
46
63
  // smoke for the shared definition re-export; the full cases live beside
47
64
  // the canonical definition in @vxrn/utils.
@@ -113,6 +130,12 @@ describe('native.app prebuild validation', () => {
113
130
  expect(() => validatePrebuildApp({ ...app, imagePicker: { camera: '' } })).toThrow(
114
131
  /imagePicker\.camera/
115
132
  )
133
+ expect(() =>
134
+ validatePrebuildApp({
135
+ ...app,
136
+ imagePicker: { camera: 'Capture photos', microphone: '' },
137
+ })
138
+ ).toThrow(/imagePicker\.microphone/)
116
139
  expect(() =>
117
140
  validatePrebuildApp({ ...app, notifications: { push: 'yes' } } as any)
118
141
  ).toThrow(/notifications\.push/)
@@ -126,11 +149,15 @@ describe('native.app prebuild validation', () => {
126
149
  })
127
150
  })
128
151
 
129
- // the template's AppDelegate entries, which the scene delegate patch anchors on
152
+ // the template's AppDelegate entries, which the scene delegate patch anchors
153
+ // on, and its two app-target plist settings, beside which the bridging header
154
+ // setting lands
130
155
  const APP_DELEGATE_PBXPROJ = `\t\t761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; };
131
156
  \t\t761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = HelloWorld/AppDelegate.swift; sourceTree = "<group>"; };
132
157
  \t\t\t\t761780EC2CA45674006654EE /* AppDelegate.swift */,
133
- \t\t\t\t761780ED2CA45674006654EE /* AppDelegate.swift in Sources */,`
158
+ \t\t\t\t761780ED2CA45674006654EE /* AppDelegate.swift in Sources */,
159
+ \t\t\t\tINFOPLIST_FILE = HelloWorld/Info.plist;
160
+ \t\t\t\tINFOPLIST_FILE = HelloWorld/Info.plist;`
134
161
 
135
162
  describe('template rendering', () => {
136
163
  it('applies names, ids, and platform versions', () => {
@@ -164,7 +191,10 @@ ${APP_DELEGATE_PBXPROJ}`,
164
191
  })
165
192
  expect(podfile.content).toContain("platform :ios, '17.0'")
166
193
  expect(podfile.content).toContain(
167
- " config = use_native_modules!\n # [vxrn/one] nitro web image modular header\n pod 'SDWebImage', :modular_headers => true\n # [vxrn/one] swift packages\n Dir[File.join(__dir__, 'OneSwiftPackages'"
194
+ `config = use_native_modules!(['node', '-e', "process.argv=['', '', 'config'];require(require('module').createRequire(require.resolve('vxrn/package.json')).resolve('@react-native-community/cli')).run()"])`
195
+ )
196
+ expect(podfile.content).toContain(
197
+ " # [vxrn/one] nitro web image modular header\n pod 'SDWebImage', :modular_headers => true\n # [vxrn/one] swift packages\n Dir[File.join(__dir__, 'OneSwiftPackages'"
168
198
  )
169
199
  expect(podfile.content).toContain(
170
200
  "config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '17.0'"
@@ -188,6 +218,7 @@ ${APP_DELEGATE_PBXPROJ}`,
188
218
  expect(infoPlist.content).toContain('<key>UIFileSharingEnabled</key>')
189
219
  expect(infoPlist.content).toContain('<key>LSSupportsOpeningDocumentsInPlace</key>')
190
220
  expect(infoPlist.content).toContain('<key>OneNativeNotificationsEnabled</key>')
221
+ expect(infoPlist.content).not.toContain('OneNativeNotificationsPush')
191
222
  expect(() =>
192
223
  renderPrebuildFile({
193
224
  relativePath: 'HelloWorld/Info.plist',
@@ -198,6 +229,158 @@ ${APP_DELEGATE_PBXPROJ}`,
198
229
  ).toThrow('lost its LSRequiresIPhoneOS anchor')
199
230
  expect(infoPlist.content).toContain('<key>NSCameraUsageDescription</key>')
200
231
  expect(infoPlist.content).toContain('<string>Capture photos &amp; videos</string>')
232
+ expect(infoPlist.content).toContain('<key>NSSpeechRecognitionUsageDescription</key>')
233
+ expect(infoPlist.content).toContain('<key>NSMicrophoneUsageDescription</key>')
234
+
235
+ const audioPlist = renderPrebuildFile({
236
+ relativePath: 'HelloWorld/Info.plist',
237
+ content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
238
+ platform: 'ios',
239
+ app: { ...app, speech: undefined, audio: { microphone: 'Record notes & ideas' } },
240
+ })
241
+ expect(audioPlist.content).toContain(
242
+ '<key>NSMicrophoneUsageDescription</key>\n\t<string>Record notes &amp; ideas</string>'
243
+ )
244
+ expect(audioPlist.content).not.toContain('NSSpeechRecognitionUsageDescription')
245
+
246
+ const backgroundAudioPlist = renderPrebuildFile({
247
+ relativePath: 'HelloWorld/Info.plist',
248
+ content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
249
+ platform: 'ios',
250
+ app: { ...app, speech: undefined, audio: { background: true } },
251
+ })
252
+ expect(backgroundAudioPlist.content?.match(/<key>UIBackgroundModes<\/key>/g)).toHaveLength(1)
253
+ expect(backgroundAudioPlist.content?.match(/<string>audio<\/string>/g)).toHaveLength(1)
254
+ expect(backgroundAudioPlist.content).not.toContain('NSMicrophoneUsageDescription')
255
+
256
+ const pipAndAudioPlist = renderPrebuildFile({
257
+ relativePath: 'HelloWorld/Info.plist',
258
+ content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
259
+ platform: 'ios',
260
+ app: { ...app, pictureInPicture: true, audio: { background: true } },
261
+ })
262
+ expect(pipAndAudioPlist.content?.match(/<key>UIBackgroundModes<\/key>/g)).toHaveLength(1)
263
+
264
+ const speechAndAudioPlist = renderPrebuildFile({
265
+ relativePath: 'HelloWorld/Info.plist',
266
+ content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
267
+ platform: 'ios',
268
+ app: { ...app, audio: { microphone: 'Record notes & ideas' } },
269
+ })
270
+ expect(speechAndAudioPlist.content?.match(/<key>NSMicrophoneUsageDescription<\/key>/g))
271
+ .toHaveLength(1)
272
+ expect(speechAndAudioPlist.content).toContain(
273
+ '<key>NSMicrophoneUsageDescription</key>\n\t<string>Record notes &amp; ideas</string>'
274
+ )
275
+
276
+ const locationPlist = renderPrebuildFile({
277
+ relativePath: 'HelloWorld/Info.plist',
278
+ content:
279
+ '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n\t<key>NSLocationWhenInUseUsageDescription</key>\n\t<string></string>\n</dict>',
280
+ platform: 'ios',
281
+ app: { ...app, location: { whenInUse: 'Find nearby cafés & parks' } },
282
+ })
283
+ if (locationPlist.content === null) throw new Error('location Info.plist was not rendered')
284
+ expect(
285
+ locationPlist.content.match(/<key>NSLocationWhenInUseUsageDescription<\/key>/g)
286
+ ).toHaveLength(1)
287
+ expect(locationPlist.content).toContain(
288
+ '<key>NSLocationWhenInUseUsageDescription</key>\n\t<string>Find nearby cafés &amp; parks</string>'
289
+ )
290
+
291
+ const noLocationPlist = renderPrebuildFile({
292
+ relativePath: 'HelloWorld/Info.plist',
293
+ content:
294
+ '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n\t<key>NSLocationWhenInUseUsageDescription</key>\n\t<string></string>\n</dict>',
295
+ platform: 'ios',
296
+ app,
297
+ })
298
+ expect(noLocationPlist.content).not.toContain('NSLocationWhenInUseUsageDescription')
299
+
300
+ const alwaysLocationPlist = renderPrebuildFile({
301
+ relativePath: 'HelloWorld/Info.plist',
302
+ content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
303
+ platform: 'ios',
304
+ app: {
305
+ ...app,
306
+ location: { whenInUse: 'Find nearby cafés & parks', alwaysAndWhenInUse: 'Monitor regions' },
307
+ },
308
+ })
309
+ if (alwaysLocationPlist.content === null) throw new Error('always location plist was not rendered')
310
+ expect(
311
+ alwaysLocationPlist.content.match(/<key>NSLocationAlwaysAndWhenInUseUsageDescription<\/key>/g)
312
+ ).toHaveLength(1)
313
+ expect(alwaysLocationPlist.content).toContain(
314
+ '<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>\n\t<string>Monitor regions</string>'
315
+ )
316
+
317
+ const backgroundLocationPlist = renderPrebuildFile({
318
+ relativePath: 'HelloWorld/Info.plist',
319
+ content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
320
+ platform: 'ios',
321
+ app: { ...app, location: { whenInUse: 'Track a hike.', background: true },
322
+ audio: { background: true } },
323
+ })
324
+ expect(backgroundLocationPlist.content?.match(/<key>UIBackgroundModes<\/key>/g)).toHaveLength(1)
325
+ expect(backgroundLocationPlist.content).toContain('<string>audio</string>')
326
+ expect(backgroundLocationPlist.content).toContain('<string>location</string>')
327
+
328
+ const photoPlist = renderPrebuildFile({
329
+ relativePath: 'HelloWorld/Info.plist',
330
+ content:
331
+ '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n\t<key>NSPhotoLibraryAddUsageDescription</key>\n\t<string></string>\n</dict>',
332
+ platform: 'ios',
333
+ app: { ...app, photoLibrary: {
334
+ addOnly: 'Save photos & videos', readWrite: 'Browse photos & videos',
335
+ } },
336
+ })
337
+ expect(photoPlist.content?.match(/<key>NSPhotoLibraryAddUsageDescription<\/key>/g))
338
+ .toHaveLength(1)
339
+ expect(photoPlist.content).toContain(
340
+ '<key>NSPhotoLibraryAddUsageDescription</key>\n\t<string>Save photos &amp; videos</string>'
341
+ )
342
+ expect(photoPlist.content?.match(/<key>NSPhotoLibraryUsageDescription<\/key>/g))
343
+ .toHaveLength(1)
344
+ expect(photoPlist.content).toContain(
345
+ '<key>NSPhotoLibraryUsageDescription</key>\n\t<string>Browse photos &amp; videos</string>'
346
+ )
347
+ expect(photoPlist.content).toContain(
348
+ '<key>PHPhotoLibraryPreventAutomaticLimitedAccessAlert</key>\n\t<true/>'
349
+ )
350
+ const readWritePhotoPlist = renderPrebuildFile({
351
+ relativePath: 'HelloWorld/Info.plist',
352
+ content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n\t<true/>\n</dict>',
353
+ platform: 'ios',
354
+ app: { ...app, photoLibrary: { readWrite: 'Browse photos' } },
355
+ })
356
+ expect(readWritePhotoPlist.content).toContain('<key>NSPhotoLibraryUsageDescription</key>')
357
+ expect(readWritePhotoPlist.content).toContain(
358
+ '<key>PHPhotoLibraryPreventAutomaticLimitedAccessAlert</key>\n\t<true/>'
359
+ )
360
+ expect(readWritePhotoPlist.content).not.toContain('NSPhotoLibraryAddUsageDescription')
361
+
362
+ const contactsPlist = renderPrebuildFile({
363
+ relativePath: 'HelloWorld/Info.plist',
364
+ content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
365
+ platform: 'ios',
366
+ app: { ...app, contacts: { usage: 'Find people & friends' } },
367
+ })
368
+ expect(contactsPlist.content).toContain(
369
+ '<key>NSContactsUsageDescription</key>\n\t<string>Find people &amp; friends</string>'
370
+ )
371
+
372
+ const calendarPlist = renderPrebuildFile({
373
+ relativePath: 'HelloWorld/Info.plist',
374
+ content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
375
+ platform: 'ios',
376
+ app: { ...app, calendar: { usage: 'Show events & meetings', remindersUsage: 'Manage tasks & plans' } },
377
+ })
378
+ expect(calendarPlist.content).toContain(
379
+ '<key>NSCalendarsFullAccessUsageDescription</key>\n\t<string>Show events &amp; meetings</string>'
380
+ )
381
+ expect(calendarPlist.content).toContain(
382
+ '<key>NSRemindersFullAccessUsageDescription</key>\n\t<string>Manage tasks &amp; plans</string>'
383
+ )
201
384
 
202
385
  const androidManifest = renderPrebuildFile({
203
386
  relativePath: 'app/src/main/AndroidManifest.xml',
@@ -209,6 +392,12 @@ ${APP_DELEGATE_PBXPROJ}`,
209
392
  expect(androidManifest.content).toContain(
210
393
  '<uses-permission android:name="android.permission.CAMERA" />'
211
394
  )
395
+ expect(androidManifest.content).toContain(
396
+ '<uses-permission android:name="android.permission.RECORD_AUDIO" />'
397
+ )
398
+ expect(androidManifest.content).toContain(
399
+ '<action android:name="android.speech.RecognitionService" />'
400
+ )
212
401
  expect(androidManifest.content).toContain(
213
402
  '<action android:name="android.intent.action.VIEW" />'
214
403
  )
@@ -240,7 +429,8 @@ ${APP_DELEGATE_PBXPROJ}`,
240
429
  const androidSettings = renderPrebuildFile({
241
430
  relativePath: 'settings.gradle',
242
431
  content: `pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") }
243
- includeBuild('../node_modules/@react-native/gradle-plugin')`,
432
+ includeBuild('../node_modules/@react-native/gradle-plugin')
433
+ extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() }`,
244
434
  platform: 'android',
245
435
  app,
246
436
  })
@@ -253,6 +443,48 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
253
443
  expect(androidSettings.content).not.toContain('../node_modules')
254
444
  })
255
445
 
446
+ it('stamps the pedometer motion permission from native.app', () => {
447
+ const pedometerPlist = renderPrebuildFile({
448
+ relativePath: 'HelloWorld/Info.plist',
449
+ content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
450
+ platform: 'ios',
451
+ app: { ...app, pedometer: { usage: 'Count steps & distance' } },
452
+ })
453
+ expect(pedometerPlist.content?.match(/<key>NSMotionUsageDescription<\/key>/g))
454
+ .toHaveLength(1)
455
+ expect(pedometerPlist.content).toContain(
456
+ '<key>NSMotionUsageDescription</key>\n\t<string>Count steps &amp; distance</string>'
457
+ )
458
+
459
+ const noPedometerPlist = renderPrebuildFile({
460
+ relativePath: 'HelloWorld/Info.plist',
461
+ content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
462
+ platform: 'ios',
463
+ app,
464
+ })
465
+ expect(noPedometerPlist.content).not.toContain('NSMotionUsageDescription')
466
+
467
+ const pedometerManifest = renderPrebuildFile({
468
+ relativePath: 'app/src/main/AndroidManifest.xml',
469
+ content:
470
+ '<manifest>\n <uses-permission android:name="android.permission.INTERNET" />\n <activity>\n </activity>\n </application>',
471
+ platform: 'android',
472
+ app: { ...app, pedometer: { usage: 'Count your steps.' } },
473
+ })
474
+ expect(pedometerManifest.content).toContain(
475
+ '<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />'
476
+ )
477
+
478
+ const noPedometerManifest = renderPrebuildFile({
479
+ relativePath: 'app/src/main/AndroidManifest.xml',
480
+ content:
481
+ '<manifest>\n <uses-permission android:name="android.permission.INTERNET" />\n <activity>\n </activity>\n </application>',
482
+ platform: 'android',
483
+ app,
484
+ })
485
+ expect(noPedometerManifest.content).not.toContain('ACTIVITY_RECOGNITION')
486
+ })
487
+
256
488
  it('stamps marketing and build versions from the manifest', () => {
257
489
  const stamped = {
258
490
  ...app,
@@ -307,8 +539,37 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
307
539
  expect(android.content).toContain('versionName "1.0"')
308
540
  })
309
541
 
542
+ it('stamps the imagePicker microphone string for camera video', () => {
543
+ const video = {
544
+ ...app,
545
+ speech: undefined,
546
+ notifications: undefined,
547
+ imagePicker: { camera: 'Capture photos', microphone: 'Record video & audio' },
548
+ }
549
+ const infoPlist = renderPrebuildFile({
550
+ relativePath: 'HelloWorld/Info.plist',
551
+ content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
552
+ platform: 'ios',
553
+ app: video,
554
+ })
555
+ expect(infoPlist.content).toContain('<key>NSCameraUsageDescription</key>')
556
+ expect(infoPlist.content).toContain(
557
+ '<key>NSMicrophoneUsageDescription</key>\n\t<string>Record video &amp; audio</string>'
558
+ )
559
+ // the camera app records: android gains no microphone permission.
560
+ const androidManifest = renderPrebuildFile({
561
+ relativePath: 'app/src/main/AndroidManifest.xml',
562
+ content:
563
+ '<manifest>\n <uses-permission android:name="android.permission.INTERNET" />',
564
+ platform: 'android',
565
+ app: video,
566
+ })
567
+ expect(androidManifest.content).toContain('android.permission.CAMERA')
568
+ expect(androidManifest.content).not.toContain('android.permission.RECORD_AUDIO')
569
+ })
570
+
310
571
  it('omits camera entries when imagePicker.camera is unset', () => {
311
- const bare = { ...app, imagePicker: undefined, notifications: undefined }
572
+ const bare = { ...app, imagePicker: undefined, speech: undefined, notifications: undefined }
312
573
  const infoPlist = renderPrebuildFile({
313
574
  relativePath: 'HelloWorld/Info.plist',
314
575
  content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
@@ -316,6 +577,7 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
316
577
  app: bare,
317
578
  })
318
579
  expect(infoPlist.content).not.toContain('NSCameraUsageDescription')
580
+ expect(infoPlist.content).not.toContain('NSMicrophoneUsageDescription')
319
581
  const androidManifest = renderPrebuildFile({
320
582
  relativePath: 'app/src/main/AndroidManifest.xml',
321
583
  content:
@@ -324,6 +586,90 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
324
586
  app: bare,
325
587
  })
326
588
  expect(androidManifest.content).not.toContain('android.permission.CAMERA')
589
+ expect(androidManifest.content).not.toContain('android.permission.RECORD_AUDIO')
590
+ })
591
+
592
+ it('stamps Android media permissions and markers from native.app', () => {
593
+ const media = {
594
+ ...app,
595
+ audio: { microphone: 'Record audio', background: true },
596
+ photoLibrary: { addOnly: 'Save photos', readWrite: 'Read photos' },
597
+ contacts: { usage: 'Find people' },
598
+ calendar: { usage: 'Show events' },
599
+ } satisfies PrebuildAppConfig
600
+ const manifest = renderPrebuildFile({
601
+ relativePath: 'app/src/main/AndroidManifest.xml',
602
+ content:
603
+ '<manifest>\n <uses-permission android:name="android.permission.INTERNET" />\n <activity>\n </activity>\n </application>',
604
+ platform: 'android',
605
+ app: media,
606
+ })
607
+ for (const permission of [
608
+ 'android.permission.RECORD_AUDIO',
609
+ 'android.permission.READ_MEDIA_IMAGES',
610
+ 'android.permission.READ_MEDIA_VIDEO',
611
+ 'android.permission.READ_CONTACTS',
612
+ 'android.permission.WRITE_CONTACTS',
613
+ 'android.permission.READ_CALENDAR',
614
+ 'android.permission.WRITE_CALENDAR',
615
+ 'android.permission.FOREGROUND_SERVICE',
616
+ 'android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK',
617
+ ]) {
618
+ expect(manifest.content).toContain(permission)
619
+ }
620
+ expect(manifest.content).toContain(
621
+ '<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />'
622
+ )
623
+ expect(manifest.content).toContain(
624
+ '<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />'
625
+ )
626
+ expect(manifest.content).toContain('one.photoLibrary.addOnly')
627
+ expect(manifest.content).toContain('one.audio.background')
628
+ expect(manifest.content).toContain('com.margelo.nitro.one.OneAudioService')
629
+ expect(manifest.content).toContain('android:foregroundServiceType="mediaPlayback"')
630
+ // speech stamps RECORD_AUDIO first; the media block must not duplicate it.
631
+ expect(
632
+ (manifest.content ?? '').split('android.permission.RECORD_AUDIO').length - 1
633
+ ).toBe(1)
634
+
635
+ const bare = renderPrebuildFile({
636
+ relativePath: 'app/src/main/AndroidManifest.xml',
637
+ content:
638
+ '<manifest>\n <uses-permission android:name="android.permission.INTERNET" />\n <activity>\n </activity>\n </application>',
639
+ platform: 'android',
640
+ app: {
641
+ ...app,
642
+ audio: undefined,
643
+ photoLibrary: undefined,
644
+ contacts: undefined,
645
+ calendar: undefined,
646
+ },
647
+ })
648
+ for (const permission of [
649
+ 'android.permission.READ_MEDIA_IMAGES',
650
+ 'android.permission.READ_CONTACTS',
651
+ 'android.permission.READ_CALENDAR',
652
+ 'android.permission.FOREGROUND_SERVICE',
653
+ ]) {
654
+ expect(bare.content).not.toContain(permission)
655
+ }
656
+ expect(bare.content).not.toContain('one.photoLibrary.addOnly')
657
+ expect(bare.content).not.toContain('one.audio.background')
658
+ expect(bare.content).not.toContain('OneAudioService')
659
+
660
+ // reminders-only calendar needs no Android event permissions.
661
+ const remindersOnly = renderPrebuildFile({
662
+ relativePath: 'app/src/main/AndroidManifest.xml',
663
+ content:
664
+ '<manifest>\n <uses-permission android:name="android.permission.INTERNET" />\n <activity>\n </activity>\n </application>',
665
+ platform: 'android',
666
+ app: { ...app, calendar: { remindersUsage: 'Manage tasks' } },
667
+ })
668
+ expect(remindersOnly.content).not.toContain('android.permission.READ_CALENDAR')
669
+ expect(remindersOnly.content).toContain('android.permission.POST_NOTIFICATIONS')
670
+ expect(remindersOnly.content).toContain('android.permission.RECEIVE_BOOT_COMPLETED')
671
+ expect(remindersOnly.content).toContain('one.calendar.reminders')
672
+ expect(bare.content).not.toContain('one.calendar.reminders')
327
673
  })
328
674
 
329
675
  it('stamps the maps key and flag only when googleMapsApiKey is set', () => {
@@ -403,7 +749,7 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
403
749
  })
404
750
 
405
751
  it('fails loudly when a notification anchor is missing', () => {
406
- const noCamera = { ...app, imagePicker: undefined }
752
+ const noCamera = { ...app, imagePicker: undefined, speech: undefined }
407
753
  expect(() =>
408
754
  renderPrebuildFile({
409
755
  relativePath: 'app/src/main/AndroidManifest.xml',
@@ -487,17 +833,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
487
833
 
488
834
  return true
489
835
  }
490
- }`,
836
+ }
837
+ class ReactNativeDelegate:${templateRnDelegate}`,
491
838
  platform: 'ios',
492
839
  app: push,
493
840
  })
494
- expect(appDelegate.content).toContain(
841
+ // one hooks the delegate's push callbacks at launch; the template
842
+ // carries no forward of its own.
843
+ expect(appDelegate.content).not.toContain(
495
844
  'didRegisterForRemoteNotificationsWithDeviceToken'
496
845
  )
497
- expect(appDelegate.content).toContain('OneNativePushTokenDidRegister')
498
- expect(appDelegate.content).toContain(
499
- 'didFailToRegisterForRemoteNotificationsWithError'
500
- )
501
846
 
502
847
  const bareProps = renderPrebuildFile({
503
848
  relativePath: 'gradle.properties',
@@ -565,11 +910,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
565
910
  app,
566
911
  })
567
912
  ).toThrow(
568
- '[vxrn] cannot stamp the camera permission: expected the INTERNET permission in app/src/main/AndroidManifest.xml'
913
+ '[vxrn] cannot stamp the camera or microphone permission: expected the INTERNET permission in app/src/main/AndroidManifest.xml'
569
914
  )
570
915
  })
571
916
 
572
- it('resolves the gradle plugin from the react-native package without hoisting', () => {
917
+ it('resolves build tools from their owning packages without hoisting', () => {
573
918
  const root = mkdtempSync(join(tmpdir(), 'vxrn-gradle-plugin-'))
574
919
  const settingsDir = join(root, 'android')
575
920
  mkdirSync(settingsDir, { recursive: true })
@@ -592,10 +937,31 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
592
937
  JSON.stringify({ name: '@react-native/gradle-plugin', version: '0.0.0' })
593
938
  )
594
939
 
940
+ const cliPath = join(
941
+ root,
942
+ 'node_modules',
943
+ 'vxrn',
944
+ 'node_modules',
945
+ '@react-native-community',
946
+ 'cli',
947
+ 'build',
948
+ 'bin.js'
949
+ )
950
+ mkdirSync(dirname(cliPath), { recursive: true })
951
+ writeFileSync(
952
+ join(root, 'node_modules', 'vxrn', 'package.json'),
953
+ JSON.stringify({ name: 'vxrn', version: '0.0.0' })
954
+ )
955
+ writeFileSync(
956
+ cliPath,
957
+ "if (process.argv[2] !== 'config') process.exit(1); console.log(JSON.stringify({root: process.cwd(), dependencies: {}}))"
958
+ )
959
+
595
960
  const rendered = renderPrebuildFile({
596
961
  relativePath: 'settings.gradle',
597
962
  content: `pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") }
598
- includeBuild('../node_modules/@react-native/gradle-plugin')`,
963
+ includeBuild('../node_modules/@react-native/gradle-plugin')
964
+ extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() }`,
599
965
  platform: 'android',
600
966
  app,
601
967
  })
@@ -608,6 +974,98 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
608
974
  encoding: 'utf8',
609
975
  }).trim()
610
976
  expect(resolved).toBe(realpathSync(join(nested, 'package.json')))
977
+ const cliScript = [...(rendered.content ?? '').matchAll(/"--print", "([^"]+)"/g)]
978
+ .map((match) => match[1])
979
+ .find((script) => script.includes('@react-native-community/cli/build/bin.js'))
980
+ if (!cliScript) throw new Error('expected installed CLI resolution script')
981
+ const resolvedCli = execFileSync(process.execPath, ['--print', cliScript], {
982
+ cwd: settingsDir,
983
+ encoding: 'utf8',
984
+ }).trim()
985
+ const cliConfig = JSON.parse(
986
+ execFileSync(process.execPath, [resolvedCli, 'config'], {
987
+ cwd: root,
988
+ encoding: 'utf8',
989
+ })
990
+ )
991
+ expect(cliConfig).toEqual({ root: realpathSync(root), dependencies: {} })
992
+
993
+ const patches = createRequire(import.meta.url)('../../native-project-patches.cjs')
994
+ const iosPhase = patches.addSetCliPathToBundleReactNativeShellScript(
995
+ '`"$NODE_BINARY" --print "react-native-xcode.sh"`'
996
+ )
997
+ const cliExport = iosPhase
998
+ .split('\n')
999
+ .find((line) => line.startsWith('export CLI_PATH='))
1000
+ if (!cliExport) throw new Error('expected iOS CLI export')
1001
+ const iosConfig = JSON.parse(
1002
+ execFileSync('bash', ['-c', `${cliExport}\n"$NODE_BINARY" "$CLI_PATH" config`], {
1003
+ cwd: root,
1004
+ env: { ...process.env, NODE_BINARY: process.execPath },
1005
+ encoding: 'utf8',
1006
+ })
1007
+ )
1008
+ expect(iosConfig).toEqual(cliConfig)
1009
+
1010
+ writeFileSync(
1011
+ join(dirname(dirname(cliPath)), 'package.json'),
1012
+ JSON.stringify({ name: '@react-native-community/cli', main: 'index.js' })
1013
+ )
1014
+ writeFileSync(
1015
+ join(dirname(dirname(cliPath)), 'index.js'),
1016
+ "exports.run = () => require('./build/bin.js')"
1017
+ )
1018
+ const podfile = renderPrebuildFile({
1019
+ relativePath: 'Podfile',
1020
+ content:
1021
+ "target 'HelloWorld' do\n config = use_native_modules!\n post_install do |installer|\n react_native_post_install(installer)\n end\nend",
1022
+ platform: 'ios',
1023
+ app,
1024
+ })
1025
+ const podScript = podfile.content?.match(
1026
+ /use_native_modules!\(\['node', '-e', "([^"\n]+)"\]\)/
1027
+ )?.[1]
1028
+ if (!podScript) throw new Error('expected iOS pod discovery command')
1029
+ expect(
1030
+ JSON.parse(
1031
+ execFileSync(process.execPath, ['-e', podScript], {
1032
+ cwd: root,
1033
+ encoding: 'utf8',
1034
+ })
1035
+ )
1036
+ ).toEqual(cliConfig)
1037
+ expect(() =>
1038
+ execFileSync(
1039
+ process.execPath,
1040
+ [
1041
+ '-e',
1042
+ "process.argv=['', '', 'config'];require('@react-native-community/cli').run()",
1043
+ ],
1044
+ { cwd: root, stdio: 'pipe' }
1045
+ )
1046
+ ).toThrow()
1047
+
1048
+ const androidReact = patches.replaceAppBuildGradleReactBlock('react {\n}\n')
1049
+ const bundleResolver = [
1050
+ ...androidReact.matchAll(/commandLine\("node", "--print", "([^"\n]+)"\)/g),
1051
+ ]
1052
+ .map((match) => match[1])
1053
+ .find((script) => script.includes('@react-native-community/cli/build/bin.js'))
1054
+ if (!bundleResolver) throw new Error('expected Android bundle CLI resolver')
1055
+ expect(
1056
+ execFileSync(process.execPath, ['--print', bundleResolver], {
1057
+ cwd: settingsDir,
1058
+ encoding: 'utf8',
1059
+ }).trim()
1060
+ ).toBe(resolvedCli)
1061
+
1062
+ expect(() =>
1063
+ execFileSync(
1064
+ process.execPath,
1065
+ ['--print', "require.resolve('@react-native-community/cli/build/bin.js')"],
1066
+ { cwd: root, stdio: 'pipe' }
1067
+ )
1068
+ ).toThrow()
611
1069
 
612
1070
  // negative control: a bare top-level resolution fails here, proving the
613
1071
  // fixture is genuinely non-hoisted and the old snippet would break
@@ -716,8 +1174,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
716
1174
  const rendered = renderPrebuildFile({
717
1175
  relativePath: 'HelloWorld/AppDelegate.swift',
718
1176
  content: `${templateAppDelegate}
719
- class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
720
- }`,
1177
+ class ReactNativeDelegate:${templateRnDelegate}`,
721
1178
  platform: 'ios',
722
1179
  app,
723
1180
  })
@@ -726,10 +1183,18 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
726
1183
  expect(rendered.content).not.toContain('startReactNative')
727
1184
  expect(rendered.content).not.toContain('UIWindow(frame:')
728
1185
  expect(rendered.content).not.toContain('var window')
729
- expect(rendered.content).not.toContain('var reactNativeFactory')
730
- expect(rendered.content).toContain('didRegisterForRemoteNotificationsWithDeviceToken')
731
- expect(rendered.content).toContain('OneNativePushTokenDidRegister')
732
- expect(rendered.content).toContain('didFailToRegisterForRemoteNotificationsWithError')
1186
+ expect(rendered.content).toContain('var reactNativeFactory')
1187
+ expect(rendered.content).toContain('override func customize(_ rootView: RCTRootView)')
1188
+ expect(rendered.content).toContain('OneHoldLaunchScreen(rootView)')
1189
+ })
1190
+
1191
+ it('rejects a template that cannot request dev bytecode', () => {
1192
+ expect(() => renderPrebuildFile({
1193
+ relativePath: 'HelloWorld/AppDelegate.swift',
1194
+ content: `${templateAppDelegate}class ReactNativeDelegate:${templateRnDelegate.replace('forBundleRoot: "index"', 'forBundleRoot: "changed"')}`,
1195
+ platform: 'ios',
1196
+ app,
1197
+ })).toThrow('expected the template bundle URL provider')
733
1198
  })
734
1199
 
735
1200
  it('throws instead of shipping a non-scene AppDelegate', () => {
@@ -764,6 +1229,7 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
764
1229
  expect(sceneDelegate).toContain('openURLContexts')
765
1230
  expect(sceneDelegate).toContain('continue userActivity')
766
1231
  expect(sceneDelegate).toContain('RCTLinkingManager')
1232
+ expect(sceneDelegate).toContain('appDelegate.reactNativeFactory')
767
1233
  })
768
1234
 
769
1235
  it('throws when the template loses a scene anchor', () => {
@@ -785,10 +1251,79 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
785
1251
  ).toThrow('AppDelegate.swift anchor')
786
1252
  })
787
1253
 
1254
+ it('bundles native.app fonts into both platforms from the real template', async () => {
1255
+ const workspaceRoot = fileURLToPath(new URL('../../../..', import.meta.url))
1256
+ const output = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-fonts-'))
1257
+ const fonts = ['tests/native-features/assets/OneNativeTestFont-BlockB.ttf']
1258
+ await generateForPlatform(workspaceRoot, 'ios', { ...app, fonts }, join(output, 'ios'))
1259
+ await generateForPlatform(workspaceRoot, 'android', { ...app, fonts }, join(output, 'android'))
1260
+
1261
+ expect(existsSync(join(output, 'ios', 'MyApp', 'OneNativeTestFont-BlockB.ttf'))).toBe(true)
1262
+ const infoPlist = readFileSync(join(output, 'ios', 'MyApp', 'Info.plist'), 'utf8')
1263
+ expect(infoPlist).toContain(
1264
+ '<key>UIAppFonts</key>\n\t<array>\n\t\t<string>OneNativeTestFont-BlockB.ttf</string>'
1265
+ )
1266
+ const pbxproj = readFileSync(
1267
+ join(output, 'ios', 'MyApp.xcodeproj', 'project.pbxproj'),
1268
+ 'utf8'
1269
+ )
1270
+ // file reference, group child, build file and resources phase entry.
1271
+ expect(pbxproj.split('/* OneNativeTestFont-BlockB.ttf */').length - 1).toBe(3)
1272
+ expect(pbxproj.split('/* OneNativeTestFont-BlockB.ttf in Resources */').length - 1).toBe(2)
1273
+ expect(pbxproj).toContain('path = MyApp/OneNativeTestFont-BlockB.ttf;')
1274
+ expect(
1275
+ existsSync(
1276
+ join(output, 'android', 'app', 'src', 'main', 'assets', 'fonts', 'OneNativeTestFont-BlockB.ttf')
1277
+ )
1278
+ ).toBe(true)
1279
+ })
1280
+
1281
+ it('bundles firebase config files and applies the google-services plugin', async () => {
1282
+ const workspaceRoot = fileURLToPath(new URL('../../../..', import.meta.url))
1283
+ const output = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-firebase-'))
1284
+ const plist = join(output, 'firebase-ios.plist')
1285
+ const json = join(output, 'firebase-android.json')
1286
+ writeFileSync(plist, '<plist><dict/></plist>\n')
1287
+ writeFileSync(json, '{}\n')
1288
+ const firebase = {
1289
+ ...app,
1290
+ ios: { ...app.ios, googleServicesFile: plist },
1291
+ android: { ...app.android, googleServicesFile: json },
1292
+ }
1293
+ await generateForPlatform(workspaceRoot, 'ios', firebase, join(output, 'ios'))
1294
+ await generateForPlatform(workspaceRoot, 'android', firebase, join(output, 'android'))
1295
+
1296
+ expect(readFileSync(join(output, 'ios', 'MyApp', 'GoogleService-Info.plist'), 'utf8')).toBe(
1297
+ '<plist><dict/></plist>\n'
1298
+ )
1299
+ const pbxproj = readFileSync(
1300
+ join(output, 'ios', 'MyApp.xcodeproj', 'project.pbxproj'),
1301
+ 'utf8'
1302
+ )
1303
+ expect(pbxproj).toContain(
1304
+ 'lastKnownFileType = text.plist.xml; name = GoogleService-Info.plist; path = MyApp/GoogleService-Info.plist;'
1305
+ )
1306
+ expect(pbxproj.split('/* GoogleService-Info.plist in Resources */').length - 1).toBe(2)
1307
+ expect(readFileSync(join(output, 'android', 'app', 'google-services.json'), 'utf8')).toBe(
1308
+ '{}\n'
1309
+ )
1310
+ expect(readFileSync(join(output, 'android', 'build.gradle'), 'utf8')).toContain(
1311
+ 'classpath("com.google.gms:google-services:4.4.4")'
1312
+ )
1313
+ expect(readFileSync(join(output, 'android', 'app', 'build.gradle'), 'utf8')).toContain(
1314
+ 'apply plugin: "com.facebook.react"\napply plugin: "com.google.gms.google-services"'
1315
+ )
1316
+ })
1317
+
788
1318
  it('generates a scene project from the real template', async () => {
789
1319
  const workspaceRoot = fileURLToPath(new URL('../../../..', import.meta.url))
790
1320
  const output = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-scene-'))
791
- await generateForPlatform(workspaceRoot, 'ios', app, join(output, 'ios'))
1321
+ await generateForPlatform(
1322
+ workspaceRoot,
1323
+ 'ios',
1324
+ { ...app, orientation: 'default' },
1325
+ join(output, 'ios')
1326
+ )
792
1327
 
793
1328
  const sceneDelegate = readFileSync(
794
1329
  join(output, 'ios', 'MyApp', 'SceneDelegate.swift'),
@@ -807,6 +1342,21 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
807
1342
  const infoPlist = readFileSync(join(output, 'ios', 'MyApp', 'Info.plist'), 'utf8')
808
1343
  expect(infoPlist).toContain('<key>UIApplicationSceneManifest</key>')
809
1344
  expect(infoPlist).toContain('<key>OneNativeNotificationsEnabled</key>')
1345
+ // orientation default opens the phone list to all four; ipad keeps its own.
1346
+ const phoneOrientations = infoPlist
1347
+ .split('<key>UISupportedInterfaceOrientations</key>')[1]
1348
+ .split('</array>')[0]
1349
+ for (const orientation of [
1350
+ 'Portrait',
1351
+ 'PortraitUpsideDown',
1352
+ 'LandscapeLeft',
1353
+ 'LandscapeRight',
1354
+ ]) {
1355
+ expect(phoneOrientations).toContain(
1356
+ `<string>UIInterfaceOrientation${orientation}</string>`
1357
+ )
1358
+ }
1359
+ expect(infoPlist).toContain('<key>UISupportedInterfaceOrientations~ipad</key>')
810
1360
 
811
1361
  const project = readFileSync(
812
1362
  join(output, 'ios', 'MyApp.xcodeproj', 'project.pbxproj'),
@@ -856,28 +1406,23 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
856
1406
  expect(entitlements).toContain('group.dev.one.myapp')
857
1407
  expect(entitlements).toContain('<key>aps-environment</key>')
858
1408
  expect(() => statSync(join(output, 'ios', 'MyApp', 'MyApp.entitlements'))).toThrow()
1409
+ expect(readFileSync(join(output, 'ios', 'MyApp', 'Info.plist'), 'utf8')).toContain(
1410
+ '<key>OneNativeNotificationsPush</key>'
1411
+ )
859
1412
  }, 180000)
860
1413
  })
861
1414
 
862
1415
  describe('community autolink inventory', () => {
863
- it('discovers installed packages through community config, sorted', async () => {
1416
+ it('generates both projects and discovers native packages without an app cli', async () => {
864
1417
  const root = mkdtempSync(join(tmpdir(), 'vxrn-autolink-'))
865
- // everything resolves from the fixture root, exactly as in a real app;
866
- // workspace installs are linked so the test needs no network.
1418
+ // the app owns its template and native packages, while vxrn owns the cli.
1419
+ // link installed inputs so this strict app layout needs no network.
867
1420
  const workspaceModules = fileURLToPath(
868
1421
  new URL('../../../../node_modules', import.meta.url)
869
1422
  )
870
1423
  mkdirSync(join(root, 'node_modules', '@react-native-community'), { recursive: true })
871
1424
  const { symlinkSync } = await import('node:fs')
872
- for (const name of [
873
- 'cli',
874
- 'cli-config',
875
- 'cli-config-android',
876
- 'cli-config-apple',
877
- 'cli-tools',
878
- 'cli-types',
879
- 'template',
880
- ]) {
1425
+ for (const name of ['template']) {
881
1426
  symlinkSync(
882
1427
  join(workspaceModules, '@react-native-community', name),
883
1428
  join(root, 'node_modules', '@react-native-community', name)
@@ -987,8 +1532,22 @@ describe('generateForPlatform determinism', () => {
987
1532
  const appWithIcon = {
988
1533
  ...app,
989
1534
  version: '9.9.9',
990
- ios: { ...app.ios, buildNumber: '4242' },
991
- android: { ...app.android, versionCode: 4242 },
1535
+ ios: { ...app.ios, buildNumber: '4242', tablet: true, alternateIcons: {
1536
+ TestAlternate: {
1537
+ source: fileURLToPath(new URL('../../../../examples/one-basic/public/app-icon.png', import.meta.url)),
1538
+ backgroundColor: '#123456',
1539
+ },
1540
+ } },
1541
+ android: {
1542
+ ...app.android,
1543
+ versionCode: 4242,
1544
+ adaptiveIcon: {
1545
+ foreground: fileURLToPath(
1546
+ new URL('../../../../examples/one-basic/public/app-icon.png', import.meta.url)
1547
+ ),
1548
+ backgroundColor: '#123456',
1549
+ },
1550
+ },
992
1551
  icon: {
993
1552
  source: fileURLToPath(
994
1553
  new URL('../../../../examples/one-basic/public/app-icon.png', import.meta.url)
@@ -1040,6 +1599,8 @@ describe('generateForPlatform determinism', () => {
1040
1599
  expect(generatedPbxproj).toContain('CURRENT_PROJECT_VERSION = 4242;')
1041
1600
  expect(generatedPbxproj).not.toContain('MARKETING_VERSION = 1.0;')
1042
1601
  expect(generatedPbxproj).not.toContain('CURRENT_PROJECT_VERSION = 1;')
1602
+ expect(generatedPbxproj.match(/ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "TestAlternate";/g))
1603
+ .toHaveLength(2)
1043
1604
  const generatedGradle = readFileSync(
1044
1605
  join(output, 'android', 'app', 'build.gradle'),
1045
1606
  'utf8'
@@ -1057,10 +1618,23 @@ describe('generateForPlatform determinism', () => {
1057
1618
  const iosContents: { images: Array<{ filename?: string }> } = JSON.parse(
1058
1619
  readFileSync(join(iosIconDir, 'Contents.json'), 'utf8')
1059
1620
  )
1060
- expect(iosContents.images).toHaveLength(9)
1621
+ expect(iosContents.images).toHaveLength(18)
1061
1622
  expect(iosContents.images.every((image) => image.filename)).toBe(true)
1623
+ // a tablet build ships the iPad sizes App Store upload requires
1624
+ expect(await sharp(join(iosIconDir, 'icon-76@2x.png')).metadata())
1625
+ .toMatchObject({ width: 152, height: 152 })
1626
+ expect(await sharp(join(iosIconDir, 'icon-83.5@2x.png')).metadata())
1627
+ .toMatchObject({ width: 167, height: 167 })
1062
1628
  const iosMarketing = await sharp(join(iosIconDir, 'icon-1024.png')).metadata()
1063
1629
  expect(iosMarketing).toMatchObject({ width: 1024, height: 1024, hasAlpha: false })
1630
+ const alternateIconDir = join(output, 'ios', 'MyApp', 'Images.xcassets', 'TestAlternate.appiconset')
1631
+ const alternateContents: { images: Array<{ filename?: string }> } = JSON.parse(
1632
+ readFileSync(join(alternateIconDir, 'Contents.json'), 'utf8')
1633
+ )
1634
+ expect(alternateContents.images).toHaveLength(18)
1635
+ expect(alternateContents.images.every((image) => image.filename)).toBe(true)
1636
+ expect(await sharp(join(alternateIconDir, 'icon-1024.png')).metadata())
1637
+ .toMatchObject({ width: 1024, height: 1024, hasAlpha: false })
1064
1638
 
1065
1639
  const androidIcon = await sharp(
1066
1640
  join(
@@ -1076,11 +1650,16 @@ describe('generateForPlatform determinism', () => {
1076
1650
  ).metadata()
1077
1651
  expect(androidIcon).toMatchObject({ width: 192, height: 192 })
1078
1652
  const iosApp = join(output, 'ios', 'MyApp')
1079
- expect(
1080
- await sharp(
1081
- join(iosApp, 'Images.xcassets', 'Splash.imageset', 'splash.png')
1082
- ).metadata()
1083
- ).toMatchObject({ width: 710, height: 209 })
1653
+ // contained artwork is drawn at its launch width in points, at 1x to 3x
1654
+ for (const [filename, width] of [
1655
+ ['splash.png', 200],
1656
+ ['splash@2x.png', 400],
1657
+ ['splash@3x.png', 600],
1658
+ ] as const) {
1659
+ expect(
1660
+ await sharp(join(iosApp, 'Images.xcassets', 'Splash.imageset', filename)).metadata()
1661
+ ).toMatchObject({ width })
1662
+ }
1084
1663
  const launchStoryboard = readFileSync(join(iosApp, 'LaunchScreen.storyboard'), 'utf8')
1085
1664
  expect(launchStoryboard).toContain('image="Splash"')
1086
1665
  expect(launchStoryboard).toContain('firstAttribute="centerX"')
@@ -1113,6 +1692,21 @@ describe('generateForPlatform determinism', () => {
1113
1692
  expect(readFileSync(join(androidRes, 'values', 'styles.xml'), 'utf8')).toContain(
1114
1693
  '@drawable/launch_screen'
1115
1694
  )
1695
+ // the adaptive launcher icon: 108dp layers per density, a color background
1696
+ // in its own resource, and both launcher xmls wired to them
1697
+ expect(
1698
+ await sharp(join(androidRes, 'mipmap-xxxhdpi', 'ic_launcher_foreground.png')).metadata()
1699
+ ).toMatchObject({ width: 432, height: 432 })
1700
+ expect(() => statSync(join(androidRes, 'mipmap-mdpi', 'ic_launcher_background.png'))).toThrow()
1701
+ expect(readFileSync(join(androidRes, 'values', 'ic_launcher_background.xml'), 'utf8')).toContain(
1702
+ '<color name="ic_launcher_background">#123456</color>'
1703
+ )
1704
+ for (const filename of ['ic_launcher.xml', 'ic_launcher_round.xml']) {
1705
+ const adaptive = readFileSync(join(androidRes, 'mipmap-anydpi-v26', filename), 'utf8')
1706
+ expect(adaptive).toContain('<background android:drawable="@color/ic_launcher_background"/>')
1707
+ expect(adaptive).toContain('<foreground android:drawable="@mipmap/ic_launcher_foreground"/>')
1708
+ expect(adaptive).not.toContain('monochrome')
1709
+ }
1116
1710
  // android 12+ uses the configured splash image, never the launcher icon
1117
1711
  const stylesV31 = readFileSync(join(androidRes, 'values-v31', 'styles.xml'), 'utf8')
1118
1712
  expect(stylesV31).toContain('@color/splash_background')
@@ -1122,6 +1716,188 @@ describe('generateForPlatform determinism', () => {
1122
1716
  expect(stylesV31).not.toContain('ic_launcher')
1123
1717
  }, 180000)
1124
1718
 
1719
+ it('fills the ios launch screen with an untrimmed cover splash', async () => {
1720
+ const workspaceRoot = fileURLToPath(new URL('../../../..', import.meta.url))
1721
+ const output = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-cover-'))
1722
+ const source = fileURLToPath(
1723
+ new URL('../../../../examples/one-basic/public/splash.png', import.meta.url)
1724
+ )
1725
+ await generateForPlatform(
1726
+ workspaceRoot,
1727
+ 'ios',
1728
+ { ...app, splash: { source, backgroundColor: '#000000', resizeMode: 'cover' } },
1729
+ join(output, 'ios')
1730
+ )
1731
+ const iosApp = join(output, 'ios', 'MyApp')
1732
+ const { width, height } = await sharp(source).metadata()
1733
+ expect(
1734
+ await sharp(join(iosApp, 'Images.xcassets', 'Splash.imageset', 'splash.png')).metadata()
1735
+ ).toMatchObject({ width, height })
1736
+ const launchStoryboard = readFileSync(join(iosApp, 'LaunchScreen.storyboard'), 'utf8')
1737
+ expect(launchStoryboard).toContain('contentMode="scaleAspectFill" image="Splash"')
1738
+ for (const edge of ['leading', 'trailing', 'top', 'bottom']) {
1739
+ expect(launchStoryboard).toContain(`firstAttribute="${edge}" secondItem="launch-view"`)
1740
+ }
1741
+ expect(launchStoryboard).not.toContain('firstAttribute="width" constant')
1742
+ }, 180000)
1743
+
1744
+ it('gives a dark launch its own background and artwork on both platforms', async () => {
1745
+ const workspaceRoot = fileURLToPath(new URL('../../../..', import.meta.url))
1746
+ const output = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-dark-'))
1747
+ const source = fileURLToPath(
1748
+ new URL('../../../../examples/one-basic/public/splash.png', import.meta.url)
1749
+ )
1750
+ const splash = {
1751
+ source,
1752
+ backgroundColor: '#ffffff',
1753
+ dark: { source, backgroundColor: '#000000' },
1754
+ }
1755
+ await generateForPlatform(workspaceRoot, 'ios', { ...app, splash }, join(output, 'ios'))
1756
+ await generateForPlatform(workspaceRoot, 'android', { ...app, splash }, join(output, 'android'))
1757
+
1758
+ const assets = join(output, 'ios', 'MyApp', 'Images.xcassets')
1759
+ const imageset = JSON.parse(
1760
+ readFileSync(join(assets, 'Splash.imageset', 'Contents.json'), 'utf8')
1761
+ )
1762
+ expect(imageset.images).toContainEqual({
1763
+ appearances: [{ appearance: 'luminosity', value: 'dark' }],
1764
+ filename: 'splash-dark@3x.png',
1765
+ idiom: 'universal',
1766
+ scale: '3x',
1767
+ })
1768
+ expect(existsSync(join(assets, 'Splash.imageset', 'splash-dark.png'))).toBe(true)
1769
+ const colorset = JSON.parse(
1770
+ readFileSync(join(assets, 'SplashBackground.colorset', 'Contents.json'), 'utf8')
1771
+ )
1772
+ expect(colorset.colors[0].color.components).toMatchObject({ red: '1.000' })
1773
+ expect(colorset.colors[1]).toMatchObject({
1774
+ appearances: [{ appearance: 'luminosity', value: 'dark' }],
1775
+ color: { components: { red: '0.000', green: '0.000', blue: '0.000' } },
1776
+ })
1777
+ const storyboard = readFileSync(
1778
+ join(output, 'ios', 'MyApp', 'LaunchScreen.storyboard'),
1779
+ 'utf8'
1780
+ )
1781
+ expect(storyboard).toContain('<color key="backgroundColor" name="SplashBackground"/>')
1782
+ expect(storyboard).toContain('<namedColor name="SplashBackground">')
1783
+
1784
+ const res = join(output, 'android', 'app', 'src', 'main', 'res')
1785
+ expect(readFileSync(join(res, 'values-night', 'colors.xml'), 'utf8')).toContain(
1786
+ '<color name="splash_background">#000000</color>'
1787
+ )
1788
+ expect(
1789
+ await sharp(join(res, 'drawable-night-xxxhdpi', 'splash.png')).metadata()
1790
+ ).toMatchObject({ width: 1152, height: 1152 })
1791
+ }, 180000)
1792
+
1793
+ it('lays a full-bleed background image under the ios launch artwork', async () => {
1794
+ const workspaceRoot = fileURLToPath(new URL('../../../..', import.meta.url))
1795
+ const output = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-splash-bg-'))
1796
+ const source = fileURLToPath(
1797
+ new URL('../../../../examples/one-basic/public/splash.png', import.meta.url)
1798
+ )
1799
+ const backgroundImage = join(output, 'ground.png')
1800
+ await sharp({
1801
+ create: { width: 30, height: 60, channels: 3, background: '#ececec' },
1802
+ })
1803
+ .png()
1804
+ .toFile(backgroundImage)
1805
+ const splash = {
1806
+ source,
1807
+ backgroundColor: '#ececec',
1808
+ width: 60,
1809
+ backgroundImage,
1810
+ dark: { backgroundColor: '#111111', backgroundImage },
1811
+ }
1812
+ await generateForPlatform(workspaceRoot, 'ios', { ...app, splash }, join(output, 'ios'))
1813
+ const assets = join(output, 'ios', 'MyApp', 'Images.xcassets')
1814
+ const background = JSON.parse(
1815
+ readFileSync(join(assets, 'SplashBackgroundImage.imageset', 'Contents.json'), 'utf8')
1816
+ )
1817
+ expect(background.images.map((image: { filename: string }) => image.filename)).toEqual([
1818
+ 'splashbackgroundimage.png',
1819
+ 'splashbackgroundimage-dark.png',
1820
+ ])
1821
+ const storyboard = readFileSync(
1822
+ join(output, 'ios', 'MyApp', 'LaunchScreen.storyboard'),
1823
+ 'utf8'
1824
+ )
1825
+ // the background paints first, so the artwork sits above it
1826
+ expect(storyboard.indexOf('image="SplashBackgroundImage"')).toBeGreaterThan(-1)
1827
+ expect(storyboard.indexOf('image="SplashBackgroundImage"')).toBeLessThan(
1828
+ storyboard.indexOf('image="Splash"')
1829
+ )
1830
+ expect(storyboard).toContain('firstAttribute="top" secondItem="launch-view" secondAttribute="top" id="splash-background-top"')
1831
+ expect(storyboard).toContain('<image name="SplashBackgroundImage" width="30" height="60"/>')
1832
+ expect(storyboard).toContain('firstAttribute="width" constant="60"')
1833
+ }, 180000)
1834
+
1835
+ it('lays a full-bleed background image under the android launch artwork', async () => {
1836
+ const workspaceRoot = fileURLToPath(new URL('../../../..', import.meta.url))
1837
+ const output = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-splash-bg-android-'))
1838
+ const source = fileURLToPath(
1839
+ new URL('../../../../examples/one-basic/public/splash.png', import.meta.url)
1840
+ )
1841
+ const backgroundImage = join(output, 'ground.png')
1842
+ await sharp({
1843
+ create: { width: 30, height: 60, channels: 3, background: '#ececec' },
1844
+ })
1845
+ .png()
1846
+ .toFile(backgroundImage)
1847
+ const splash = {
1848
+ source,
1849
+ backgroundColor: '#ececec',
1850
+ width: 60,
1851
+ backgroundImage,
1852
+ dark: { backgroundColor: '#111111', backgroundImage },
1853
+ }
1854
+ await generateForPlatform(
1855
+ workspaceRoot,
1856
+ 'android',
1857
+ { ...app, splash },
1858
+ join(output, 'android')
1859
+ )
1860
+ const res = join(output, 'android', 'app', 'src', 'main', 'res')
1861
+ // one nodpi asset per appearance at the source resolution
1862
+ for (const prefix of ['drawable-nodpi', 'drawable-night-nodpi']) {
1863
+ expect(
1864
+ await sharp(join(res, prefix, 'splash_background.png')).metadata()
1865
+ ).toMatchObject({ width: 30, height: 60 })
1866
+ }
1867
+ const launchScreen = readFileSync(join(res, 'drawable', 'launch_screen.xml'), 'utf8')
1868
+ // the background paints first and fills, so the centered mark sits above it
1869
+ expect(launchScreen.indexOf('@drawable/splash_background')).toBeGreaterThan(-1)
1870
+ expect(launchScreen.indexOf('@drawable/splash_background')).toBeLessThan(
1871
+ launchScreen.indexOf('@drawable/splash"')
1872
+ )
1873
+ expect(launchScreen).toContain(
1874
+ '<bitmap android:gravity="fill" android:src="@drawable/splash_background" />'
1875
+ )
1876
+ expect(launchScreen).toContain(
1877
+ '<bitmap android:gravity="center" android:src="@drawable/splash" />'
1878
+ )
1879
+ }, 180000)
1880
+
1881
+ it('writes the accent color asset and names it in the Info.plist', async () => {
1882
+ const workspaceRoot = fileURLToPath(new URL('../../../..', import.meta.url))
1883
+ const output = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-accent-'))
1884
+ const accented = {
1885
+ ...app,
1886
+ ios: { ...app.ios, accentColor: { light: '#867286', dark: '#c8bfc8' } },
1887
+ }
1888
+ await generateForPlatform(workspaceRoot, 'ios', accented, join(output, 'ios'))
1889
+ const colorset = JSON.parse(
1890
+ readFileSync(
1891
+ join(output, 'ios', 'MyApp', 'Images.xcassets', 'AccentColor.colorset', 'Contents.json'),
1892
+ 'utf8'
1893
+ )
1894
+ )
1895
+ expect(colorset.colors).toHaveLength(2)
1896
+ expect(colorset.colors[1].appearances).toEqual([{ appearance: 'luminosity', value: 'dark' }])
1897
+ const plist = readFileSync(join(output, 'ios', 'MyApp', 'Info.plist'), 'utf8')
1898
+ expect(plist).toContain('<key>NSAccentColorName</key>\n\t<string>AccentColor</string>')
1899
+ }, 180000)
1900
+
1125
1901
  it('regenerates byte-identical projects from the same manifest', async () => {
1126
1902
  // root stays the workspace so the installed community template resolves;
1127
1903
  // output goes to isolated temp dirs, never the repo.
@@ -1203,6 +1979,8 @@ describe('generateForPlatform determinism', () => {
1203
1979
  'utf8'
1204
1980
  )
1205
1981
  expect(mainActivity).toContain('RNScreensFragmentFactory')
1982
+ expect(mainActivity).toContain('import com.margelo.nitro.one.OneLaunchScreen')
1983
+ expect(mainActivity).toMatch(/super\.onCreate\(null\)\n.*\n\s*OneLaunchScreen\.hold\(this\)/)
1206
1984
  }, 180000)
1207
1985
  })
1208
1986
 
@@ -1276,7 +2054,7 @@ describe('ios widgets', () => {
1276
2054
  const readGenerated = (relativePath: string) =>
1277
2055
  readFileSync(join(output, 'ios', relativePath), 'utf8')
1278
2056
  const rendered = readGenerated('OneWidgets/OneWidget.swift')
1279
- // every node type packages/native/src/widgets/view.ts can emit needs a
2057
+ // every node type packages/one/src/platform/widgets/view.ts can emit needs a
1280
2058
  // Swift decode path, or the extension renders a blank subtree
1281
2059
  for (const type of [
1282
2060
  'text',
@@ -1359,7 +2137,7 @@ describe('ios widgets', () => {
1359
2137
  await generateForPlatform(
1360
2138
  workspaceRoot,
1361
2139
  'ios',
1362
- { ...widgetsApp, notifications: { push: true } },
2140
+ { ...widgetsApp, notifications: { push: true, apsEnvironment: 'production' } },
1363
2141
  join(output, 'ios')
1364
2142
  )
1365
2143
 
@@ -1377,7 +2155,806 @@ describe('ios widgets', () => {
1377
2155
  join(output, 'ios', 'MyApp', 'OneAppWidgets.entitlements'),
1378
2156
  'utf8'
1379
2157
  )
1380
- expect(appEntitlements).toContain('<key>aps-environment</key>')
2158
+ expect(appEntitlements).toContain('<key>aps-environment</key>\n\t<string>production</string>')
1381
2159
  expect(appEntitlements).toContain('group.dev.one.myapp')
1382
2160
  }, 180000)
1383
2161
  })
2162
+
2163
+ describe('app kotlin source discovery', () => {
2164
+ it('generates app sources without harvesting nested javascript packages', async () => {
2165
+ const root = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-kotlin-'))
2166
+ const workspaceModules = fileURLToPath(
2167
+ new URL('../../../../node_modules', import.meta.url)
2168
+ )
2169
+ const { symlinkSync } = await import('node:fs')
2170
+ mkdirSync(join(root, 'node_modules', '@react-native-community'), { recursive: true })
2171
+ for (const name of ['cli', 'template']) {
2172
+ symlinkSync(
2173
+ join(workspaceModules, '@react-native-community', name),
2174
+ join(root, 'node_modules', '@react-native-community', name)
2175
+ )
2176
+ }
2177
+ writeFileSync(join(root, 'package.json'), '{"name":"app"}')
2178
+ const appDir = join(root, 'features', 'counter')
2179
+ const dependencyDir = join(root, 'packages', 'vendored')
2180
+ mkdirSync(appDir, { recursive: true })
2181
+ mkdirSync(join(dependencyDir, 'fixtures'), { recursive: true })
2182
+ const source = 'package app.counter\nclass Counter\n'
2183
+ writeFileSync(join(appDir, 'Counter.kt'), source)
2184
+ writeFileSync(join(dependencyDir, 'package.json'), '{"name":"vendored"}')
2185
+ writeFileSync(
2186
+ join(dependencyDir, 'fixtures', 'Browser.kt'),
2187
+ 'package browser.fixture\nclass Browser\n'
2188
+ )
2189
+ await generateForPlatform(root, 'android', app)
2190
+ const generated = join(root, 'android', 'app', 'src', 'main', 'java', 'one', 'source')
2191
+ const ids = readdirSync(generated)
2192
+ expect(ids).toHaveLength(1)
2193
+ expect(readFileSync(join(generated, ids[0], 'Counter.kt'), 'utf8')).toBe(source)
2194
+ }, 180000)
2195
+
2196
+ it('compiles declared workspace sources with the bundle root source ids', async () => {
2197
+ const root = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-kotlin-workspace-'))
2198
+ const dest = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-kotlin-workspace-dest-'))
2199
+ const project = join(root, 'project')
2200
+ mkdirSync(join(project, 'native'), { recursive: true })
2201
+ mkdirSync(join(project, 'vendor'), { recursive: true })
2202
+ writeFileSync(join(root, 'package.json'), JSON.stringify({ workspaces: ['project'] }))
2203
+ writeFileSync(join(project, 'package.json'), '{"name":"payload"}')
2204
+ writeFileSync(join(project, 'vendor', 'package.json'), '{"name":"vendor"}')
2205
+ writeFileSync(join(project, 'vendor', 'Fixture.kt'), 'class Fixture')
2206
+ const source = 'import dev.onejs.one.source.OneModule\nobject Counter : OneModule { fun value(): Int = 42 }'
2207
+ const file = join(project, 'native', 'Counter.kt')
2208
+ writeFileSync(file, source)
2209
+ await generateKotlinSources({ root, dest })
2210
+ const { kotlinSourceId } = await import('../utils/nativeSourceContract')
2211
+ const output = join(dest, 'app/src/main/java/one/source')
2212
+ const id = kotlinSourceId(root, file)
2213
+ expect(readdirSync(output)).toEqual([id])
2214
+ expect(readFileSync(join(output, id, 'Counter.kt'), 'utf8')).toBe(source)
2215
+ expect(readFileSync(join(output, id, `OneNativeSource_${id}.kt`), 'utf8')).toContain(`${id}.Counter`)
2216
+ })
2217
+
2218
+ it('skips evidence directories holding preserved proof sources', async () => {
2219
+ const root = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-kotlin-evidence-'))
2220
+ const dest = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-kotlin-evidence-dest-'))
2221
+ const live = 'package app.counter\nclass Counter\n'
2222
+ writeFileSync(join(root, 'Counter.kt'), live)
2223
+ mkdirSync(join(root, 'proofs', 'evidence'), { recursive: true })
2224
+ const duplicate = 'class Preserved\n'
2225
+ writeFileSync(join(root, 'proofs', 'evidence', 'source-before-build.kt'), duplicate)
2226
+ writeFileSync(join(root, 'proofs', 'evidence', 'fault-source.kt'), duplicate)
2227
+ await generateKotlinSources({ root, dest })
2228
+ const generated = join(dest, 'app', 'src', 'main', 'java', 'one', 'source')
2229
+ const ids = readdirSync(generated)
2230
+ expect(ids).toHaveLength(1)
2231
+ expect(readFileSync(join(generated, ids[0], 'Counter.kt'), 'utf8')).toBe(live)
2232
+ })
2233
+
2234
+ it('drops sources an earlier prebuild generated that are gone now', async () => {
2235
+ const root = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-kotlin-stale-'))
2236
+ const dest = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-kotlin-stale-dest-'))
2237
+ const live = 'package app.counter\nclass Counter\n'
2238
+ writeFileSync(join(root, 'Counter.kt'), live)
2239
+ mkdirSync(join(root, 'proofs'), { recursive: true })
2240
+ writeFileSync(join(root, 'proofs', 'Removed.kt'), 'class Removed\n')
2241
+ await generateKotlinSources({ root, dest })
2242
+ const generated = join(dest, 'app', 'src', 'main', 'java', 'one', 'source')
2243
+ expect(readdirSync(generated)).toHaveLength(2)
2244
+ rmSync(join(root, 'proofs'), { recursive: true })
2245
+ await generateKotlinSources({ root, dest })
2246
+ const ids = readdirSync(generated)
2247
+ expect(ids).toHaveLength(1)
2248
+ expect(readFileSync(join(generated, ids[0], 'Counter.kt'), 'utf8')).toBe(live)
2249
+ rmSync(join(root, 'Counter.kt'))
2250
+ await generateKotlinSources({ root, dest })
2251
+ expect(existsSync(generated)).toBe(false)
2252
+ })
2253
+ })
2254
+
2255
+ describe('swift cxx interop', () => {
2256
+ it('maps the same package names as prebuild and rejects collisions', () => {
2257
+ const root = mkdtempSync(join(tmpdir(), 'vxrn-swift-package-map-'))
2258
+ const first = join(root, 'features', 'my-badge')
2259
+ const second = join(root, 'other', 'counter')
2260
+ mkdirSync(first, { recursive: true })
2261
+ mkdirSync(second, { recursive: true })
2262
+ writeFileSync(join(first, 'Package.swift'), '')
2263
+ writeFileSync(join(second, 'Package.swift'), '')
2264
+ const packages = swiftPackageDirectories(root)
2265
+ expect(packages.size).toBe(2)
2266
+ expect(packages.get('my_badge')).toBe(first)
2267
+ expect(packages.get('counter')).toBe(second)
2268
+ const duplicate = join(root, 'other', 'my_badge')
2269
+ mkdirSync(duplicate)
2270
+ writeFileSync(join(duplicate, 'Package.swift'), '')
2271
+ expect(() => swiftPackageDirectories(root)).toThrow(/both have name my_badge/)
2272
+ })
2273
+
2274
+ it('emits cxx flags for swift packages', async () => {
2275
+ const root = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-swift-cxx-'))
2276
+ const workspaceModules = fileURLToPath(
2277
+ new URL('../../../../node_modules', import.meta.url)
2278
+ )
2279
+ mkdirSync(join(root, 'node_modules', '@react-native-community'), { recursive: true })
2280
+ const { symlinkSync } = await import('node:fs')
2281
+ for (const name of ['cli', 'template']) {
2282
+ symlinkSync(
2283
+ join(workspaceModules, '@react-native-community', name),
2284
+ join(root, 'node_modules', '@react-native-community', name)
2285
+ )
2286
+ }
2287
+ const pkgDir = join(root, 'MyFeature')
2288
+ mkdirSync(join(pkgDir, 'Sources'), { recursive: true })
2289
+ writeFileSync(join(pkgDir, 'Package.swift'), '// swift-tools-version: 5.9\n')
2290
+ writeFileSync(
2291
+ join(pkgDir, 'Sources', 'Feature.swift'),
2292
+ 'import One\npublic func probe() { OneNativeRegisteredValue.register("x", for: "x") }\n'
2293
+ )
2294
+ await generateForPlatform(root, 'ios', app)
2295
+ const podspec = readFileSync(
2296
+ join(root, 'ios', 'OneSwiftPackages', 'MyFeature', 'MyFeature.podspec'),
2297
+ 'utf8'
2298
+ )
2299
+ expect(podspec).toContain(
2300
+ '$(inherited) -cxx-interoperability-mode=default -Xcc -std=c++20'
2301
+ )
2302
+ expect(podspec).toContain('-Xfrontend -import-module -Xfrontend One')
2303
+ }, 180000)
2304
+ })
2305
+
2306
+ describe('one updates prebuild', () => {
2307
+ const updatesApp = {
2308
+ ...app,
2309
+ updates: { url: 'https://updates.example.com', runtimeVersion: 'test-1' },
2310
+ } satisfies PrebuildAppConfig
2311
+
2312
+ const templateAppDelegate = `import UIKit
2313
+ import React
2314
+ import React_RCTAppDelegate
2315
+ import ReactAppDependencyProvider
2316
+
2317
+ @main
2318
+ class AppDelegate: UIResponder, UIApplicationDelegate {
2319
+ var window: UIWindow?
2320
+
2321
+ var reactNativeDelegate: ReactNativeDelegate?
2322
+ var reactNativeFactory: RCTReactNativeFactory?
2323
+
2324
+ func application(
2325
+ _ application: UIApplication,
2326
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
2327
+ ) -> Bool {
2328
+ let delegate = ReactNativeDelegate()
2329
+ let factory = RCTReactNativeFactory(delegate: delegate)
2330
+ delegate.dependencyProvider = RCTAppDependencyProvider()
2331
+
2332
+ reactNativeDelegate = delegate
2333
+ reactNativeFactory = factory
2334
+
2335
+ window = UIWindow(frame: UIScreen.main.bounds)
2336
+
2337
+ factory.startReactNative(
2338
+ withModuleName: "HelloWorld",
2339
+ in: window,
2340
+ launchOptions: launchOptions
2341
+ )
2342
+
2343
+ return true
2344
+ }
2345
+ }
2346
+
2347
+ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
2348
+ override func sourceURL(for bridge: RCTBridge) -> URL? {
2349
+ self.bundleURL()
2350
+ }
2351
+
2352
+ override func bundleURL() -> URL? {
2353
+ #if DEBUG
2354
+ RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
2355
+ #else
2356
+ Bundle.main.url(forResource: "main", withExtension: "jsbundle")
2357
+ #endif
2358
+ }
2359
+ }
2360
+ `
2361
+
2362
+ it('validates the updates config', () => {
2363
+ expect(() => validatePrebuildApp(updatesApp)).not.toThrow()
2364
+ expect(() =>
2365
+ validatePrebuildApp({
2366
+ ...app,
2367
+ updates: { runtimeVersion: 'test-1' },
2368
+ })
2369
+ ).not.toThrow()
2370
+ expect(() =>
2371
+ validatePrebuildApp({ ...app, updates: {} } as any)
2372
+ ).toThrow(/updates\.runtimeVersion/)
2373
+ expect(() =>
2374
+ validatePrebuildApp({ ...app, updates: { runtimeVersion: '' } })
2375
+ ).toThrow(/updates\.runtimeVersion/)
2376
+ expect(() =>
2377
+ validatePrebuildApp({
2378
+ ...app,
2379
+ updates: { url: '', runtimeVersion: 'test-1' },
2380
+ })
2381
+ ).toThrow(/updates\.url/)
2382
+ })
2383
+
2384
+ it('points the release bundleURL at the launcher', () => {
2385
+ const rendered = renderPrebuildFile({
2386
+ relativePath: 'HelloWorld/AppDelegate.swift',
2387
+ content: templateAppDelegate,
2388
+ platform: 'ios',
2389
+ app: updatesApp,
2390
+ })
2391
+ expect(rendered.content).toContain('OneUpdatesBundleURL()')
2392
+ expect(rendered.content).not.toContain('import One')
2393
+ expect(rendered.content).not.toContain('Bundle.main.url(forResource: "main"')
2394
+ expect(rendered.content).toContain('#if DEBUG')
2395
+
2396
+ const plain = renderPrebuildFile({
2397
+ relativePath: 'HelloWorld/AppDelegate.swift',
2398
+ content: templateAppDelegate,
2399
+ platform: 'ios',
2400
+ app,
2401
+ })
2402
+ expect(plain.content).not.toContain('OneUpdatesBundleURL')
2403
+ expect(plain.content).toContain('Bundle.main.url(forResource: "main"')
2404
+ })
2405
+
2406
+ it('points every app target at the One bridging header', () => {
2407
+ const configs = `buildSettings = {
2408
+ \t\t\t\tINFOPLIST_FILE = MyApp/Info.plist;
2409
+ \t\t\t};
2410
+ \t\t\tname = Debug;
2411
+ buildSettings = {
2412
+ \t\t\t\tINFOPLIST_FILE = MyApp/Info.plist;
2413
+ \t\t\t};
2414
+ \t\t\tname = Release;`
2415
+ const project = `shellScript = ${JSON.stringify('REACT_NATIVE_XCODE="$REACT_NATIVE_PATH/scripts/react-native-xcode.sh"\n/bin/sh -c "\\""$WITH_ENVIRONMENT\\"" \\""$REACT_NATIVE_XCODE\\"""\n')};\n${'/* AppDelegate.swift in Sources */ = {isa = PBXBuildFile;'}\n${'/* AppDelegate.swift */ = {isa = PBXFileReference;'}\n${'/* AppDelegate.swift */,'}\n${'/* AppDelegate.swift in Sources */,'}\n${configs}`
2416
+ const setting = 'SWIFT_OBJC_BRIDGING_HEADER = "MyApp/One-Bridging-Header.h";'
2417
+ // the launch screen hold needs the header with or without updates.
2418
+ for (const target of [app, updatesApp]) {
2419
+ const rendered = renderPrebuildFile({
2420
+ relativePath: 'MyApp.xcodeproj/project.pbxproj',
2421
+ content: project,
2422
+ platform: 'ios',
2423
+ app: target,
2424
+ })
2425
+ expect((rendered.content ?? '').split(setting).length - 1).toBe(2)
2426
+ }
2427
+
2428
+ const missing = project.replaceAll('INFOPLIST_FILE = MyApp/Info.plist;', 'INFOPLIST = x;')
2429
+ expect(() =>
2430
+ renderPrebuildFile({
2431
+ relativePath: 'MyApp.xcodeproj/project.pbxproj',
2432
+ content: missing,
2433
+ platform: 'ios',
2434
+ app: updatesApp,
2435
+ })
2436
+ ).toThrow('bridging header')
2437
+ })
2438
+
2439
+ it('throws instead of shipping a stock bundleURL with updates configured', () => {
2440
+ expect(() =>
2441
+ renderPrebuildFile({
2442
+ relativePath: 'HelloWorld/AppDelegate.swift',
2443
+ content: templateAppDelegate.replace('Bundle.main.url', 'Bundle.main.path'),
2444
+ platform: 'ios',
2445
+ app: updatesApp,
2446
+ })
2447
+ ).toThrow('One.Updates')
2448
+ })
2449
+
2450
+ it('locks userInterfaceStyle on both platforms, and follows the system when unset', () => {
2451
+ const plist = (userInterfaceStyle?: 'light' | 'dark' | 'automatic') =>
2452
+ renderPrebuildFile({
2453
+ relativePath: 'HelloWorld/Info.plist',
2454
+ content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
2455
+ platform: 'ios',
2456
+ app: { ...app, userInterfaceStyle },
2457
+ }).content
2458
+ expect(plist('light')).toContain('<key>UIUserInterfaceStyle</key>\n\t<string>Light</string>')
2459
+ expect(plist('dark')).toContain('<string>Dark</string>')
2460
+ expect(plist('automatic')).toContain('<string>Automatic</string>')
2461
+ expect(plist()).not.toContain('UIUserInterfaceStyle')
2462
+
2463
+ const styles =
2464
+ '<resources>\n <style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">\n </style>\n</resources>'
2465
+ const theme = (userInterfaceStyle?: 'light' | 'dark' | 'automatic') =>
2466
+ renderPrebuildFile({
2467
+ relativePath: 'app/src/main/res/values/styles.xml',
2468
+ content: styles,
2469
+ platform: 'android',
2470
+ app: { ...app, userInterfaceStyle },
2471
+ }).content
2472
+ expect(theme('light')).toContain('parent="Theme.AppCompat.Light.NoActionBar"')
2473
+ expect(theme('dark')).toContain('parent="Theme.AppCompat.NoActionBar"')
2474
+ expect(theme('automatic')).toContain('parent="Theme.AppCompat.DayNight.NoActionBar"')
2475
+ expect(theme()).toContain('parent="Theme.AppCompat.DayNight.NoActionBar"')
2476
+ })
2477
+
2478
+ it('writes the app entitlements file for links, apple sign in and custom keys', async () => {
2479
+ const workspaceRoot = fileURLToPath(new URL('../../../..', import.meta.url))
2480
+ const output = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-entitlements-'))
2481
+ const linked = {
2482
+ ...app,
2483
+ notifications: undefined,
2484
+ ios: {
2485
+ ...app.ios,
2486
+ associatedDomains: ['applinks:example.com'],
2487
+ usesAppleSignIn: true,
2488
+ entitlements: { 'com.apple.developer.icloud-container-identifiers': ['iCloud.dev.one'] },
2489
+ },
2490
+ }
2491
+ await generateForPlatform(workspaceRoot, 'ios', linked, join(output, 'ios'))
2492
+ const entitlements = readFileSync(join(output, 'ios', 'MyApp', 'MyApp.entitlements'), 'utf8')
2493
+ expect(entitlements).toContain(
2494
+ '<key>com.apple.developer.associated-domains</key>\n\t<array>\n\t\t<string>applinks:example.com</string>'
2495
+ )
2496
+ expect(entitlements).toContain('<key>com.apple.developer.applesignin</key>')
2497
+ expect(entitlements).toContain('<string>iCloud.dev.one</string>')
2498
+ expect(entitlements).not.toContain('aps-environment')
2499
+ const project = readFileSync(join(output, 'ios', 'MyApp.xcodeproj', 'project.pbxproj'), 'utf8')
2500
+ expect(project).toContain('CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements;')
2501
+
2502
+ await expect(
2503
+ generateForPlatform(
2504
+ workspaceRoot,
2505
+ 'ios',
2506
+ { ...linked, ios: { ...linked.ios, entitlements: { 'com.apple.developer.applesignin': [] } } },
2507
+ join(output, 'ios-dup')
2508
+ )
2509
+ ).rejects.toThrow('native.app already writes')
2510
+ }, 180000)
2511
+
2512
+ it('stamps extra Info.plist keys and rejects ones already written', () => {
2513
+ const plist = renderPrebuildFile({
2514
+ relativePath: 'HelloWorld/Info.plist',
2515
+ content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
2516
+ platform: 'ios',
2517
+ app: {
2518
+ ...app,
2519
+ ios: {
2520
+ ...app.ios,
2521
+ infoPlist: {
2522
+ NSUserTrackingUsageDescription: 'Measure ads & installs',
2523
+ GADIsAdManagerApp: true,
2524
+ SKAdNetworkItems: [{ SKAdNetworkIdentifier: 'abc.skadnetwork' }],
2525
+ },
2526
+ },
2527
+ },
2528
+ }).content
2529
+ expect(plist).toContain(
2530
+ '<key>NSUserTrackingUsageDescription</key>\n\t<string>Measure ads &amp; installs</string>'
2531
+ )
2532
+ expect(plist).toContain('<key>GADIsAdManagerApp</key>\n\t<true/>')
2533
+ expect(plist).toContain(
2534
+ '<key>SKAdNetworkItems</key>\n\t<array>\n\t\t<dict>\n\t\t\t<key>SKAdNetworkIdentifier</key>'
2535
+ )
2536
+ expect(() =>
2537
+ renderPrebuildFile({
2538
+ relativePath: 'HelloWorld/Info.plist',
2539
+ content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
2540
+ platform: 'ios',
2541
+ app: { ...app, ios: { ...app.ios, infoPlist: { CFBundleURLTypes: [] } } },
2542
+ })
2543
+ ).toThrow('already writes')
2544
+ })
2545
+
2546
+ it('stamps android permissions, blocked permissions, app links and sdk levels', () => {
2547
+ const manifest = renderPrebuildFile({
2548
+ relativePath: 'app/src/main/AndroidManifest.xml',
2549
+ content:
2550
+ '<manifest xmlns:android="http://schemas.android.com/apk/res/android">\n <uses-permission android:name="android.permission.INTERNET" />\n <application>\n <activity\n android:name=".MainActivity">\n </activity>\n </application>\n</manifest>',
2551
+ platform: 'android',
2552
+ app: {
2553
+ ...app,
2554
+ notifications: undefined,
2555
+ imagePicker: undefined,
2556
+ speech: undefined,
2557
+ scheme: undefined,
2558
+ android: {
2559
+ ...app.android,
2560
+ permissions: ['VIBRATE', 'com.example.permission.CUSTOM'],
2561
+ blockedPermissions: ['RECORD_AUDIO'],
2562
+ appLinks: [{ host: 'example.com', pathPrefix: '/invite' }],
2563
+ },
2564
+ },
2565
+ }).content
2566
+ expect(manifest).toContain('<uses-permission android:name="android.permission.VIBRATE" />')
2567
+ expect(manifest).toContain('<uses-permission android:name="com.example.permission.CUSTOM" />')
2568
+ expect(manifest).toContain(
2569
+ '<uses-permission android:name="android.permission.RECORD_AUDIO" tools:node="remove" />'
2570
+ )
2571
+ expect(manifest).toContain('xmlns:tools="http://schemas.android.com/tools"')
2572
+ expect(manifest).toContain('<intent-filter android:autoVerify="true">')
2573
+ expect(manifest).toContain('<data android:host="example.com" android:pathPrefix="/invite" />')
2574
+
2575
+ const gradle = renderPrebuildFile({
2576
+ relativePath: 'build.gradle',
2577
+ content: 'minSdkVersion = 24\ncompileSdkVersion = 37\ntargetSdkVersion = 36',
2578
+ platform: 'android',
2579
+ app: { ...app, android: { ...app.android, targetSdk: 35, compileSdk: 36 } },
2580
+ }).content
2581
+ expect(gradle).toBe('minSdkVersion = 28\ncompileSdkVersion = 36\ntargetSdkVersion = 35')
2582
+ })
2583
+
2584
+ it('turns on release minify, resource shrinking and extra proguard rules', () => {
2585
+ const android = {
2586
+ ...app.android,
2587
+ minify: true,
2588
+ shrinkResources: true,
2589
+ proguardRules: '-keep class com.example.** { *; }',
2590
+ }
2591
+ const gradle = renderPrebuildFile({
2592
+ relativePath: 'app/build.gradle',
2593
+ content:
2594
+ 'react {\n autolinkLibrariesWithApp()\n}\ndef enableProguardInReleaseBuilds = false\n release {\n minifyEnabled enableProguardInReleaseBuilds\n }',
2595
+ platform: 'android',
2596
+ app: { ...app, android },
2597
+ }).content
2598
+ expect(gradle).toContain('def enableProguardInReleaseBuilds = true')
2599
+ expect(gradle).toContain(
2600
+ 'minifyEnabled enableProguardInReleaseBuilds\n shrinkResources true'
2601
+ )
2602
+ const rules = renderPrebuildFile({
2603
+ relativePath: 'app/proguard-rules.pro',
2604
+ content: '# Add project specific ProGuard rules here.\n',
2605
+ platform: 'android',
2606
+ app: { ...app, android },
2607
+ }).content
2608
+ expect(rules).toBe(
2609
+ '# Add project specific ProGuard rules here.\n\n-keep class com.example.** { *; }\n'
2610
+ )
2611
+ expect(() =>
2612
+ renderPrebuildFile({
2613
+ relativePath: 'app/build.gradle',
2614
+ content: 'react {\n autolinkLibrariesWithApp()\n}\nno switch',
2615
+ platform: 'android',
2616
+ app: { ...app, android },
2617
+ })
2618
+ ).toThrow('proguard switch')
2619
+ })
2620
+
2621
+ it('stamps orientation as expo does, and nothing when unset', () => {
2622
+ const plistTemplate =
2623
+ '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n\t<key>UISupportedInterfaceOrientations</key>\n\t<array>\n\t\t<string>UIInterfaceOrientationPortrait</string>\n\t</array>\n</dict>'
2624
+ const landscape = renderPrebuildFile({
2625
+ relativePath: 'HelloWorld/Info.plist',
2626
+ content: plistTemplate,
2627
+ platform: 'ios',
2628
+ app: { ...app, orientation: 'landscape' },
2629
+ })
2630
+ expect(landscape.content).toContain(
2631
+ '<array>\n\t\t<string>UIInterfaceOrientationLandscapeLeft</string>\n\t\t<string>UIInterfaceOrientationLandscapeRight</string>\n\t</array>'
2632
+ )
2633
+ expect(landscape.content).not.toContain('UIInterfaceOrientationPortrait<')
2634
+ const unset = renderPrebuildFile({
2635
+ relativePath: 'HelloWorld/Info.plist',
2636
+ content: plistTemplate,
2637
+ platform: 'ios',
2638
+ app,
2639
+ })
2640
+ expect(unset.content).toContain('<string>UIInterfaceOrientationPortrait</string>')
2641
+ expect(unset.content).not.toContain('PortraitUpsideDown')
2642
+
2643
+ const manifestTemplate =
2644
+ '<manifest>\n <uses-permission android:name="android.permission.INTERNET" />\n <application>\n <activity\n android:name=".MainActivity"\n android:exported="true">\n </activity>\n </application>\n</manifest>'
2645
+ for (const [orientation, attribute] of [
2646
+ ['portrait', 'portrait'],
2647
+ ['landscape', 'landscape'],
2648
+ ['default', 'unspecified'],
2649
+ ] as const) {
2650
+ const rendered = renderPrebuildFile({
2651
+ relativePath: 'app/src/main/AndroidManifest.xml',
2652
+ content: manifestTemplate,
2653
+ platform: 'android',
2654
+ app: {
2655
+ ...app,
2656
+ notifications: undefined,
2657
+ imagePicker: undefined,
2658
+ speech: undefined,
2659
+ orientation,
2660
+ },
2661
+ })
2662
+ expect(rendered.content).toContain(`android:screenOrientation="${attribute}"`)
2663
+ }
2664
+ expect(() =>
2665
+ validatePrebuildApp({ ...app, orientation: 'sideways' as 'default' })
2666
+ ).toThrow('orientation "sideways"')
2667
+ })
2668
+
2669
+ it('stamps the updates url and runtime version into Info.plist', () => {
2670
+ const plist = renderPrebuildFile({
2671
+ relativePath: 'HelloWorld/Info.plist',
2672
+ content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
2673
+ platform: 'ios',
2674
+ app: updatesApp,
2675
+ })
2676
+ expect(plist.content).toContain('<key>OneUpdatesURL</key>')
2677
+ expect(plist.content).toContain('<string>https://updates.example.com</string>')
2678
+ expect(plist.content).toContain('<key>OneUpdatesRuntimeVersion</key>')
2679
+ expect(plist.content).toContain('<string>test-1</string>')
2680
+
2681
+ const noUrl = renderPrebuildFile({
2682
+ relativePath: 'HelloWorld/Info.plist',
2683
+ content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
2684
+ platform: 'ios',
2685
+ app: { ...app, updates: { runtimeVersion: 'test-1' } },
2686
+ })
2687
+ expect(noUrl.content).not.toContain('<key>OneUpdatesURL</key>')
2688
+ expect(noUrl.content).toContain('<key>OneUpdatesRuntimeVersion</key>')
2689
+
2690
+ const plain = renderPrebuildFile({
2691
+ relativePath: 'HelloWorld/Info.plist',
2692
+ content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
2693
+ platform: 'ios',
2694
+ app,
2695
+ })
2696
+ expect(plain.content).not.toContain('OneUpdates')
2697
+ })
2698
+
2699
+ it('writes the embedded manifest from the ios bundle phase', () => {
2700
+ const phase = `shellScript = ${JSON.stringify('REACT_NATIVE_XCODE="$REACT_NATIVE_PATH/scripts/react-native-xcode.sh"\n/bin/sh -c "\\""$WITH_ENVIRONMENT\\"" \\""$REACT_NATIVE_XCODE\\"""\n')};\n${'/* AppDelegate.swift in Sources */ = {isa = PBXBuildFile;'}\n${'/* AppDelegate.swift */ = {isa = PBXFileReference;'}\n${'/* AppDelegate.swift */,'}\n${'/* AppDelegate.swift in Sources */,'}\nINFOPLIST_FILE = MyApp/Info.plist;\nINFOPLIST_FILE = MyApp/Info.plist;`
2701
+ const rendered = renderPrebuildFile({
2702
+ relativePath: 'HelloWorld.xcodeproj/project.pbxproj',
2703
+ content: phase,
2704
+ platform: 'ios',
2705
+ app: updatesApp,
2706
+ })
2707
+ expect(rendered.content).toContain(
2708
+ '[vxrn/one] the embedded update manifest lands beside the release bundle'
2709
+ )
2710
+ expect(rendered.content).toContain('one-updates-embedded.json')
2711
+ expect(rendered.content).toContain('runtimeVersion: \\"test-1\\"')
2712
+
2713
+ const plain = renderPrebuildFile({
2714
+ relativePath: 'HelloWorld.xcodeproj/project.pbxproj',
2715
+ content: phase,
2716
+ platform: 'ios',
2717
+ app,
2718
+ })
2719
+ expect(plain.content).not.toContain('one-updates-embedded.json')
2720
+ })
2721
+
2722
+ it('points MainApplication at the One host factory', () => {
2723
+ const templateMainApplication = `package com.helloworld
2724
+
2725
+ import android.app.Application
2726
+ import com.facebook.react.PackageList
2727
+ import com.facebook.react.ReactApplication
2728
+ import com.facebook.react.ReactHost
2729
+ import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
2730
+ import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
2731
+ `
2732
+ const rendered = renderPrebuildFile({
2733
+ relativePath: 'app/src/main/java/com/helloworld/MainApplication.kt',
2734
+ content: templateMainApplication,
2735
+ platform: 'android',
2736
+ app: updatesApp,
2737
+ })
2738
+ expect(rendered.content).toContain(
2739
+ 'import com.margelo.nitro.one.OneUpdatesReactHost.getDefaultReactHost'
2740
+ )
2741
+ expect(rendered.content).not.toContain('DefaultReactHost.getDefaultReactHost')
2742
+
2743
+ const plain = renderPrebuildFile({
2744
+ relativePath: 'app/src/main/java/com/helloworld/MainApplication.kt',
2745
+ content: templateMainApplication,
2746
+ platform: 'android',
2747
+ app,
2748
+ })
2749
+ expect(plain.content).toContain('DefaultReactHost.getDefaultReactHost')
2750
+
2751
+ expect(() =>
2752
+ renderPrebuildFile({
2753
+ relativePath: 'app/src/main/java/com/helloworld/MainApplication.kt',
2754
+ content: 'package com.helloworld\n',
2755
+ platform: 'android',
2756
+ app: updatesApp,
2757
+ })
2758
+ ).toThrow('One.Updates')
2759
+ })
2760
+
2761
+ it('stamps the updates config into the android manifest', () => {
2762
+ const manifest = `<manifest>
2763
+ <uses-permission android:name="android.permission.INTERNET" />
2764
+ <application>
2765
+ <activity android:name=".MainActivity">
2766
+ </activity>
2767
+ </application>
2768
+ </manifest>`
2769
+ const rendered = renderPrebuildFile({
2770
+ relativePath: 'app/src/main/AndroidManifest.xml',
2771
+ content: manifest,
2772
+ platform: 'android',
2773
+ app: updatesApp,
2774
+ })
2775
+ expect(rendered.content).toContain(
2776
+ '<meta-data android:name="dev.onejs.updates.url" android:value="https://updates.example.com" />'
2777
+ )
2778
+ expect(rendered.content).toContain(
2779
+ '<meta-data android:name="dev.onejs.updates.runtimeVersion" android:value="@string/one_updates_runtime_version" />'
2780
+ )
2781
+ })
2782
+
2783
+ it('writes the embedded manifest from the android bundle task', () => {
2784
+ const rendered = renderPrebuildFile({
2785
+ relativePath: 'app/build.gradle',
2786
+ content: 'react {\n entryFile = file("x")\n}\n',
2787
+ platform: 'android',
2788
+ app: updatesApp,
2789
+ })
2790
+ expect(rendered.content).toContain(
2791
+ '[vxrn/one] the embedded update manifest lands beside the release bundle'
2792
+ )
2793
+ expect(rendered.content).toContain('one-updates-embedded.json')
2794
+ expect(rendered.content).toContain('runtimeVersion: "test-1"')
2795
+
2796
+ const plain = renderPrebuildFile({
2797
+ relativePath: 'app/build.gradle',
2798
+ content: 'react {\n entryFile = file("x")\n}\n',
2799
+ platform: 'android',
2800
+ app,
2801
+ })
2802
+ expect(plain.content).not.toContain('one-updates-embedded.json')
2803
+ })
2804
+ })
2805
+
2806
+ describe('kotlin compose app integration', () => {
2807
+ it('enables compose plugin, buildFeatures, and dependencies in gradle files', () => {
2808
+ const dest = mkdtempSync(join(tmpdir(), 'vxrn-compose-gradle-'))
2809
+ const rootGradle = join(dest, 'build.gradle')
2810
+ const appDir = join(dest, 'app')
2811
+ const appGradle = join(appDir, 'build.gradle')
2812
+ mkdirSync(appDir, { recursive: true })
2813
+
2814
+ writeFileSync(
2815
+ rootGradle,
2816
+ `buildscript {
2817
+ dependencies {
2818
+ classpath("com.android.tools.build:gradle")
2819
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
2820
+ }
2821
+ }
2822
+ `
2823
+ )
2824
+ writeFileSync(
2825
+ appGradle,
2826
+ `apply plugin: "com.android.application"
2827
+ apply plugin: "org.jetbrains.kotlin.android"
2828
+ apply plugin: "com.facebook.react"
2829
+
2830
+ android {
2831
+ namespace "com.example.app"
2832
+ }
2833
+
2834
+ dependencies {
2835
+ implementation("com.facebook.react:react-android")
2836
+ }
2837
+ `
2838
+ )
2839
+
2840
+ enableAppComposeIntegration(dest)
2841
+
2842
+ const updatedRoot = readFileSync(rootGradle, 'utf8')
2843
+ expect(updatedRoot).toContain('org.jetbrains.kotlin.plugin.compose.gradle.plugin')
2844
+
2845
+ const updatedApp = readFileSync(appGradle, 'utf8')
2846
+ expect(updatedApp).toContain('apply plugin: "org.jetbrains.kotlin.plugin.compose"')
2847
+ expect(updatedApp).toContain('buildFeatures {\n compose true\n }')
2848
+ expect(updatedApp).toContain('androidx.compose.ui:ui:$composeUiVersion')
2849
+ expect(updatedApp).toContain('androidx.compose.foundation:foundation:$composeUiVersion')
2850
+ expect(updatedApp).toContain('androidx.compose.material3:material3:$material3Version')
2851
+ expect(updatedApp).toContain(
2852
+ 'androidx.navigation:navigation-compose:$navigationComposeVersion'
2853
+ )
2854
+ })
2855
+
2856
+ it('enables compose only when .kt has views, keeping module-only prebuild unchanged', async () => {
2857
+ const root = mkdtempSync(join(tmpdir(), 'vxrn-compose-views-root-'))
2858
+ const dest = mkdtempSync(join(tmpdir(), 'vxrn-compose-views-dest-'))
2859
+ const rootGradle = join(dest, 'build.gradle')
2860
+ const appDir = join(dest, 'app')
2861
+ const appGradle = join(appDir, 'build.gradle')
2862
+ mkdirSync(appDir, { recursive: true })
2863
+
2864
+ const initialRootGradle = `buildscript {
2865
+ dependencies {
2866
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
2867
+ }
2868
+ }
2869
+ `
2870
+ const initialAppGradle = `apply plugin: "org.jetbrains.kotlin.android"
2871
+ android {
2872
+ namespace "com.example"
2873
+ }
2874
+ dependencies {
2875
+ implementation("com.facebook.react:react-android")
2876
+ }
2877
+ `
2878
+ writeFileSync(rootGradle, initialRootGradle)
2879
+ writeFileSync(appGradle, initialAppGradle)
2880
+
2881
+ // 1. Module-only source: prebuild must leave gradle unchanged
2882
+ writeFileSync(
2883
+ join(root, 'MathModule.kt'),
2884
+ `package com.example
2885
+ object MathModule : dev.onejs.one.source.OneModule {
2886
+ fun add(a: Int, b: Int): Int = a + b
2887
+ }
2888
+ `
2889
+ )
2890
+
2891
+ await generateKotlinSources({ root, dest })
2892
+
2893
+ expect(readFileSync(rootGradle, 'utf8')).toBe(initialRootGradle)
2894
+ expect(readFileSync(appGradle, 'utf8')).toBe(initialAppGradle)
2895
+
2896
+ // 2. Add a composable view source: prebuild must enable Compose
2897
+ writeFileSync(
2898
+ join(root, 'Counter.kt'),
2899
+ `package com.example
2900
+ import androidx.compose.runtime.Composable
2901
+
2902
+ @Composable
2903
+ fun Counter(label: String, onIncrement: () -> Unit) {
2904
+ }
2905
+ `
2906
+ )
2907
+
2908
+ await generateKotlinSources({ root, dest })
2909
+
2910
+ const updatedRoot = readFileSync(rootGradle, 'utf8')
2911
+ const updatedApp = readFileSync(appGradle, 'utf8')
2912
+ expect(updatedRoot).toContain('org.jetbrains.kotlin.plugin.compose')
2913
+ expect(updatedApp).toContain('apply plugin: "org.jetbrains.kotlin.plugin.compose"')
2914
+ expect(updatedApp).toContain('compose true')
2915
+ expect(updatedApp).toContain('androidx.compose.ui:ui')
2916
+ })
2917
+
2918
+ it('adds material-icons-core only when a kotlin source imports material icons', async () => {
2919
+ const root = mkdtempSync(join(tmpdir(), 'vxrn-compose-icons-root-'))
2920
+ const dest = mkdtempSync(join(tmpdir(), 'vxrn-compose-icons-dest-'))
2921
+ const appDir = join(dest, 'app')
2922
+ const appGradle = join(appDir, 'build.gradle')
2923
+ mkdirSync(appDir, { recursive: true })
2924
+
2925
+ const initialAppGradle = `apply plugin: "org.jetbrains.kotlin.android"
2926
+ android {
2927
+ namespace "com.example"
2928
+ }
2929
+ dependencies {
2930
+ implementation("com.facebook.react:react-android")
2931
+ }
2932
+ `
2933
+ writeFileSync(appGradle, initialAppGradle)
2934
+ writeFileSync(
2935
+ join(root, 'Counter.kt'),
2936
+ `package com.example\nimport androidx.compose.runtime.Composable\n\n@Composable\nfun Counter(label: String) {\n}\n`
2937
+ )
2938
+
2939
+ await generateKotlinSources({ root, dest })
2940
+
2941
+ expect(readFileSync(appGradle, 'utf8')).not.toContain('material-icons-core')
2942
+
2943
+ writeFileSync(
2944
+ join(root, 'Resources.kt'),
2945
+ `package com.example\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.runtime.Composable\n\n@Composable\nfun Resources() {\n}\n`
2946
+ )
2947
+
2948
+ await generateKotlinSources({ root, dest })
2949
+
2950
+ const updated = readFileSync(appGradle, 'utf8')
2951
+ expect(updated).toContain('androidx.compose.material:material-icons-core:$materialIconsVersion')
2952
+ expect(updated).toContain('materialIconsVersion')
2953
+ // the base block still lands once on a re-prebuild
2954
+ expect(updated.split('androidx.compose.ui:ui').length - 1).toBe(1)
2955
+
2956
+ await generateKotlinSources({ root, dest })
2957
+
2958
+ expect(readFileSync(appGradle, 'utf8')).toBe(updated)
2959
+ })
2960
+ })