vxrn 2.0.0-beta.99.1 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +1116 -178
  44. package/dist/exports/prebuildWithoutExpo.mjs.map +1 -1
  45. package/dist/exports/prebuildWithoutExpo.native.js +1592 -243
  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 +1588 -48
  148. package/src/exports/prebuildWithoutExpo.ts +1573 -213
  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'"
@@ -199,6 +229,158 @@ ${APP_DELEGATE_PBXPROJ}`,
199
229
  ).toThrow('lost its LSRequiresIPhoneOS anchor')
200
230
  expect(infoPlist.content).toContain('<key>NSCameraUsageDescription</key>')
201
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
+ )
202
384
 
203
385
  const androidManifest = renderPrebuildFile({
204
386
  relativePath: 'app/src/main/AndroidManifest.xml',
@@ -210,6 +392,12 @@ ${APP_DELEGATE_PBXPROJ}`,
210
392
  expect(androidManifest.content).toContain(
211
393
  '<uses-permission android:name="android.permission.CAMERA" />'
212
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
+ )
213
401
  expect(androidManifest.content).toContain(
214
402
  '<action android:name="android.intent.action.VIEW" />'
215
403
  )
@@ -241,7 +429,8 @@ ${APP_DELEGATE_PBXPROJ}`,
241
429
  const androidSettings = renderPrebuildFile({
242
430
  relativePath: 'settings.gradle',
243
431
  content: `pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") }
244
- 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() }`,
245
434
  platform: 'android',
246
435
  app,
247
436
  })
@@ -254,6 +443,48 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
254
443
  expect(androidSettings.content).not.toContain('../node_modules')
255
444
  })
256
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
+
257
488
  it('stamps marketing and build versions from the manifest', () => {
258
489
  const stamped = {
259
490
  ...app,
@@ -308,8 +539,37 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
308
539
  expect(android.content).toContain('versionName "1.0"')
309
540
  })
310
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
+
311
571
  it('omits camera entries when imagePicker.camera is unset', () => {
312
- const bare = { ...app, imagePicker: undefined, notifications: undefined }
572
+ const bare = { ...app, imagePicker: undefined, speech: undefined, notifications: undefined }
313
573
  const infoPlist = renderPrebuildFile({
314
574
  relativePath: 'HelloWorld/Info.plist',
315
575
  content: '<dict>\n\t<key>LSRequiresIPhoneOS</key>\n</dict>',
@@ -317,6 +577,7 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
317
577
  app: bare,
318
578
  })
319
579
  expect(infoPlist.content).not.toContain('NSCameraUsageDescription')
580
+ expect(infoPlist.content).not.toContain('NSMicrophoneUsageDescription')
320
581
  const androidManifest = renderPrebuildFile({
321
582
  relativePath: 'app/src/main/AndroidManifest.xml',
322
583
  content:
@@ -325,6 +586,90 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
325
586
  app: bare,
326
587
  })
327
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')
328
673
  })
329
674
 
330
675
  it('stamps the maps key and flag only when googleMapsApiKey is set', () => {
@@ -404,7 +749,7 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
404
749
  })
405
750
 
406
751
  it('fails loudly when a notification anchor is missing', () => {
407
- const noCamera = { ...app, imagePicker: undefined }
752
+ const noCamera = { ...app, imagePicker: undefined, speech: undefined }
408
753
  expect(() =>
409
754
  renderPrebuildFile({
410
755
  relativePath: 'app/src/main/AndroidManifest.xml',
@@ -488,17 +833,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
488
833
 
489
834
  return true
490
835
  }
491
- }`,
836
+ }
837
+ class ReactNativeDelegate:${templateRnDelegate}`,
492
838
  platform: 'ios',
493
839
  app: push,
494
840
  })
495
- 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(
496
844
  'didRegisterForRemoteNotificationsWithDeviceToken'
497
845
  )
498
- expect(appDelegate.content).toContain('OneNativePushTokenDidRegister')
499
- expect(appDelegate.content).toContain(
500
- 'didFailToRegisterForRemoteNotificationsWithError'
501
- )
502
846
 
503
847
  const bareProps = renderPrebuildFile({
504
848
  relativePath: 'gradle.properties',
@@ -566,11 +910,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
566
910
  app,
567
911
  })
568
912
  ).toThrow(
569
- '[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'
570
914
  )
571
915
  })
572
916
 
573
- it('resolves the gradle plugin from the react-native package without hoisting', () => {
917
+ it('resolves build tools from their owning packages without hoisting', () => {
574
918
  const root = mkdtempSync(join(tmpdir(), 'vxrn-gradle-plugin-'))
575
919
  const settingsDir = join(root, 'android')
576
920
  mkdirSync(settingsDir, { recursive: true })
@@ -593,10 +937,31 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
593
937
  JSON.stringify({ name: '@react-native/gradle-plugin', version: '0.0.0' })
594
938
  )
595
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
+
596
960
  const rendered = renderPrebuildFile({
597
961
  relativePath: 'settings.gradle',
598
962
  content: `pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") }
599
- 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() }`,
600
965
  platform: 'android',
601
966
  app,
602
967
  })
@@ -609,6 +974,98 @@ includeBuild('../node_modules/@react-native/gradle-plugin')`,
609
974
  encoding: 'utf8',
610
975
  }).trim()
611
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()
612
1069
 
613
1070
  // negative control: a bare top-level resolution fails here, proving the
614
1071
  // fixture is genuinely non-hoisted and the old snippet would break
@@ -717,8 +1174,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
717
1174
  const rendered = renderPrebuildFile({
718
1175
  relativePath: 'HelloWorld/AppDelegate.swift',
719
1176
  content: `${templateAppDelegate}
720
- class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
721
- }`,
1177
+ class ReactNativeDelegate:${templateRnDelegate}`,
722
1178
  platform: 'ios',
723
1179
  app,
724
1180
  })
@@ -727,10 +1183,18 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
727
1183
  expect(rendered.content).not.toContain('startReactNative')
728
1184
  expect(rendered.content).not.toContain('UIWindow(frame:')
729
1185
  expect(rendered.content).not.toContain('var window')
730
- expect(rendered.content).not.toContain('var reactNativeFactory')
731
- expect(rendered.content).toContain('didRegisterForRemoteNotificationsWithDeviceToken')
732
- expect(rendered.content).toContain('OneNativePushTokenDidRegister')
733
- 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')
734
1198
  })
735
1199
 
736
1200
  it('throws instead of shipping a non-scene AppDelegate', () => {
@@ -765,6 +1229,7 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
765
1229
  expect(sceneDelegate).toContain('openURLContexts')
766
1230
  expect(sceneDelegate).toContain('continue userActivity')
767
1231
  expect(sceneDelegate).toContain('RCTLinkingManager')
1232
+ expect(sceneDelegate).toContain('appDelegate.reactNativeFactory')
768
1233
  })
769
1234
 
770
1235
  it('throws when the template loses a scene anchor', () => {
@@ -786,10 +1251,79 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
786
1251
  ).toThrow('AppDelegate.swift anchor')
787
1252
  })
788
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
+
789
1318
  it('generates a scene project from the real template', async () => {
790
1319
  const workspaceRoot = fileURLToPath(new URL('../../../..', import.meta.url))
791
1320
  const output = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-scene-'))
792
- await generateForPlatform(workspaceRoot, 'ios', app, join(output, 'ios'))
1321
+ await generateForPlatform(
1322
+ workspaceRoot,
1323
+ 'ios',
1324
+ { ...app, orientation: 'default' },
1325
+ join(output, 'ios')
1326
+ )
793
1327
 
794
1328
  const sceneDelegate = readFileSync(
795
1329
  join(output, 'ios', 'MyApp', 'SceneDelegate.swift'),
@@ -808,6 +1342,21 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
808
1342
  const infoPlist = readFileSync(join(output, 'ios', 'MyApp', 'Info.plist'), 'utf8')
809
1343
  expect(infoPlist).toContain('<key>UIApplicationSceneManifest</key>')
810
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>')
811
1360
 
812
1361
  const project = readFileSync(
813
1362
  join(output, 'ios', 'MyApp.xcodeproj', 'project.pbxproj'),
@@ -864,24 +1413,16 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
864
1413
  })
865
1414
 
866
1415
  describe('community autolink inventory', () => {
867
- it('discovers installed packages through community config, sorted', async () => {
1416
+ it('generates both projects and discovers native packages without an app cli', async () => {
868
1417
  const root = mkdtempSync(join(tmpdir(), 'vxrn-autolink-'))
869
- // everything resolves from the fixture root, exactly as in a real app;
870
- // 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.
871
1420
  const workspaceModules = fileURLToPath(
872
1421
  new URL('../../../../node_modules', import.meta.url)
873
1422
  )
874
1423
  mkdirSync(join(root, 'node_modules', '@react-native-community'), { recursive: true })
875
1424
  const { symlinkSync } = await import('node:fs')
876
- for (const name of [
877
- 'cli',
878
- 'cli-config',
879
- 'cli-config-android',
880
- 'cli-config-apple',
881
- 'cli-tools',
882
- 'cli-types',
883
- 'template',
884
- ]) {
1425
+ for (const name of ['template']) {
885
1426
  symlinkSync(
886
1427
  join(workspaceModules, '@react-native-community', name),
887
1428
  join(root, 'node_modules', '@react-native-community', name)
@@ -991,8 +1532,22 @@ describe('generateForPlatform determinism', () => {
991
1532
  const appWithIcon = {
992
1533
  ...app,
993
1534
  version: '9.9.9',
994
- ios: { ...app.ios, buildNumber: '4242' },
995
- 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
+ },
996
1551
  icon: {
997
1552
  source: fileURLToPath(
998
1553
  new URL('../../../../examples/one-basic/public/app-icon.png', import.meta.url)
@@ -1044,6 +1599,8 @@ describe('generateForPlatform determinism', () => {
1044
1599
  expect(generatedPbxproj).toContain('CURRENT_PROJECT_VERSION = 4242;')
1045
1600
  expect(generatedPbxproj).not.toContain('MARKETING_VERSION = 1.0;')
1046
1601
  expect(generatedPbxproj).not.toContain('CURRENT_PROJECT_VERSION = 1;')
1602
+ expect(generatedPbxproj.match(/ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "TestAlternate";/g))
1603
+ .toHaveLength(2)
1047
1604
  const generatedGradle = readFileSync(
1048
1605
  join(output, 'android', 'app', 'build.gradle'),
1049
1606
  'utf8'
@@ -1061,10 +1618,23 @@ describe('generateForPlatform determinism', () => {
1061
1618
  const iosContents: { images: Array<{ filename?: string }> } = JSON.parse(
1062
1619
  readFileSync(join(iosIconDir, 'Contents.json'), 'utf8')
1063
1620
  )
1064
- expect(iosContents.images).toHaveLength(9)
1621
+ expect(iosContents.images).toHaveLength(18)
1065
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 })
1066
1628
  const iosMarketing = await sharp(join(iosIconDir, 'icon-1024.png')).metadata()
1067
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 })
1068
1638
 
1069
1639
  const androidIcon = await sharp(
1070
1640
  join(
@@ -1080,11 +1650,16 @@ describe('generateForPlatform determinism', () => {
1080
1650
  ).metadata()
1081
1651
  expect(androidIcon).toMatchObject({ width: 192, height: 192 })
1082
1652
  const iosApp = join(output, 'ios', 'MyApp')
1083
- expect(
1084
- await sharp(
1085
- join(iosApp, 'Images.xcassets', 'Splash.imageset', 'splash.png')
1086
- ).metadata()
1087
- ).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
+ }
1088
1663
  const launchStoryboard = readFileSync(join(iosApp, 'LaunchScreen.storyboard'), 'utf8')
1089
1664
  expect(launchStoryboard).toContain('image="Splash"')
1090
1665
  expect(launchStoryboard).toContain('firstAttribute="centerX"')
@@ -1117,6 +1692,21 @@ describe('generateForPlatform determinism', () => {
1117
1692
  expect(readFileSync(join(androidRes, 'values', 'styles.xml'), 'utf8')).toContain(
1118
1693
  '@drawable/launch_screen'
1119
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
+ }
1120
1710
  // android 12+ uses the configured splash image, never the launcher icon
1121
1711
  const stylesV31 = readFileSync(join(androidRes, 'values-v31', 'styles.xml'), 'utf8')
1122
1712
  expect(stylesV31).toContain('@color/splash_background')
@@ -1126,6 +1716,188 @@ describe('generateForPlatform determinism', () => {
1126
1716
  expect(stylesV31).not.toContain('ic_launcher')
1127
1717
  }, 180000)
1128
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
+
1129
1901
  it('regenerates byte-identical projects from the same manifest', async () => {
1130
1902
  // root stays the workspace so the installed community template resolves;
1131
1903
  // output goes to isolated temp dirs, never the repo.
@@ -1207,6 +1979,8 @@ describe('generateForPlatform determinism', () => {
1207
1979
  'utf8'
1208
1980
  )
1209
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\)/)
1210
1984
  }, 180000)
1211
1985
  })
1212
1986
 
@@ -1280,7 +2054,7 @@ describe('ios widgets', () => {
1280
2054
  const readGenerated = (relativePath: string) =>
1281
2055
  readFileSync(join(output, 'ios', relativePath), 'utf8')
1282
2056
  const rendered = readGenerated('OneWidgets/OneWidget.swift')
1283
- // 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
1284
2058
  // Swift decode path, or the extension renders a blank subtree
1285
2059
  for (const type of [
1286
2060
  'text',
@@ -1363,7 +2137,7 @@ describe('ios widgets', () => {
1363
2137
  await generateForPlatform(
1364
2138
  workspaceRoot,
1365
2139
  'ios',
1366
- { ...widgetsApp, notifications: { push: true } },
2140
+ { ...widgetsApp, notifications: { push: true, apsEnvironment: 'production' } },
1367
2141
  join(output, 'ios')
1368
2142
  )
1369
2143
 
@@ -1381,12 +2155,122 @@ describe('ios widgets', () => {
1381
2155
  join(output, 'ios', 'MyApp', 'OneAppWidgets.entitlements'),
1382
2156
  'utf8'
1383
2157
  )
1384
- expect(appEntitlements).toContain('<key>aps-environment</key>')
2158
+ expect(appEntitlements).toContain('<key>aps-environment</key>\n\t<string>production</string>')
1385
2159
  expect(appEntitlements).toContain('group.dev.one.myapp')
1386
2160
  }, 180000)
1387
2161
  })
1388
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
+
1389
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
+
1390
2274
  it('emits cxx flags for swift packages', async () => {
1391
2275
  const root = mkdtempSync(join(tmpdir(), 'vxrn-prebuild-swift-cxx-'))
1392
2276
  const workspaceModules = fileURLToPath(
@@ -1405,7 +2289,7 @@ describe('swift cxx interop', () => {
1405
2289
  writeFileSync(join(pkgDir, 'Package.swift'), '// swift-tools-version: 5.9\n')
1406
2290
  writeFileSync(
1407
2291
  join(pkgDir, 'Sources', 'Feature.swift'),
1408
- 'import VxrnNative\npublic func probe() { OneNativeRegisteredValue.register("x", for: "x") }\n'
2292
+ 'import One\npublic func probe() { OneNativeRegisteredValue.register("x", for: "x") }\n'
1409
2293
  )
1410
2294
  await generateForPlatform(root, 'ios', app)
1411
2295
  const podspec = readFileSync(
@@ -1415,6 +2299,662 @@ describe('swift cxx interop', () => {
1415
2299
  expect(podspec).toContain(
1416
2300
  '$(inherited) -cxx-interoperability-mode=default -Xcc -std=c++20'
1417
2301
  )
1418
- expect(podspec).toContain('-Xfrontend -import-module -Xfrontend VxrnNative')
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')
1419
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
+ })
1420
2960
  })