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,17 +1,47 @@
1
1
  import { execFileSync } from 'node:child_process'
2
+ import { createHash } from 'node:crypto'
2
3
  import module from 'node:module'
3
4
  import path from 'node:path'
4
5
  import { pathToFileURL } from 'node:url'
5
- import { validateNativeApp, type NativeAppManifest } from '@vxrn/utils/nativeAppManifest'
6
+ import {
7
+ validateNativeApp,
8
+ type NativeAppManifest,
9
+ type PlistValue,
10
+ } from '@vxrn/utils/nativeAppManifest'
6
11
  import FSExtra from 'fs-extra'
7
12
  import sharp from 'sharp'
8
- import { swiftPackageId } from '../utils/swiftPackageId'
13
+ import { swiftPackageDirectories } from '../utils/swiftPackageId'
9
14
 
10
15
  type NativeProjectPatches = {
16
+ ONE_NOTIFICATIONS: {
17
+ enabledInfoPlistKey: string
18
+ pushInfoPlistKey: string
19
+ apsEnvironment: string
20
+ androidPermissions: string[]
21
+ receiver: string
22
+ receiverAction: string
23
+ pushService: string
24
+ pushServiceAction: string
25
+ pushGradleProperty: string
26
+ }
27
+ ONE_UPDATES: {
28
+ urlInfoPlistKey: string
29
+ runtimeVersionInfoPlistKey: string
30
+ bridgingHeaderImport: string
31
+ }
32
+ pointReleaseBundleURLAtOneUpdates(appDelegate: string): string
33
+ ONE_LAUNCH_SCREEN: { bridgingHeaderImport: string }
34
+ holdLaunchScreenOverRootView(appDelegate: string): string
35
+ holdLaunchScreenInMainActivity(mainActivity: string): string
11
36
  addSetCliPathToBundleReactNativeShellScript(input: string): string
12
37
  addPodHermescToBundleReactNativeShellScript(input: string): string
13
38
  addDepsPatchToBundleReactNativeShellScript(input: string): string
39
+ addEmbeddedUpdatesManifestToBundleReactNativeShellScript(
40
+ input: string,
41
+ runtimeVersion: string
42
+ ): string
14
43
  injectFmtCxx17FixIntoPodfile(input: string): string
44
+ injectWebGPUHeaderPathsIntoPodfile(input: string): string
15
45
  injectOneSwiftPackagesIntoPodfile(input: string): string
16
46
  injectHermesMinificationPatchIntoPodfile(input: string): string
17
47
  injectReactNativeScreensGammaIntoPodfile(input: string): string
@@ -22,15 +52,27 @@ type NativeProjectPatches = {
22
52
  addReactNativeScreensFix(input: string): string
23
53
  }
24
54
 
25
- const nativeProjectPatches = module.createRequire(import.meta.url)(
55
+ const requireVxrn = module.createRequire(import.meta.url)
56
+ const nativeProjectPatches = requireVxrn(
26
57
  '../../native-project-patches.cjs'
27
58
  ) as NativeProjectPatches
59
+ const notificationsHost = nativeProjectPatches.ONE_NOTIFICATIONS
60
+ const updatesHost = nativeProjectPatches.ONE_UPDATES
28
61
 
29
62
  /*
30
63
  This code block is partially copied from meta owned repos.
31
64
  Copyright (c) Facebook, Inc. and its affiliates.
32
65
  */
33
66
 
67
+ const PORTRAIT_ORIENTATIONS = [
68
+ 'UIInterfaceOrientationPortrait',
69
+ 'UIInterfaceOrientationPortraitUpsideDown',
70
+ ]
71
+ const LANDSCAPE_ORIENTATIONS = [
72
+ 'UIInterfaceOrientationLandscapeLeft',
73
+ 'UIInterfaceOrientationLandscapeRight',
74
+ ]
75
+
34
76
  // the manifest is one({ native: { app } }), defined once in @vxrn/utils. the
35
77
  // one cli validates the full manifest before passing it here; vxrn
36
78
  // re-validates through the same definition so direct callers fail before
@@ -52,7 +94,7 @@ const IOS_BUNDLE_PLACEHOLDER =
52
94
  const ANDROID_PACKAGE_PLACEHOLDER = 'com.helloworld'
53
95
  const ANDROID_PACKAGE_PATH = 'com/helloworld'
54
96
 
55
- function patchIosBundlePhase(project: string): string {
97
+ function patchIosBundlePhase(project: string, updatesRuntimeVersion?: string): string {
56
98
  let patchedBundlePhases = 0
57
99
  const patchedProject = project.replace(
58
100
  /shellScript = ("(?:\\.|[^"\\])*");/g,
@@ -78,6 +120,20 @@ function patchIosBundlePhase(project: string): string {
78
120
  ) {
79
121
  throw new Error('[vxrn] failed to apply required iOS bundle phase patches')
80
122
  }
123
+ if (updatesRuntimeVersion !== undefined) {
124
+ patched =
125
+ nativeProjectPatches.addEmbeddedUpdatesManifestToBundleReactNativeShellScript(
126
+ patched,
127
+ updatesRuntimeVersion
128
+ )
129
+ if (
130
+ !patched.includes(
131
+ '[vxrn/one] the embedded update manifest lands beside the release bundle'
132
+ )
133
+ ) {
134
+ throw new Error('[vxrn] failed to apply the iOS embedded updates manifest patch')
135
+ }
136
+ }
81
137
  patchedBundlePhases++
82
138
  return `shellScript = ${JSON.stringify(patched)};`
83
139
  }
@@ -117,21 +173,14 @@ import ReactAppDependencyProvider
117
173
  class SceneDelegate: UIResponder, UIWindowSceneDelegate {
118
174
  var window: UIWindow?
119
175
 
120
- var reactNativeDelegate: ReactNativeDelegate?
121
- var reactNativeFactory: RCTReactNativeFactory?
122
-
123
176
  func scene(
124
177
  _ scene: UIScene,
125
178
  willConnectTo session: UISceneSession,
126
179
  options connectionOptions: UIScene.ConnectionOptions
127
180
  ) {
128
181
  guard let windowScene = scene as? UIWindowScene else { return }
129
- let delegate = ReactNativeDelegate()
130
- let factory = RCTReactNativeFactory(delegate: delegate)
131
- delegate.dependencyProvider = RCTAppDependencyProvider()
132
-
133
- reactNativeDelegate = delegate
134
- reactNativeFactory = factory
182
+ guard let appDelegate = UIApplication.shared.delegate as? AppDelegate,
183
+ let factory = appDelegate.reactNativeFactory else { return }
135
184
 
136
185
  let window = UIWindow(windowScene: windowScene)
137
186
  self.window = window
@@ -213,7 +262,12 @@ ${anchor}`
213
262
  )
214
263
  }
215
264
 
216
- function patchIosAppDelegateSceneLifecycle(rendered: string, appName: string): string {
265
+ function patchIosAppDelegateSceneLifecycle(
266
+ rendered: string,
267
+ appName: string,
268
+ backgroundTasks: boolean,
269
+ appIntents: boolean
270
+ ): string {
217
271
  const anchor = `@main
218
272
  class AppDelegate: UIResponder, UIApplicationDelegate {
219
273
  var window: UIWindow?
@@ -252,38 +306,60 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
252
306
  anchor,
253
307
  `@main
254
308
  class AppDelegate: UIResponder, UIApplicationDelegate {
309
+ var reactNativeDelegate: ReactNativeDelegate?
310
+ var reactNativeFactory: RCTReactNativeFactory?
311
+
255
312
  func application(
256
313
  _ application: UIApplication,
257
314
  didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
258
315
  ) -> Bool {
259
- true
316
+ let delegate = ReactNativeDelegate()
317
+ let factory = RCTReactNativeFactory(delegate: delegate)
318
+ delegate.dependencyProvider = RCTAppDependencyProvider()
319
+ reactNativeDelegate = delegate
320
+ reactNativeFactory = factory
321
+ ${backgroundTasks ? ` OneBackgroundTasksRegisterHost(factory, launchOptions)
322
+ ` : ''}${appIntents ? ` OneAppIntentsRegisterHost(factory, launchOptions)
323
+ ` : ''} return true
260
324
  }
325
+ }`
326
+ )
327
+ }
261
328
 
262
- // remote push answers land here; the notifications module observes the
263
- // forward. inert unless something calls registerForRemoteNotifications.
264
- func application(
265
- _ application: UIApplication,
266
- didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
267
- ) {
268
- NotificationCenter.default.post(
269
- name: NSNotification.Name("OneNativePushTokenDidRegister"),
270
- object: nil,
271
- userInfo: ["deviceToken": deviceToken]
272
- )
273
- }
329
+ const ONE_BRIDGING_HEADER_NAME = 'One-Bridging-Header.h'
274
330
 
275
- func application(
276
- _ application: UIApplication,
277
- didFailToRegisterForRemoteNotificationsWithError error: Error
278
- ) {
279
- NotificationCenter.default.post(
280
- name: NSNotification.Name("OneNativePushTokenDidFail"),
281
- object: nil,
282
- userInfo: ["error": error.localizedDescription]
331
+ function renderOneBridgingHeader(app: NativeAppManifest): string {
332
+ const imports = [nativeProjectPatches.ONE_LAUNCH_SCREEN.bridgingHeaderImport]
333
+ if (app.updates !== undefined) imports.push(updatesHost.bridgingHeaderImport)
334
+ if (app.ios?.backgroundTasks !== undefined) imports.push('#import "OneBackgroundTasksBridge.h"')
335
+ if (app.ios?.appIntents !== undefined) imports.push('#import "OneAppIntentsBridge.h"')
336
+ return `// generated by one prebuild: exposes One's c entry points to AppDelegate
337
+ // without importing the One module.
338
+ ${imports.join('\n')}
339
+ `
340
+ }
341
+
342
+ function generateOneBridgingHeader(dest: string, app: NativeAppManifest): void {
343
+ FSExtra.writeFileSync(
344
+ path.join(dest, app.name, ONE_BRIDGING_HEADER_NAME),
345
+ renderOneBridgingHeader(app)
346
+ )
347
+ }
348
+
349
+ function patchIosPbxprojOneBridgingHeader(project: string, appName: string): string {
350
+ // both app-target configurations point at the generated bridging header.
351
+ // the anchor is the app plist, which only those two settings own.
352
+ const anchor = `INFOPLIST_FILE = ${appName}/Info.plist;`
353
+ const setting = `SWIFT_OBJC_BRIDGING_HEADER = "${appName}/${ONE_BRIDGING_HEADER_NAME}";`
354
+ const occurrences = project.split(anchor).length - 1
355
+ if (occurrences !== 2) {
356
+ throw new Error(
357
+ '[vxrn] prebuild template project.pbxproj changed shape: cannot point the app target at the One bridging header'
283
358
  )
284
359
  }
285
- }`
286
- )
360
+ return project
361
+ .split(anchor)
362
+ .join(`${anchor}\n\t\t\t\t${setting}`)
287
363
  }
288
364
 
289
365
  function patchIosPbxprojSceneDelegate(rendered: string, appName: string): string {
@@ -318,8 +394,184 @@ function patchIosPbxprojSceneDelegate(rendered: string, appName: string): string
318
394
  return patched
319
395
  }
320
396
 
321
- function patchIosPbxprojPushEntitlements(rendered: string, appName: string): string {
322
- // the aps-environment entitlement only when the app opts into push. the
397
+ function patchIosPbxprojAppIntents(rendered: string, appName: string): string {
398
+ const name = 'OneAppIntents.swift'
399
+ const fileRef = pbxprojId('one-app-intents-file')
400
+ const buildFile = pbxprojId('one-app-intents-build')
401
+ const edits: Array<[string, string]> = [
402
+ [
403
+ '/* AppDelegate.swift in Sources */ = {isa = PBXBuildFile;',
404
+ `\t\t${buildFile} /* ${name} in Sources */ = {isa = PBXBuildFile; fileRef = ${fileRef} /* ${name} */; };`,
405
+ ],
406
+ [
407
+ '/* AppDelegate.swift */ = {isa = PBXFileReference;',
408
+ `\t\t${fileRef} /* ${name} */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ${name}; path = ${appName}/${name}; sourceTree = "<group>"; };`,
409
+ ],
410
+ ['/* AppDelegate.swift */,', `\t\t\t\t${fileRef} /* ${name} */,`],
411
+ ['/* AppDelegate.swift in Sources */,', `\t\t\t\t${buildFile} /* ${name} in Sources */,`],
412
+ ]
413
+ let patched = rendered
414
+ for (const [anchor, insertion] of edits) {
415
+ const next = insertAfterLine(patched, anchor, insertion)
416
+ if (next === patched) {
417
+ throw new Error(`[vxrn] prebuild template project.pbxproj lost its AppDelegate.swift anchor (${anchor})`)
418
+ }
419
+ patched = next
420
+ }
421
+ return patched
422
+ }
423
+
424
+ function swiftLiteral(value: string): string {
425
+ return JSON.stringify(value).replace(/\\u([0-9a-fA-F]{4})/g, (_match, hex: string) => `\\u{${hex}}`)
426
+ }
427
+
428
+ function swiftShortcutPhrase(value: string): string {
429
+ return `"${value.split('{app}').map((part) => swiftLiteral(part).slice(1, -1)).join('\\(.applicationName)')}"`
430
+ }
431
+
432
+ export function renderIosAppIntentsSwift(actions: NonNullable<NonNullable<NativeAppManifest['ios']>['appIntents']>['actions']): string {
433
+ const types = actions.map((action, index) => {
434
+ const summaryTitle = swiftLiteral(action.title).slice(1, -1)
435
+ const text = action.textParameterTitle === undefined ? '' : `
436
+ @Parameter(title: ${swiftLiteral(action.textParameterTitle)}) var text: String
437
+
438
+ static var parameterSummary: some ParameterSummary {
439
+ Summary("${summaryTitle} \\(\\.$text)")
440
+ }
441
+ `
442
+ return `struct OneAppIntent${index}: AppIntent {
443
+ static let title: LocalizedStringResource = ${swiftLiteral(action.title)}
444
+ ${text}
445
+ func perform() async throws -> some IntentResult & ReturnsValue<String> {
446
+ let result = try await oneAppIntentsRun(${swiftLiteral(action.id)}, ${text ? 'text' : 'nil'})
447
+ return .result(value: result)
448
+ }
449
+ }`
450
+ })
451
+ const shortcuts = actions.map((action, index) => ` AppShortcut(
452
+ intent: OneAppIntent${index}(),
453
+ phrases: [${swiftShortcutPhrase(action.shortcutPhrase)}],
454
+ shortTitle: ${swiftLiteral(action.title)},
455
+ systemImageName: "bolt.fill"
456
+ )`).join('\n')
457
+ return `import AppIntents
458
+ import Foundation
459
+
460
+ private func oneAppIntentsRun(_ identifier: String, _ text: String?) async throws -> String {
461
+ try await withCheckedThrowingContinuation { continuation in
462
+ OneAppIntentsPerform(identifier, text) { result, error in
463
+ if let error { continuation.resume(throwing: error) }
464
+ else if let result { continuation.resume(returning: result) }
465
+ else { continuation.resume(throwing: NSError(domain: "OneAppIntents", code: 3)) }
466
+ }
467
+ }
468
+ }
469
+
470
+ ${types.join('\n\n')}
471
+
472
+ struct OneAppShortcuts: AppShortcutsProvider {
473
+ static var appShortcuts: [AppShortcut] {
474
+ ${shortcuts}
475
+ }
476
+ }
477
+ `
478
+ }
479
+
480
+ function fontFileName(font: string): string {
481
+ return path.basename(font)
482
+ }
483
+
484
+ function pbxprojId(seed: string): string {
485
+ return createHash('sha1').update(seed).digest('hex').slice(0, 24).toUpperCase()
486
+ }
487
+
488
+ const GOOGLE_SERVICES_PLIST = 'GoogleService-Info.plist'
489
+ const GOOGLE_SERVICES_VERSION = '4.4.4'
490
+
491
+ // the files prebuild bundles next to the ios app sources: native.app fonts and
492
+ // the firebase plist.
493
+ function iosResourceFiles(app: NativeAppManifest): string[] {
494
+ return [
495
+ ...(app.fonts ?? []).map(fontFileName),
496
+ ...(app.ios?.googleServicesFile ? [GOOGLE_SERVICES_PLIST] : []),
497
+ ]
498
+ }
499
+
500
+ // each bundled file becomes an app-group file in the Resources phase, the way
501
+ // expo's font and google services plugins add theirs.
502
+ function patchIosPbxprojResources(rendered: string, appName: string, names: string[]): string {
503
+ let patched = rendered
504
+ for (const name of names) {
505
+ const fileRef = pbxprojId(`one-resource-file:${name}`)
506
+ const buildFile = pbxprojId(`one-resource-build:${name}`)
507
+ const fileType = name.endsWith('.plist') ? 'text.plist.xml' : 'file'
508
+ const edits: Array<[string, string]> = [
509
+ [
510
+ '/* Images.xcassets in Resources */ = {isa = PBXBuildFile;',
511
+ `\t\t${buildFile} /* ${name} in Resources */ = {isa = PBXBuildFile; fileRef = ${fileRef} /* ${name} */; };`,
512
+ ],
513
+ [
514
+ '/* Images.xcassets */ = {isa = PBXFileReference;',
515
+ `\t\t${fileRef} /* ${name} */ = {isa = PBXFileReference; lastKnownFileType = ${fileType}; name = ${name}; path = ${appName}/${name}; sourceTree = "<group>"; };`,
516
+ ],
517
+ ['/* Images.xcassets */,', `\t\t\t\t${fileRef} /* ${name} */,`],
518
+ ['/* Images.xcassets in Resources */,', `\t\t\t\t${buildFile} /* ${name} in Resources */,`],
519
+ ]
520
+ for (const [anchor, insertion] of edits) {
521
+ const next = insertAfterLine(patched, anchor, insertion)
522
+ if (next === patched) {
523
+ throw new Error(
524
+ `[vxrn] prebuild template project.pbxproj lost its Images.xcassets anchor (${anchor})`
525
+ )
526
+ }
527
+ patched = next
528
+ }
529
+ }
530
+ return patched
531
+ }
532
+
533
+ // ios copies fonts and the firebase plist next to the app sources; android
534
+ // loads fonts from assets/fonts and the gradle plugin reads
535
+ // app/google-services.json.
536
+ function copyAppResources({
537
+ root,
538
+ dest,
539
+ platform,
540
+ app,
541
+ }: {
542
+ root: string
543
+ dest: string
544
+ platform: 'ios' | 'android'
545
+ app: NativeAppManifest
546
+ }): void {
547
+ const copies: Array<[string, string]> = (app.fonts ?? []).map((font) => [
548
+ font,
549
+ platform === 'ios'
550
+ ? path.join(dest, app.name, fontFileName(font))
551
+ : path.join(dest, 'app', 'src', 'main', 'assets', 'fonts', fontFileName(font)),
552
+ ])
553
+ const googleServices =
554
+ platform === 'ios' ? app.ios?.googleServicesFile : app.android?.googleServicesFile
555
+ if (googleServices) {
556
+ copies.push([
557
+ googleServices,
558
+ platform === 'ios'
559
+ ? path.join(dest, app.name, GOOGLE_SERVICES_PLIST)
560
+ : path.join(dest, 'app', 'google-services.json'),
561
+ ])
562
+ }
563
+ for (const [file, target] of copies) {
564
+ const source = path.resolve(root, file)
565
+ if (!FSExtra.existsSync(source)) {
566
+ throw new Error(`[vxrn] native.app names ${file}, which does not exist`)
567
+ }
568
+ FSExtra.mkdirSync(path.dirname(target), { recursive: true })
569
+ FSExtra.copyFileSync(source, target)
570
+ }
571
+ }
572
+
573
+ function patchIosPbxprojAppEntitlements(rendered: string, appName: string): string {
574
+ // the app entitlement file only when the app has an entitlement. the
323
575
  // community template ships no entitlements file, so this adds the file
324
576
  // reference plus the CODE_SIGN_ENTITLEMENTS setting on the app target.
325
577
  const edits: Array<[string, string]> = [
@@ -355,18 +607,117 @@ function patchIosPbxprojPushEntitlements(rendered: string, appName: string): str
355
607
  )
356
608
  }
357
609
 
358
- function renderPushEntitlements(): string {
610
+ function renderPlistValue(value: PlistValue, indent: string): string {
611
+ if (typeof value === 'string') return `${indent}<string>${escapeXml(value)}</string>`
612
+ if (typeof value === 'boolean') return `${indent}<${value}/>`
613
+ if (typeof value === 'number') {
614
+ return Number.isInteger(value)
615
+ ? `${indent}<integer>${value}</integer>`
616
+ : `${indent}<real>${value}</real>`
617
+ }
618
+ if (Array.isArray(value)) {
619
+ return `${indent}<array>\n${value.map((item) => renderPlistValue(item, `${indent}\t`)).join('\n')}\n${indent}</array>`
620
+ }
621
+ return `${indent}<dict>\n${renderPlistEntries(value, `${indent}\t`)}\n${indent}</dict>`
622
+ }
623
+
624
+ function renderPlistEntries(entries: Record<string, PlistValue>, indent: string): string {
625
+ return Object.entries(entries)
626
+ .map(([key, value]) => `${indent}<key>${escapeXml(key)}</key>\n${renderPlistValue(value, indent)}`)
627
+ .join('\n')
628
+ }
629
+
630
+ // every app-target entitlement in one file: push, the widget app group,
631
+ // associated domains, sign in with apple, then the app's own keys. a key the
632
+ // manifest already models cannot be set twice through ios.entitlements.
633
+ function appEntitlements(app: NativeAppManifest): Record<string, PlistValue> {
634
+ const modeled: Record<string, PlistValue> = {}
635
+ if (app.notifications?.push === true || app.ios?.widgets?.pushNotifications) {
636
+ modeled['aps-environment'] =
637
+ app.notifications?.apsEnvironment ?? notificationsHost.apsEnvironment
638
+ }
639
+ if (app.ios?.widgets) {
640
+ modeled['com.apple.security.application-groups'] = [app.ios.widgets.appGroup]
641
+ }
642
+ if (app.ios?.associatedDomains?.length) {
643
+ modeled['com.apple.developer.associated-domains'] = app.ios.associatedDomains
644
+ }
645
+ if (app.ios?.usesAppleSignIn) {
646
+ modeled['com.apple.developer.applesignin'] = ['Default']
647
+ }
648
+ for (const key of Object.keys(app.ios?.entitlements ?? {})) {
649
+ if (key in modeled) {
650
+ throw new Error(
651
+ `[vxrn] native.app.ios.entitlements sets ${key}, which native.app already writes: use its field`
652
+ )
653
+ }
654
+ }
655
+ return { ...modeled, ...app.ios?.entitlements }
656
+ }
657
+
658
+ function renderEntitlements(entries: Record<string, PlistValue>): string {
359
659
  return `<?xml version="1.0" encoding="UTF-8"?>
360
660
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
361
661
  <plist version="1.0">
362
662
  <dict>
363
- \t<key>aps-environment</key>
364
- \t<string>development</string>
663
+ ${renderPlistEntries(entries, '\t')}
365
664
  </dict>
366
665
  </plist>
367
666
  `
368
667
  }
369
668
 
669
+ function patchAndroidMainApplicationUpdatesHost(rendered: string): string {
670
+ // One's factory takes the same named arguments, so swapping the import
671
+ // repoints the call site at the delegate that re-asks the launcher.
672
+ const anchor = 'import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost'
673
+ if (!rendered.includes(anchor)) {
674
+ throw new Error(
675
+ '[vxrn] prebuild template MainApplication.kt changed shape: cannot point the ReactHost at One.Updates'
676
+ )
677
+ }
678
+ return rendered.replace(
679
+ anchor,
680
+ 'import com.margelo.nitro.one.OneUpdatesReactHost.getDefaultReactHost'
681
+ )
682
+ }
683
+
684
+ function escapeGradleString(value: string): string {
685
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
686
+ }
687
+
688
+ function patchAndroidBuildGradleUpdatesManifest(
689
+ rendered: string,
690
+ runtimeVersion: string
691
+ ): string {
692
+ // every bundle task writes the embedded manifest into its own assets
693
+ // output beside index.android.bundle, with an id and timestamp generated
694
+ // at build time. the react block above was just replaced, so its
695
+ // autolink call is the anchor.
696
+ const anchor = ' autolinkLibrariesWithApp()\n}'
697
+ if (!rendered.includes(anchor)) {
698
+ throw new Error(
699
+ '[vxrn] cannot stamp the embedded updates manifest: expected the replaced react block in app/build.gradle'
700
+ )
701
+ }
702
+ const snippet = `${anchor}
703
+
704
+ // [vxrn/one] the embedded update manifest lands beside the release bundle
705
+ tasks.matching { it.name.startsWith("createBundle") && it.name.endsWith("JsAndAssets") }.configureEach { bundleTask ->
706
+ // capture the output directory provider at configuration time, so the
707
+ // action below holds no task reference at execution time.
708
+ def bundleDir = bundleTask.jsBundleDir
709
+ bundleTask.doLast {
710
+ def manifestFile = new File(bundleDir.get().asFile, "one-updates-embedded.json")
711
+ manifestFile.text = groovy.json.JsonOutput.toJson([
712
+ id: java.util.UUID.randomUUID().toString(),
713
+ createdAt: java.time.Instant.now().toString(),
714
+ runtimeVersion: "${escapeGradleString(runtimeVersion)}",
715
+ ])
716
+ }
717
+ }`
718
+ return rendered.replace(anchor, snippet)
719
+ }
720
+
370
721
  function patchIosPbxprojWidgets(project: string, app: NativeAppManifest): string {
371
722
  const name = app.name
372
723
  const bundleId = app.ios!.bundleId
@@ -528,16 +879,25 @@ function generateSceneDelegate(args: {
528
879
  path.join(dest, app.name, 'SceneDelegate.swift'),
529
880
  renderSceneDelegateSwift(app.name)
530
881
  )
531
- // widgets use the same app entitlement file for the app group and apns.
532
- // without widgets, push gets its own entitlement file.
533
- if (app.notifications?.push === true && !app.ios?.widgets) {
882
+ // widgets write the app entitlements into their shared file; without
883
+ // widgets the app gets its own file when it has any entitlement.
884
+ const entitlements = appEntitlements(app)
885
+ if (Object.keys(entitlements).length && !app.ios?.widgets) {
534
886
  FSExtra.writeFileSync(
535
887
  path.join(dest, app.name, `${app.name}.entitlements`),
536
- renderPushEntitlements()
888
+ renderEntitlements(entitlements)
537
889
  )
538
890
  }
539
891
  }
540
892
 
893
+ function generateIosAppIntents(dest: string, platform: 'ios' | 'android', app: NativeAppManifest): void {
894
+ if (platform !== 'ios' || !app.ios?.appIntents) return
895
+ FSExtra.writeFileSync(
896
+ path.join(dest, app.name, 'OneAppIntents.swift'),
897
+ renderIosAppIntentsSwift(app.ios.appIntents.actions)
898
+ )
899
+ }
900
+
541
901
  function generateIosWidgets(dest: string, app: NativeAppManifest): void {
542
902
  const widgets = app.ios?.widgets
543
903
  if (!widgets) return
@@ -550,12 +910,7 @@ function generateIosWidgets(dest: string, app: NativeAppManifest): void {
550
910
  `
551
911
  FSExtra.writeFileSync(
552
912
  path.join(appDir, 'OneAppWidgets.entitlements'),
553
- widgets.pushNotifications || app.notifications?.push
554
- ? entitlements.replace(
555
- '</dict></plist>',
556
- '<key>aps-environment</key><string>development</string></dict></plist>'
557
- )
558
- : entitlements
913
+ renderEntitlements(appEntitlements(app))
559
914
  )
560
915
  FSExtra.writeFileSync(path.join(extensionDir, 'OneWidgets.entitlements'), entitlements)
561
916
  FSExtra.writeFileSync(
@@ -1085,6 +1440,14 @@ export interface RenderedPrebuildFile {
1085
1440
  content: string | null
1086
1441
  }
1087
1442
 
1443
+ const IPAD_APP_ICON_SIZES: Array<[string, string[]]> = [
1444
+ ['20x20', ['1x', '2x']],
1445
+ ['29x29', ['1x', '2x']],
1446
+ ['40x40', ['1x', '2x']],
1447
+ ['76x76', ['1x', '2x']],
1448
+ ['83.5x83.5', ['2x']],
1449
+ ]
1450
+
1088
1451
  async function generateAppIcons(args: {
1089
1452
  root: string
1090
1453
  dest: string
@@ -1094,38 +1457,66 @@ async function generateAppIcons(args: {
1094
1457
  const { root, dest, platform, app } = args
1095
1458
  if (!app.icon) return
1096
1459
 
1460
+ if (platform === 'ios') {
1461
+ const assetsDir = path.join(dest, app.name, 'Images.xcassets')
1462
+ const template: {
1463
+ images: Array<{ idiom: string; scale: string; size: string; filename?: string }>
1464
+ info: { author: string; version: number }
1465
+ } = JSON.parse(FSExtra.readFileSync(path.join(assetsDir, 'AppIcon.appiconset', 'Contents.json'), 'utf8'))
1466
+ const icons: Array<[string, { source: string; backgroundColor: string }]> = [
1467
+ ['AppIcon', app.icon],
1468
+ ...Object.entries(app.ios?.alternateIcons ?? {}),
1469
+ ]
1470
+ for (const [name, icon] of icons) {
1471
+ const source = path.resolve(root, icon.source)
1472
+ if (!FSExtra.existsSync(source)) {
1473
+ throw new Error(`[vxrn] native.app icon source does not exist: ${source}`)
1474
+ }
1475
+ const metadata = await sharp(source).metadata()
1476
+ if (metadata.width !== metadata.height || metadata.width === undefined || metadata.width < 1024) {
1477
+ throw new Error(`[vxrn] native.app icon source must be a square image at least 1024px wide: ${source}`)
1478
+ }
1479
+ const iconDir = path.join(assetsDir, `${name}.appiconset`)
1480
+ FSExtra.mkdirSync(iconDir, { recursive: true })
1481
+ const contents = structuredClone(template)
1482
+ // the template is iPhone-only. App Store upload rejects a tablet build
1483
+ // without the iPad sizes (ITMS-90023, 152 and 167 pixels among them).
1484
+ if (app.ios?.tablet) {
1485
+ for (const [size, scales] of IPAD_APP_ICON_SIZES) {
1486
+ for (const scale of scales) contents.images.push({ idiom: 'ipad', scale, size })
1487
+ }
1488
+ }
1489
+ for (const image of contents.images) {
1490
+ const points = Number.parseFloat(image.size.split('x')[0])
1491
+ const scale = Number.parseInt(image.scale, 10)
1492
+ const pixels = points * scale
1493
+ const filename = image.idiom === 'ios-marketing'
1494
+ ? 'icon-1024.png' : `icon-${points}@${image.scale}.png`
1495
+ image.filename = filename
1496
+ await sharp(source)
1497
+ .rotate()
1498
+ .resize(pixels, pixels, { fit: 'cover' })
1499
+ .flatten({ background: icon.backgroundColor })
1500
+ .png()
1501
+ .toFile(path.join(iconDir, filename))
1502
+ }
1503
+ FSExtra.writeFileSync(path.join(iconDir, 'Contents.json'), `${JSON.stringify(contents, null, 2)}\n`)
1504
+ }
1505
+ return
1506
+ }
1507
+
1097
1508
  const source = path.resolve(root, app.icon.source)
1098
1509
  if (!FSExtra.existsSync(source)) {
1099
1510
  throw new Error(`[vxrn] native.app.icon source does not exist: ${source}`)
1100
1511
  }
1101
1512
  const metadata = await sharp(source).metadata()
1102
- if (
1103
- metadata.width === undefined ||
1104
- metadata.height === undefined ||
1105
- metadata.width !== metadata.height ||
1106
- metadata.width < 1024
1107
- ) {
1108
- throw new Error(
1109
- '[vxrn] native.app.icon source must be a square image at least 1024px wide'
1110
- )
1513
+ if (metadata.width !== metadata.height || metadata.width === undefined || metadata.width < 1024) {
1514
+ throw new Error('[vxrn] native.app.icon source must be a square image at least 1024px wide')
1111
1515
  }
1112
-
1113
- if (platform === 'ios') {
1114
- const iconDir = path.join(dest, app.name, 'Images.xcassets', 'AppIcon.appiconset')
1115
- const contentsPath = path.join(iconDir, 'Contents.json')
1116
- const contents: {
1117
- images: Array<{ idiom: string; scale: string; size: string; filename?: string }>
1118
- info: { author: string; version: number }
1119
- } = JSON.parse(FSExtra.readFileSync(contentsPath, 'utf8'))
1120
- for (const image of contents.images) {
1121
- const points = Number.parseFloat(image.size.split('x')[0])
1122
- const scale = Number.parseInt(image.scale, 10)
1123
- const pixels = points * scale
1124
- const filename =
1125
- image.idiom === 'ios-marketing'
1126
- ? 'icon-1024.png'
1127
- : `icon-${points}@${image.scale}.png`
1128
- image.filename = filename
1516
+ for (const [density, multiplier] of Object.entries(ANDROID_DENSITIES)) {
1517
+ const pixels = 48 * multiplier
1518
+ const iconDir = path.join(dest, 'app', 'src', 'main', 'res', `mipmap-${density}`)
1519
+ for (const filename of ['ic_launcher.png', 'ic_launcher_round.png']) {
1129
1520
  await sharp(source)
1130
1521
  .rotate()
1131
1522
  .resize(pixels, pixels, { fit: 'cover' })
@@ -1133,22 +1524,116 @@ async function generateAppIcons(args: {
1133
1524
  .png()
1134
1525
  .toFile(path.join(iconDir, filename))
1135
1526
  }
1136
- FSExtra.writeFileSync(contentsPath, `${JSON.stringify(contents, null, 2)}\n`)
1137
- return
1138
1527
  }
1528
+ }
1139
1529
 
1140
- for (const [density, multiplier] of Object.entries(ANDROID_DENSITIES)) {
1141
- const pixels = 48 * multiplier
1142
- const iconDir = path.join(dest, 'app', 'src', 'main', 'res', `mipmap-${density}`)
1143
- for (const filename of ['ic_launcher.png', 'ic_launcher_round.png']) {
1530
+ // the android 8+ launcher icon: foreground, background and monochrome
1531
+ // layers at 108dp per density, wired by mipmap-anydpi-v26. a background color
1532
+ // lives in its own resource file, as android studio's image asset tool writes
1533
+ // it, so the splash's colors.xml never collides with it.
1534
+ async function generateAdaptiveIcon(args: {
1535
+ root: string
1536
+ dest: string
1537
+ app: NativeAppManifest
1538
+ }): Promise<void> {
1539
+ const { root, dest, app } = args
1540
+ const adaptiveIcon = app.android?.adaptiveIcon
1541
+ if (!adaptiveIcon) return
1542
+
1543
+ const res = path.join(dest, 'app', 'src', 'main', 'res')
1544
+ const layers = {
1545
+ foreground: adaptiveIcon.foreground,
1546
+ background: adaptiveIcon.background,
1547
+ monochrome: adaptiveIcon.monochrome,
1548
+ }
1549
+ for (const [layer, file] of Object.entries(layers)) {
1550
+ if (!file) continue
1551
+ const source = path.resolve(root, file)
1552
+ if (!FSExtra.existsSync(source)) {
1553
+ throw new Error(`[vxrn] native.app.android.adaptiveIcon.${layer} does not exist: ${source}`)
1554
+ }
1555
+ for (const [density, multiplier] of Object.entries(ANDROID_DENSITIES)) {
1556
+ const pixels = 108 * multiplier
1144
1557
  await sharp(source)
1145
1558
  .rotate()
1146
1559
  .resize(pixels, pixels, { fit: 'cover' })
1147
- .flatten({ background: app.icon.backgroundColor })
1148
1560
  .png()
1149
- .toFile(path.join(iconDir, filename))
1561
+ .toFile(path.join(res, `mipmap-${density}`, `ic_launcher_${layer}.png`))
1562
+ }
1563
+ }
1564
+ if (!adaptiveIcon.background) {
1565
+ FSExtra.writeFileSync(
1566
+ path.join(res, 'values', 'ic_launcher_background.xml'),
1567
+ `<?xml version="1.0" encoding="utf-8"?>
1568
+ <resources>
1569
+ <color name="ic_launcher_background">${adaptiveIcon.backgroundColor ?? '#FFFFFF'}</color>
1570
+ </resources>
1571
+ `
1572
+ )
1573
+ }
1574
+ const adaptiveXml = `<?xml version="1.0" encoding="utf-8"?>
1575
+ <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
1576
+ <background android:drawable="${adaptiveIcon.background ? '@mipmap/ic_launcher_background' : '@color/ic_launcher_background'}"/>
1577
+ <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
1578
+ ${adaptiveIcon.monochrome ? ' <monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>\n' : ''}</adaptive-icon>
1579
+ `
1580
+ const anydpi = path.join(res, 'mipmap-anydpi-v26')
1581
+ FSExtra.mkdirSync(anydpi, { recursive: true })
1582
+ for (const filename of ['ic_launcher.xml', 'ic_launcher_round.xml']) {
1583
+ FSExtra.writeFileSync(path.join(anydpi, filename), adaptiveXml)
1584
+ }
1585
+ }
1586
+
1587
+ const IOS_DARK_APPEARANCE = [{ appearance: 'luminosity', value: 'dark' }]
1588
+
1589
+ function srgb(hex: string) {
1590
+ return {
1591
+ red: Number.parseInt(hex.slice(1, 3), 16) / 255,
1592
+ green: Number.parseInt(hex.slice(3, 5), 16) / 255,
1593
+ blue: Number.parseInt(hex.slice(5, 7), 16) / 255,
1594
+ }
1595
+ }
1596
+
1597
+ // an asset catalog color entry, with its dark appearance when one is given.
1598
+ function xcodeColorSet(light: string, dark: string | undefined): string {
1599
+ const entry = (hex: string) => {
1600
+ const { red, green, blue } = srgb(hex)
1601
+ return {
1602
+ color: {
1603
+ 'color-space': 'srgb',
1604
+ components: {
1605
+ red: red.toFixed(3),
1606
+ green: green.toFixed(3),
1607
+ blue: blue.toFixed(3),
1608
+ alpha: '1.000',
1609
+ },
1610
+ },
1611
+ idiom: 'universal',
1150
1612
  }
1151
1613
  }
1614
+ return `${JSON.stringify(
1615
+ {
1616
+ colors: [
1617
+ entry(light),
1618
+ ...(dark ? [{ appearances: IOS_DARK_APPEARANCE, ...entry(dark) }] : []),
1619
+ ],
1620
+ info: { author: 'xcode', version: 1 },
1621
+ },
1622
+ null,
1623
+ 2
1624
+ )}\n`
1625
+ }
1626
+
1627
+ // the AccentColor asset the Info.plist names as the app's tint.
1628
+ function generateAccentColor(args: { dest: string; app: NativeAppManifest }): void {
1629
+ const accent = args.app.ios?.accentColor
1630
+ if (!accent) return
1631
+ const colorDir = path.join(args.dest, args.app.name, 'Images.xcassets', 'AccentColor.colorset')
1632
+ FSExtra.mkdirSync(colorDir, { recursive: true })
1633
+ FSExtra.writeFileSync(
1634
+ path.join(colorDir, 'Contents.json'),
1635
+ xcodeColorSet(accent.light, accent.dark)
1636
+ )
1152
1637
  }
1153
1638
 
1154
1639
  async function generateSplashScreen(args: {
@@ -1160,42 +1645,112 @@ async function generateSplashScreen(args: {
1160
1645
  const { root, dest, platform, app } = args
1161
1646
  if (!app.splash) return
1162
1647
 
1163
- const source = path.resolve(root, app.splash.source)
1164
- if (!FSExtra.existsSync(source)) {
1165
- throw new Error(`[vxrn] native.app.splash source does not exist: ${source}`)
1166
- }
1167
- const { data: artwork, info: metadata } = await sharp(source)
1168
- .rotate()
1169
- .trim({ background: app.splash.backgroundColor })
1170
- .png()
1171
- .toBuffer({ resolveWithObject: true })
1172
- if (!metadata.width || !metadata.height) {
1173
- throw new Error('[vxrn] native.app.splash source must be an image')
1648
+ // a cover splash fills the launch screen, so its artwork keeps its margins.
1649
+ const cover = app.splash.resizeMode === 'cover'
1650
+ const prepareArtwork = async (sourcePath: string, backgroundColor: string) => {
1651
+ const source = path.resolve(root, sourcePath)
1652
+ if (!FSExtra.existsSync(source)) {
1653
+ throw new Error(`[vxrn] native.app.splash source does not exist: ${source}`)
1654
+ }
1655
+ // a vector source rasterizes at the density its largest use needs: the
1656
+ // @3x launch artwork, or the android xxxhdpi splash.
1657
+ const { width: sourceWidth, format } = await sharp(source).metadata()
1658
+ const density =
1659
+ format === 'svg' && sourceWidth && !cover
1660
+ ? Math.min(2400, Math.ceil((72 * 4 * (app.splash?.width ?? 200)) / sourceWidth))
1661
+ : undefined
1662
+ const image = sharp(source, density ? { density } : {}).rotate()
1663
+ const prepared = await (cover ? image : image.trim({ background: backgroundColor }))
1664
+ .png()
1665
+ .toBuffer({ resolveWithObject: true })
1666
+ if (!prepared.info.width || !prepared.info.height) {
1667
+ throw new Error('[vxrn] native.app.splash source must be an image')
1668
+ }
1669
+ return prepared
1174
1670
  }
1671
+ const { data: artwork, info: metadata } = await prepareArtwork(
1672
+ app.splash.source,
1673
+ app.splash.backgroundColor
1674
+ )
1675
+ const dark = app.splash.dark
1676
+ const darkArtwork = dark?.source
1677
+ ? (await prepareArtwork(dark.source, dark.backgroundColor)).data
1678
+ : undefined
1175
1679
  const artworkWidth = app.splash.width ?? 200
1176
1680
  const artworkHeight = Number(
1177
1681
  (artworkWidth * (metadata.height / metadata.width)).toFixed(3)
1178
1682
  )
1683
+ // a full-bleed background keeps its own resolution on both platforms; the
1684
+ // launch surface scales it (aspect-fill on ios, fill on android).
1685
+ const readBackground = (sourcePath: string) => {
1686
+ const source = path.resolve(root, sourcePath)
1687
+ if (!FSExtra.existsSync(source)) {
1688
+ throw new Error(`[vxrn] native.app.splash backgroundImage does not exist: ${source}`)
1689
+ }
1690
+ return sharp(source).rotate().png().toBuffer({ resolveWithObject: true })
1691
+ }
1179
1692
 
1180
1693
  if (platform === 'ios') {
1181
1694
  const appDir = path.join(dest, app.name)
1182
- const splashDir = path.join(appDir, 'Images.xcassets', 'Splash.imageset')
1183
- FSExtra.mkdirSync(splashDir, { recursive: true })
1184
- await sharp(artwork).toFile(path.join(splashDir, 'splash.png'))
1695
+ const assets = path.join(appDir, 'Images.xcassets')
1696
+ // one imageset per image, each with an optional dark appearance. contain
1697
+ // artwork is drawn at 1x, 2x and 3x of its launch width; a full-bleed
1698
+ // image keeps its own resolution.
1699
+ const writeImageSet = async (
1700
+ name: string,
1701
+ light: Buffer,
1702
+ dark: Buffer | undefined,
1703
+ pointWidth: number | undefined
1704
+ ) => {
1705
+ const dir = path.join(assets, `${name}.imageset`)
1706
+ FSExtra.mkdirSync(dir, { recursive: true })
1707
+ const scales = pointWidth === undefined ? [1] : [1, 2, 3]
1708
+ const images: Array<Record<string, unknown>> = []
1709
+ for (const [variant, suffix, appearances] of [
1710
+ [light, '', undefined],
1711
+ [dark, '-dark', IOS_DARK_APPEARANCE],
1712
+ ] as const) {
1713
+ if (!variant) continue
1714
+ for (const scale of scales) {
1715
+ const filename = `${name.toLowerCase()}${suffix}${scale === 1 ? '' : `@${scale}x`}.png`
1716
+ const output = sharp(variant)
1717
+ await (pointWidth === undefined
1718
+ ? output
1719
+ : output.resize({ width: Math.round(pointWidth * scale) })
1720
+ )
1721
+ .png()
1722
+ .toFile(path.join(dir, filename))
1723
+ images.push({
1724
+ ...(appearances ? { appearances } : {}),
1725
+ filename,
1726
+ idiom: 'universal',
1727
+ scale: `${scale}x`,
1728
+ })
1729
+ }
1730
+ }
1731
+ FSExtra.writeFileSync(
1732
+ path.join(dir, 'Contents.json'),
1733
+ `${JSON.stringify({ images, info: { author: 'xcode', version: 1 } }, null, 2)}\n`
1734
+ )
1735
+ }
1736
+ await writeImageSet('Splash', artwork, darkArtwork, cover ? undefined : artworkWidth)
1737
+ const background = app.splash.backgroundImage
1738
+ ? await readBackground(app.splash.backgroundImage)
1739
+ : undefined
1740
+ const darkBackground = dark?.backgroundImage
1741
+ ? (await readBackground(dark.backgroundImage)).data
1742
+ : undefined
1743
+ if (background) {
1744
+ await writeImageSet('SplashBackgroundImage', background.data, darkBackground, undefined)
1745
+ }
1746
+ // the launch background is a named color so a dark launch gets its own.
1747
+ const colorDir = path.join(assets, 'SplashBackground.colorset')
1748
+ FSExtra.mkdirSync(colorDir, { recursive: true })
1185
1749
  FSExtra.writeFileSync(
1186
- path.join(splashDir, 'Contents.json'),
1187
- `${JSON.stringify(
1188
- {
1189
- images: [{ filename: 'splash.png', idiom: 'universal', scale: '1x' }],
1190
- info: { author: 'xcode', version: 1 },
1191
- },
1192
- null,
1193
- 2
1194
- )}\n`
1750
+ path.join(colorDir, 'Contents.json'),
1751
+ xcodeColorSet(app.splash.backgroundColor, dark?.backgroundColor)
1195
1752
  )
1196
- const red = Number.parseInt(app.splash.backgroundColor.slice(1, 3), 16) / 255
1197
- const green = Number.parseInt(app.splash.backgroundColor.slice(3, 5), 16) / 255
1198
- const blue = Number.parseInt(app.splash.backgroundColor.slice(5, 7), 16) / 255
1753
+ const { red, green, blue } = srgb(app.splash.backgroundColor)
1199
1754
  FSExtra.writeFileSync(
1200
1755
  path.join(appDir, 'LaunchScreen.storyboard'),
1201
1756
  `<?xml version="1.0" encoding="UTF-8"?>
@@ -1212,15 +1767,35 @@ async function generateSplashScreen(args: {
1212
1767
  <viewController id="launch-controller" sceneMemberID="viewController">
1213
1768
  <view key="view" contentMode="scaleToFill" id="launch-view">
1214
1769
  <rect key="frame" x="0.0" y="0.0" width="390" height="844"/>
1215
- <subviews>
1216
- <imageView userInteractionEnabled="NO" contentMode="scaleAspectFit" image="Splash" translatesAutoresizingMaskIntoConstraints="NO" id="splash-image"/>
1770
+ <subviews>${
1771
+ background
1772
+ ? `
1773
+ <imageView userInteractionEnabled="NO" contentMode="scaleAspectFill" image="SplashBackgroundImage" translatesAutoresizingMaskIntoConstraints="NO" id="splash-background"/>`
1774
+ : ''
1775
+ }
1776
+ <imageView userInteractionEnabled="NO" contentMode="${cover ? 'scaleAspectFill' : 'scaleAspectFit'}" image="Splash" translatesAutoresizingMaskIntoConstraints="NO" id="splash-image"/>
1217
1777
  </subviews>
1218
- <color key="backgroundColor" red="${red}" green="${green}" blue="${blue}" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
1219
- <constraints>
1220
- <constraint firstItem="splash-image" firstAttribute="centerX" secondItem="launch-view" secondAttribute="centerX" id="splash-center-x"/>
1778
+ <color key="backgroundColor" name="SplashBackground"/>
1779
+ <constraints>${
1780
+ background
1781
+ ? `
1782
+ <constraint firstItem="splash-background" firstAttribute="leading" secondItem="launch-view" secondAttribute="leading" id="splash-background-leading"/>
1783
+ <constraint firstItem="splash-background" firstAttribute="trailing" secondItem="launch-view" secondAttribute="trailing" id="splash-background-trailing"/>
1784
+ <constraint firstItem="splash-background" firstAttribute="top" secondItem="launch-view" secondAttribute="top" id="splash-background-top"/>
1785
+ <constraint firstItem="splash-background" firstAttribute="bottom" secondItem="launch-view" secondAttribute="bottom" id="splash-background-bottom"/>`
1786
+ : ''
1787
+ }
1788
+ ${
1789
+ cover
1790
+ ? ` <constraint firstItem="splash-image" firstAttribute="leading" secondItem="launch-view" secondAttribute="leading" id="splash-leading"/>
1791
+ <constraint firstItem="splash-image" firstAttribute="trailing" secondItem="launch-view" secondAttribute="trailing" id="splash-trailing"/>
1792
+ <constraint firstItem="splash-image" firstAttribute="top" secondItem="launch-view" secondAttribute="top" id="splash-top"/>
1793
+ <constraint firstItem="splash-image" firstAttribute="bottom" secondItem="launch-view" secondAttribute="bottom" id="splash-bottom"/>`
1794
+ : ` <constraint firstItem="splash-image" firstAttribute="centerX" secondItem="launch-view" secondAttribute="centerX" id="splash-center-x"/>
1221
1795
  <constraint firstItem="splash-image" firstAttribute="centerY" secondItem="launch-view" secondAttribute="centerY" id="splash-center-y"/>
1222
1796
  <constraint firstItem="splash-image" firstAttribute="width" constant="${artworkWidth}" id="splash-width"/>
1223
- <constraint firstItem="splash-image" firstAttribute="height" constant="${artworkHeight}" id="splash-height"/>
1797
+ <constraint firstItem="splash-image" firstAttribute="height" constant="${artworkHeight}" id="splash-height"/>`
1798
+ }
1224
1799
  </constraints>
1225
1800
  </view>
1226
1801
  </viewController>
@@ -1229,7 +1804,15 @@ async function generateSplashScreen(args: {
1229
1804
  </scene>
1230
1805
  </scenes>
1231
1806
  <resources>
1232
- <image name="Splash" width="${metadata.width}" height="${metadata.height}"/>
1807
+ <image name="Splash" width="${cover ? metadata.width : artworkWidth}" height="${cover ? metadata.height : artworkHeight}"/>${
1808
+ background
1809
+ ? `
1810
+ <image name="SplashBackgroundImage" width="${background.info.width}" height="${background.info.height}"/>`
1811
+ : ''
1812
+ }
1813
+ <namedColor name="SplashBackground">
1814
+ <color red="${red}" green="${green}" blue="${blue}" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
1815
+ </namedColor>
1233
1816
  </resources>
1234
1817
  </document>
1235
1818
  `
@@ -1239,42 +1822,77 @@ async function generateSplashScreen(args: {
1239
1822
 
1240
1823
  const mainRes = path.join(dest, 'app', 'src', 'main', 'res')
1241
1824
  const drawable = path.join(mainRes, 'drawable')
1242
- for (const [density, multiplier] of Object.entries(ANDROID_DENSITIES)) {
1243
- const canvasSize = 288 * multiplier
1244
- const imageSize = Math.round(artworkWidth * multiplier)
1245
- const contained = await sharp(artwork)
1246
- .resize(imageSize, imageSize, {
1247
- fit: 'contain',
1248
- background: { r: 0, g: 0, b: 0, alpha: 0 },
1249
- })
1250
- .png()
1251
- .toBuffer()
1252
- const drawableDensity = path.join(mainRes, `drawable-${density}`)
1253
- FSExtra.mkdirSync(drawableDensity, { recursive: true })
1254
- await sharp({
1255
- create: {
1256
- width: canvasSize,
1257
- height: canvasSize,
1258
- channels: 4,
1259
- background: { r: 0, g: 0, b: 0, alpha: 0 },
1260
- },
1261
- })
1262
- .composite([
1263
- {
1264
- input: contained,
1265
- left: Math.round((canvasSize - imageSize) / 2),
1266
- top: Math.round((canvasSize - imageSize) / 2),
1825
+ const variants: Array<[Buffer, string]> = [[artwork, 'drawable']]
1826
+ if (darkArtwork) variants.push([darkArtwork, 'drawable-night'])
1827
+ for (const [variantArtwork, drawablePrefix] of variants) {
1828
+ for (const [density, multiplier] of Object.entries(ANDROID_DENSITIES)) {
1829
+ const canvasSize = 288 * multiplier
1830
+ const imageSize = Math.round(artworkWidth * multiplier)
1831
+ const contained = await sharp(variantArtwork)
1832
+ .resize(imageSize, imageSize, {
1833
+ fit: 'contain',
1834
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
1835
+ })
1836
+ .png()
1837
+ .toBuffer()
1838
+ const drawableDensity = path.join(mainRes, `${drawablePrefix}-${density}`)
1839
+ FSExtra.mkdirSync(drawableDensity, { recursive: true })
1840
+ await sharp({
1841
+ create: {
1842
+ width: canvasSize,
1843
+ height: canvasSize,
1844
+ channels: 4,
1845
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
1267
1846
  },
1268
- ])
1269
- .png()
1270
- .toFile(path.join(drawableDensity, 'splash.png'))
1847
+ })
1848
+ .composite([
1849
+ {
1850
+ input: contained,
1851
+ left: Math.round((canvasSize - imageSize) / 2),
1852
+ top: Math.round((canvasSize - imageSize) / 2),
1853
+ },
1854
+ ])
1855
+ .png()
1856
+ .toFile(path.join(drawableDensity, 'splash.png'))
1857
+ }
1858
+ }
1859
+ // the background paints full-bleed under the mark. one nodpi asset per
1860
+ // appearance: gravity fill stretches it over the layer bounds on every
1861
+ // screen, which the gradient art this contract carries survives exactly.
1862
+ // a night appearance without its own image falls back to the light one,
1863
+ // the way the ios imageset falls back to its universal entry.
1864
+ const background = app.splash.backgroundImage
1865
+ ? await readBackground(app.splash.backgroundImage)
1866
+ : undefined
1867
+ const darkBackground = dark?.backgroundImage
1868
+ ? (await readBackground(dark.backgroundImage)).data
1869
+ : undefined
1870
+ if (background) {
1871
+ const nodpi = path.join(mainRes, 'drawable-nodpi')
1872
+ FSExtra.mkdirSync(nodpi, { recursive: true })
1873
+ FSExtra.writeFileSync(path.join(nodpi, 'splash_background.png'), background.data)
1874
+ if (darkBackground) {
1875
+ const nightNodpi = path.join(mainRes, 'drawable-night-nodpi')
1876
+ FSExtra.mkdirSync(nightNodpi, { recursive: true })
1877
+ FSExtra.writeFileSync(
1878
+ path.join(nightNodpi, 'splash_background.png'),
1879
+ darkBackground
1880
+ )
1881
+ }
1271
1882
  }
1272
1883
  FSExtra.writeFileSync(
1273
1884
  path.join(drawable, 'launch_screen.xml'),
1274
1885
  `<?xml version="1.0" encoding="utf-8"?>
1275
1886
  <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
1276
1887
  <item android:drawable="@color/splash_background" />
1277
- <item>
1888
+ ${
1889
+ background
1890
+ ? ` <item>
1891
+ <bitmap android:gravity="fill" android:src="@drawable/splash_background" />
1892
+ </item>
1893
+ `
1894
+ : ''
1895
+ } <item>
1278
1896
  <bitmap android:gravity="center" android:src="@drawable/splash" />
1279
1897
  </item>
1280
1898
  </layer-list>
@@ -1288,6 +1906,17 @@ async function generateSplashScreen(args: {
1288
1906
  </resources>
1289
1907
  `
1290
1908
  )
1909
+ if (dark) {
1910
+ FSExtra.mkdirSync(path.join(mainRes, 'values-night'), { recursive: true })
1911
+ FSExtra.writeFileSync(
1912
+ path.join(mainRes, 'values-night', 'colors.xml'),
1913
+ `<?xml version="1.0" encoding="utf-8"?>
1914
+ <resources>
1915
+ <color name="splash_background">${dark.backgroundColor}</color>
1916
+ </resources>
1917
+ `
1918
+ )
1919
+ }
1291
1920
  const stylesPath = path.join(mainRes, 'values', 'styles.xml')
1292
1921
  const styles = FSExtra.readFileSync(stylesPath, 'utf8').replace(
1293
1922
  ' <!-- Customize your theme here. -->',
@@ -1377,32 +2006,108 @@ ${schemes.map((scheme) => `\t\t\t\t<string>${scheme}</string>`).join('\n')}
1377
2006
  `\t<key>ITSAppUsesNonExemptEncryption</key>\n\t<${app.ios.usesNonExemptEncryption ? 'true' : 'false'}/>`
1378
2007
  )
1379
2008
  }
2009
+ if (app.ios?.accentColor) {
2010
+ stamps.push('\t<key>NSAccentColorName</key>\n\t<string>AccentColor</string>')
2011
+ }
1380
2012
  if (app.ios?.fileSharing) {
1381
2013
  stamps.push(
1382
2014
  `\t<key>UIFileSharingEnabled</key>\n\t<true/>\n\t<key>LSSupportsOpeningDocumentsInPlace</key>\n\t<true/>`
1383
2015
  )
1384
2016
  }
2017
+ if (app.photoLibrary?.readWrite !== undefined) {
2018
+ stamps.push('\t<key>PHPhotoLibraryPreventAutomaticLimitedAccessAlert</key>\n\t<true/>')
2019
+ }
1385
2020
  if (app.notifications !== undefined) {
1386
2021
  // gates the UNUserNotificationCenter delegate install: apps that link
1387
- // @vxrn/native without notifications keep whatever delegate their own
2022
+ // one without notifications keep whatever delegate their own
1388
2023
  // push library sets.
1389
- stamps.push(`\t<key>OneNativeNotificationsEnabled</key>\n\t<true/>`)
2024
+ stamps.push(`\t<key>${notificationsHost.enabledInfoPlistKey}</key>\n\t<true/>`)
1390
2025
  }
1391
2026
  if (app.notifications?.push === true) {
1392
2027
  // without the aps-environment entitlement the simulator never answers
1393
2028
  // registerForRemoteNotifications, so the token getter reads this to
1394
2029
  // reject at once like android's nopush flavor.
1395
- stamps.push(`\t<key>OneNativeNotificationsPush</key>\n\t<true/>`)
2030
+ stamps.push(`\t<key>${notificationsHost.pushInfoPlistKey}</key>\n\t<true/>`)
1396
2031
  }
1397
2032
  if (app.ios?.widgets) {
1398
2033
  stamps.push('\t<key>NSSupportsLiveActivities</key>\n\t<true/>')
1399
2034
  }
1400
- if (app.pictureInPicture) {
2035
+ const refreshTasks = app.ios?.backgroundTasks?.refresh ?? []
2036
+ const processingTasks = app.ios?.backgroundTasks?.processing ?? []
2037
+ if (refreshTasks.length || processingTasks.length) {
2038
+ const plistArray = (key: string, identifiers: string[]) =>
2039
+ `\t<key>${key}</key>\n\t<array>\n${identifiers.map((identifier) => `\t\t<string>${escapeXml(identifier)}</string>`).join('\n')}\n\t</array>`
2040
+ stamps.push(plistArray('BGTaskSchedulerPermittedIdentifiers', [...refreshTasks, ...processingTasks]))
2041
+ stamps.push(plistArray('OneBackgroundRefreshTaskIdentifiers', refreshTasks))
2042
+ stamps.push(plistArray('OneBackgroundProcessingTaskIdentifiers', processingTasks))
2043
+ }
2044
+ if (app.ios?.appIntents) {
1401
2045
  stamps.push(
1402
- '\t<key>UIBackgroundModes</key>\n\t<array>\n\t\t<string>audio</string>\n\t</array>'
2046
+ `\t<key>OneAppIntentIdentifiers</key>\n\t<array>\n${app.ios.appIntents.actions.map((action) => `\t\t<string>${escapeXml(action.id)}</string>`).join('\n')}\n\t</array>`
1403
2047
  )
1404
2048
  }
1405
- if (stamps.length) {
2049
+ const backgroundModes = [
2050
+ ...(app.pictureInPicture || app.audio?.background ? ['audio'] : []),
2051
+ ...(app.location?.background ? ['location'] : []),
2052
+ ...(refreshTasks.length ? ['fetch'] : []),
2053
+ ...(processingTasks.length ? ['processing'] : []),
2054
+ ]
2055
+ if (backgroundModes.length) {
2056
+ stamps.push(
2057
+ `\t<key>UIBackgroundModes</key>\n\t<array>\n${backgroundModes.map((mode) => `\t\t<string>${mode}</string>`).join('\n')}\n\t</array>`
2058
+ )
2059
+ }
2060
+ if (app.updates !== undefined) {
2061
+ // the launcher reads both; without the url the embedded bundle
2062
+ // launches with updates disabled.
2063
+ if (app.updates.url !== undefined) {
2064
+ stamps.push(
2065
+ `\t<key>${updatesHost.urlInfoPlistKey}</key>\n\t<string>${escapeXml(app.updates.url)}</string>`
2066
+ )
2067
+ }
2068
+ stamps.push(
2069
+ `\t<key>${updatesHost.runtimeVersionInfoPlistKey}</key>\n\t<string>${escapeXml(app.updates.runtimeVersion)}</string>`
2070
+ )
2071
+ }
2072
+ if (app.fonts?.length) {
2073
+ stamps.push(
2074
+ `\t<key>UIAppFonts</key>\n\t<array>\n${app.fonts.map((font) => `\t\t<string>${escapeXml(fontFileName(font))}</string>`).join('\n')}\n\t</array>`
2075
+ )
2076
+ }
2077
+ if (app.userInterfaceStyle !== undefined) {
2078
+ stamps.push(
2079
+ `\t<key>UIUserInterfaceStyle</key>\n\t<string>${app.userInterfaceStyle === 'light' ? 'Light' : app.userInterfaceStyle === 'dark' ? 'Dark' : 'Automatic'}</string>`
2080
+ )
2081
+ }
2082
+ if (app.orientation !== undefined) {
2083
+ // expo's orientation mapping: it sets the phone list only.
2084
+ const phoneOrientations =
2085
+ app.orientation === 'portrait'
2086
+ ? PORTRAIT_ORIENTATIONS
2087
+ : app.orientation === 'landscape'
2088
+ ? LANDSCAPE_ORIENTATIONS
2089
+ : [...PORTRAIT_ORIENTATIONS, ...LANDSCAPE_ORIENTATIONS]
2090
+ const phoneList =
2091
+ /\t<key>UISupportedInterfaceOrientations<\/key>\n\t<array>\n[\s\S]*?\t<\/array>/
2092
+ if (!phoneList.test(rendered)) {
2093
+ throw new Error(
2094
+ `[vxrn] prebuild template ${relativePath} lost its UISupportedInterfaceOrientations list`
2095
+ )
2096
+ }
2097
+ rendered = rendered.replace(
2098
+ phoneList,
2099
+ `\t<key>UISupportedInterfaceOrientations</key>\n\t<array>\n${phoneOrientations.map((orientation) => `\t\t<string>${orientation}</string>`).join('\n')}\n\t</array>`
2100
+ )
2101
+ }
2102
+ for (const [key, value] of Object.entries(app.ios?.infoPlist ?? {})) {
2103
+ if (rendered.includes(`<key>${key}</key>`) || stamps.some((stamp) => stamp.includes(`<key>${key}</key>`))) {
2104
+ throw new Error(
2105
+ `[vxrn] native.app.ios.infoPlist sets ${key}, which the template or native.app already writes: use its field`
2106
+ )
2107
+ }
2108
+ stamps.push(renderPlistEntries({ [key]: value }, '\t'))
2109
+ }
2110
+ if (stamps.length) {
1406
2111
  const anchor = '\t<key>LSRequiresIPhoneOS</key>'
1407
2112
  if (!rendered.includes(anchor))
1408
2113
  throw new Error(
@@ -1414,17 +2119,64 @@ ${schemes.map((scheme) => `\t\t\t\t<string>${scheme}</string>`).join('\n')}
1414
2119
  if (
1415
2120
  platform === 'android' &&
1416
2121
  relativePath === 'app/src/main/AndroidManifest.xml' &&
1417
- app.imagePicker?.camera !== undefined
2122
+ (app.imagePicker?.camera !== undefined || app.speech !== undefined)
2123
+ ) {
2124
+ const anchor = '<uses-permission android:name="android.permission.INTERNET" />'
2125
+ if (!rendered.includes(anchor)) {
2126
+ throw new Error(
2127
+ '[vxrn] cannot stamp the camera or microphone permission: expected the INTERNET permission in app/src/main/AndroidManifest.xml'
2128
+ )
2129
+ }
2130
+ const stamps: string[] = []
2131
+ if (app.imagePicker?.camera !== undefined) {
2132
+ stamps.push(' <uses-permission android:name="android.permission.CAMERA" />')
2133
+ }
2134
+ if (app.speech !== undefined) {
2135
+ // package visibility (android 11+) hides the recognition service
2136
+ // from SpeechRecognizer.isRecognitionAvailable without the query
2137
+ stamps.push(
2138
+ ' <uses-permission android:name="android.permission.RECORD_AUDIO" />',
2139
+ ' <queries>\n <intent>\n <action android:name="android.speech.RecognitionService" />\n </intent>\n </queries>'
2140
+ )
2141
+ }
2142
+ rendered = rendered.replace(anchor, `${anchor}\n${stamps.join('\n')}`)
2143
+ }
2144
+ if (
2145
+ platform === 'android' &&
2146
+ relativePath === 'app/src/main/AndroidManifest.xml' &&
2147
+ app.location !== undefined
1418
2148
  ) {
1419
2149
  const anchor = '<uses-permission android:name="android.permission.INTERNET" />'
1420
2150
  if (!rendered.includes(anchor)) {
1421
2151
  throw new Error(
1422
- '[vxrn] cannot stamp the camera permission: expected the INTERNET permission in app/src/main/AndroidManifest.xml'
2152
+ '[vxrn] cannot stamp location permissions: expected the INTERNET permission in app/src/main/AndroidManifest.xml'
2153
+ )
2154
+ }
2155
+ const stamps = [
2156
+ ' <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />',
2157
+ ' <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />',
2158
+ ]
2159
+ if (app.location.background) {
2160
+ stamps.push(
2161
+ ' <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />'
2162
+ )
2163
+ }
2164
+ rendered = rendered.replace(anchor, `${anchor}\n${stamps.join('\n')}`)
2165
+ }
2166
+ if (
2167
+ platform === 'android' &&
2168
+ relativePath === 'app/src/main/AndroidManifest.xml' &&
2169
+ app.pedometer !== undefined
2170
+ ) {
2171
+ const anchor = '<uses-permission android:name="android.permission.INTERNET" />'
2172
+ if (!rendered.includes(anchor)) {
2173
+ throw new Error(
2174
+ '[vxrn] cannot stamp the pedometer permission: expected the INTERNET permission in app/src/main/AndroidManifest.xml'
1423
2175
  )
1424
2176
  }
1425
2177
  rendered = rendered.replace(
1426
2178
  anchor,
1427
- `${anchor}\n <uses-permission android:name="android.permission.CAMERA" />`
2179
+ `${anchor}\n <uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />`
1428
2180
  )
1429
2181
  }
1430
2182
  if (
@@ -1440,9 +2192,14 @@ ${schemes.map((scheme) => `\t\t\t\t<string>${scheme}</string>`).join('\n')}
1440
2192
  }
1441
2193
  rendered = rendered.replace(
1442
2194
  anchor,
1443
- `${anchor}\n <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />\n <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />`
2195
+ [
2196
+ anchor,
2197
+ ...notificationsHost.androidPermissions.map(
2198
+ (permission) => ` <uses-permission android:name="${permission}" />`
2199
+ ),
2200
+ ].join('\n')
1444
2201
  )
1445
- if (!rendered.includes('android.permission.POST_NOTIFICATIONS')) {
2202
+ if (!rendered.includes(notificationsHost.androidPermissions[0])) {
1446
2203
  throw new Error(
1447
2204
  '[vxrn] failed to stamp notification permissions into app manifest'
1448
2205
  )
@@ -1452,9 +2209,9 @@ ${schemes.map((scheme) => `\t\t\t\t<string>${scheme}</string>`).join('\n')}
1452
2209
  // arrive as explicit intents; only boot needs the filter.
1453
2210
  rendered = rendered.replace(
1454
2211
  ' </activity>\n </application>',
1455
- ' </activity>\n <receiver android:name="dev.onejs.onenative.OneNativeNotificationsReceiver" android:exported="false">\n <intent-filter>\n <action android:name="android.intent.action.BOOT_COMPLETED" />\n </intent-filter>\n </receiver>\n </application>'
2212
+ ` </activity>\n <receiver android:name="${notificationsHost.receiver}" android:exported="false">\n <intent-filter>\n <action android:name="${notificationsHost.receiverAction}" />\n </intent-filter>\n </receiver>\n </application>`
1456
2213
  )
1457
- if (!rendered.includes('OneNativeNotificationsReceiver')) {
2214
+ if (!rendered.includes(notificationsHost.receiver)) {
1458
2215
  throw new Error(
1459
2216
  '[vxrn] failed to stamp the notification receiver into app manifest'
1460
2217
  )
@@ -1468,7 +2225,7 @@ ${schemes.map((scheme) => `\t\t\t\t<string>${scheme}</string>`).join('\n')}
1468
2225
  // the fcm refresh service lives in the app manifest, never the
1469
2226
  // library one, so apps without push never start it. push implies the
1470
2227
  // notifications block above, so the receiver anchor is present.
1471
- const anchor = 'dev.onejs.onenative.OneNativeNotificationsReceiver'
2228
+ const anchor = notificationsHost.receiver
1472
2229
  if (!rendered.includes(anchor)) {
1473
2230
  throw new Error(
1474
2231
  '[vxrn] cannot stamp the push service: expected the notification receiver in app/src/main/AndroidManifest.xml'
@@ -1476,12 +2233,174 @@ ${schemes.map((scheme) => `\t\t\t\t<string>${scheme}</string>`).join('\n')}
1476
2233
  }
1477
2234
  rendered = rendered.replace(
1478
2235
  ' </receiver>\n </application>',
1479
- ' </receiver>\n <service android:name="dev.onejs.onenative.OneNativePushService" android:exported="false">\n <intent-filter>\n <action android:name="com.google.firebase.MESSAGING_EVENT" />\n </intent-filter>\n </service>\n </application>'
2236
+ ` </receiver>\n <service android:name="${notificationsHost.pushService}" android:exported="false">\n <intent-filter>\n <action android:name="${notificationsHost.pushServiceAction}" />\n </intent-filter>\n </service>\n </application>`
1480
2237
  )
1481
- if (!rendered.includes('OneNativePushService')) {
2238
+ if (!rendered.includes(notificationsHost.pushService)) {
1482
2239
  throw new Error('[vxrn] failed to stamp the push service into app manifest')
1483
2240
  }
1484
2241
  }
2242
+ // after the notification receiver and push service stamps, which own
2243
+ // the activity and receiver anchors; this block only needs </application>.
2244
+ if (
2245
+ platform === 'android' &&
2246
+ relativePath === 'app/src/main/AndroidManifest.xml' &&
2247
+ (app.audio !== undefined ||
2248
+ app.photoLibrary !== undefined ||
2249
+ app.contacts !== undefined ||
2250
+ app.calendar !== undefined ||
2251
+ app.ios?.backgroundTasks !== undefined)
2252
+ ) {
2253
+ const anchor = '<uses-permission android:name="android.permission.INTERNET" />'
2254
+ if (!rendered.includes(anchor)) {
2255
+ throw new Error(
2256
+ '[vxrn] cannot stamp media permissions: expected the INTERNET permission in app/src/main/AndroidManifest.xml'
2257
+ )
2258
+ }
2259
+ const stamps: string[] = []
2260
+ if (
2261
+ app.audio?.microphone !== undefined &&
2262
+ !rendered.includes('android.permission.RECORD_AUDIO')
2263
+ ) {
2264
+ stamps.push(' <uses-permission android:name="android.permission.RECORD_AUDIO" />')
2265
+ }
2266
+ if (app.photoLibrary?.readWrite !== undefined) {
2267
+ stamps.push(
2268
+ ' <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />',
2269
+ ' <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />',
2270
+ ' <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />'
2271
+ )
2272
+ }
2273
+ if (app.photoLibrary?.addOnly !== undefined) {
2274
+ // no install-time permission past 28; the meta-data below is the
2275
+ // declaration the Kotlin add check reads.
2276
+ stamps.push(
2277
+ ' <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />'
2278
+ )
2279
+ }
2280
+ if (app.contacts !== undefined) {
2281
+ stamps.push(
2282
+ ' <uses-permission android:name="android.permission.READ_CONTACTS" />',
2283
+ ' <uses-permission android:name="android.permission.WRITE_CONTACTS" />'
2284
+ )
2285
+ }
2286
+ if (app.calendar?.usage !== undefined) {
2287
+ stamps.push(
2288
+ ' <uses-permission android:name="android.permission.READ_CALENDAR" />',
2289
+ ' <uses-permission android:name="android.permission.WRITE_CALENDAR" />'
2290
+ )
2291
+ }
2292
+ if (app.calendar?.remindersUsage !== undefined) {
2293
+ if (!rendered.includes('android.permission.POST_NOTIFICATIONS')) {
2294
+ stamps.push(' <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />')
2295
+ }
2296
+ if (!rendered.includes('android.permission.RECEIVE_BOOT_COMPLETED')) {
2297
+ stamps.push(' <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />')
2298
+ }
2299
+ }
2300
+ if (app.audio?.background === true) {
2301
+ stamps.push(
2302
+ ' <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />',
2303
+ ' <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />'
2304
+ )
2305
+ }
2306
+ if (stamps.length) {
2307
+ rendered = rendered.replace(anchor, `${anchor}\n${stamps.join('\n')}`)
2308
+ }
2309
+ // config markers the Kotlin manifest checks read: add-only has no
2310
+ // permission past 28, and background playback gates the service.
2311
+ const appStamps: string[] = []
2312
+ if (app.calendar?.remindersUsage !== undefined) {
2313
+ appStamps.push(
2314
+ ' <meta-data android:name="one.calendar.reminders" android:value="true" />'
2315
+ )
2316
+ }
2317
+ if (app.photoLibrary?.addOnly !== undefined) {
2318
+ appStamps.push(
2319
+ ' <meta-data android:name="one.photoLibrary.addOnly" android:value="true" />'
2320
+ )
2321
+ }
2322
+ if (app.audio?.background === true) {
2323
+ appStamps.push(
2324
+ ' <meta-data android:name="one.audio.background" android:value="true" />',
2325
+ ' <service android:name="com.margelo.nitro.one.OneAudioService" android:exported="false" android:foregroundServiceType="mediaPlayback" />'
2326
+ )
2327
+ }
2328
+ // background task identifiers use the shared ios declaration on both
2329
+ // platforms, so the Android WorkManager half accepts the same
2330
+ // identifiers the iOS BGTaskScheduler half does. no new public API.
2331
+ if (app.ios?.backgroundTasks !== undefined) {
2332
+ const refreshTasks = app.ios.backgroundTasks.refresh ?? []
2333
+ const processingTasks = app.ios.backgroundTasks.processing ?? []
2334
+ if (refreshTasks.length) {
2335
+ appStamps.push(
2336
+ ` <meta-data android:name="one.backgroundTasks.refresh" android:value="${escapeXml(refreshTasks.join(','))}" />`
2337
+ )
2338
+ }
2339
+ if (processingTasks.length) {
2340
+ appStamps.push(
2341
+ ` <meta-data android:name="one.backgroundTasks.processing" android:value="${escapeXml(processingTasks.join(','))}" />`
2342
+ )
2343
+ }
2344
+ }
2345
+ if (appStamps.length) {
2346
+ // before the application close, so this composes with the
2347
+ // notification receiver stamp that owns the activity anchor.
2348
+ const appAnchor = ' </application>'
2349
+ if (!rendered.includes(appAnchor)) {
2350
+ throw new Error(
2351
+ '[vxrn] cannot stamp media components: expected </application> in app/src/main/AndroidManifest.xml'
2352
+ )
2353
+ }
2354
+ rendered = rendered.replace(
2355
+ appAnchor,
2356
+ `${appStamps.join('\n')}\n${appAnchor}`
2357
+ )
2358
+ }
2359
+ }
2360
+ if (
2361
+ platform === 'android' &&
2362
+ relativePath === 'app/src/main/java/com/helloworld/MainApplication.kt' &&
2363
+ app.updates !== undefined
2364
+ ) {
2365
+ rendered = patchAndroidMainApplicationUpdatesHost(rendered)
2366
+ }
2367
+ if (
2368
+ platform === 'android' &&
2369
+ relativePath === 'app/src/main/AndroidManifest.xml' &&
2370
+ app.updates !== undefined
2371
+ ) {
2372
+ // the launcher reads both; without the url the embedded bundle
2373
+ // launches with updates disabled.
2374
+ const anchor = ' </application>'
2375
+ if (!rendered.includes(anchor)) {
2376
+ throw new Error(
2377
+ '[vxrn] cannot stamp the updates config: expected </application> in app/src/main/AndroidManifest.xml'
2378
+ )
2379
+ }
2380
+ const stamps: string[] = []
2381
+ if (app.updates.url !== undefined) {
2382
+ stamps.push(
2383
+ ` <meta-data android:name="dev.onejs.updates.url" android:value="${escapeXml(app.updates.url)}" />`
2384
+ )
2385
+ }
2386
+ stamps.push(
2387
+ ' <meta-data android:name="dev.onejs.updates.runtimeVersion" android:value="@string/one_updates_runtime_version" />'
2388
+ )
2389
+ rendered = rendered.replace(anchor, `${stamps.join('\n')}\n${anchor}`)
2390
+ }
2391
+ if (
2392
+ platform === 'android' &&
2393
+ relativePath === 'app/src/main/res/values/strings.xml' &&
2394
+ app.updates !== undefined
2395
+ ) {
2396
+ // aapt infers an inline numeric version as an integer. a string resource
2397
+ // preserves the exact runtime identifier read by the native launcher.
2398
+ const version = app.updates.runtimeVersion.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
2399
+ rendered = rendered.replace(
2400
+ '</resources>',
2401
+ ` <string name="one_updates_runtime_version" translatable="false">"${escapeXml(version)}"</string>\n</resources>`
2402
+ )
2403
+ }
1485
2404
  if (
1486
2405
  platform === 'android' &&
1487
2406
  relativePath === 'app/src/main/AndroidManifest.xml' &&
@@ -1503,11 +2422,11 @@ ${schemes.map((scheme) => `\t\t\t\t<string>${scheme}</string>`).join('\n')}
1503
2422
  relativePath === 'gradle.properties' &&
1504
2423
  app.notifications?.push === true
1505
2424
  ) {
1506
- // the flag @vxrn/native reads to compile the fcm source set in. it
2425
+ // the flag one reads to compile the fcm source set in. it
1507
2426
  // lives in the root gradle.properties so library builds see it through
1508
2427
  // the root project; without it the file is untouched and firebase
1509
2428
  // messaging stays out of the app.
1510
- const line = 'oneNativePush=true'
2429
+ const line = `${notificationsHost.pushGradleProperty}=true`
1511
2430
  if (!rendered.includes(line)) {
1512
2431
  const trailed = rendered.endsWith('\n') ? rendered : `${rendered}\n`
1513
2432
  rendered = `${trailed}\n# Remote push: set by native.app.notifications.push.\n${line}\n`
@@ -1518,7 +2437,7 @@ ${schemes.map((scheme) => `\t\t\t\t<string>${scheme}</string>`).join('\n')}
1518
2437
  relativePath === 'gradle.properties' &&
1519
2438
  app.android?.googleMapsApiKey !== undefined
1520
2439
  ) {
1521
- // the flag @vxrn/native reads to compile the maps source set in. it
2440
+ // the flag one reads to compile the maps source set in. it
1522
2441
  // lives in the root gradle.properties so library builds see it through
1523
2442
  // the root project; without the key the file is untouched and maps
1524
2443
  // stays out of the app.
@@ -1528,6 +2447,95 @@ ${schemes.map((scheme) => `\t\t\t\t<string>${scheme}</string>`).join('\n')}
1528
2447
  rendered = `${trailed}\n# One.UI.Map: set by native.app.android.googleMapsApiKey.\n${line}\n`
1529
2448
  }
1530
2449
  }
2450
+ if (
2451
+ platform === 'android' &&
2452
+ relativePath === 'app/src/main/res/values/styles.xml' &&
2453
+ (app.userInterfaceStyle === 'light' || app.userInterfaceStyle === 'dark')
2454
+ ) {
2455
+ // the template's DayNight theme follows the system; a locked style
2456
+ // takes the matching fixed appcompat theme.
2457
+ const dayNight = 'parent="Theme.AppCompat.DayNight.NoActionBar"'
2458
+ if (!rendered.includes(dayNight)) {
2459
+ throw new Error(
2460
+ `[vxrn] cannot lock userInterfaceStyle: expected ${dayNight} in app/src/main/res/values/styles.xml`
2461
+ )
2462
+ }
2463
+ rendered = rendered.replace(
2464
+ dayNight,
2465
+ app.userInterfaceStyle === 'light'
2466
+ ? 'parent="Theme.AppCompat.Light.NoActionBar"'
2467
+ : 'parent="Theme.AppCompat.NoActionBar"'
2468
+ )
2469
+ }
2470
+ if (
2471
+ platform === 'android' &&
2472
+ relativePath === 'app/src/main/AndroidManifest.xml' &&
2473
+ (app.android?.permissions?.length || app.android?.blockedPermissions?.length)
2474
+ ) {
2475
+ const anchor = '<uses-permission android:name="android.permission.INTERNET" />'
2476
+ if (!rendered.includes(anchor)) {
2477
+ throw new Error(
2478
+ '[vxrn] cannot stamp permissions: expected the INTERNET permission in app/src/main/AndroidManifest.xml'
2479
+ )
2480
+ }
2481
+ // a bare name is an android.permission, as expo reads it.
2482
+ const permissionName = (name: string) =>
2483
+ name.includes('.') ? name : `android.permission.${name}`
2484
+ const lines = [
2485
+ ...(app.android?.permissions ?? []).map(
2486
+ (name) => ` <uses-permission android:name="${permissionName(name)}" />`
2487
+ ),
2488
+ ...(app.android?.blockedPermissions ?? []).map(
2489
+ (name) =>
2490
+ ` <uses-permission android:name="${permissionName(name)}" tools:node="remove" />`
2491
+ ),
2492
+ ]
2493
+ rendered = rendered.replace(anchor, `${anchor}\n${lines.join('\n')}`)
2494
+ if (app.android?.blockedPermissions?.length && !rendered.includes('xmlns:tools=')) {
2495
+ rendered = rendered.replace(
2496
+ 'xmlns:android="http://schemas.android.com/apk/res/android"',
2497
+ 'xmlns:android="http://schemas.android.com/apk/res/android"\n xmlns:tools="http://schemas.android.com/tools"'
2498
+ )
2499
+ }
2500
+ }
2501
+ if (
2502
+ platform === 'android' &&
2503
+ relativePath === 'app/src/main/AndroidManifest.xml' &&
2504
+ app.android?.appLinks?.length
2505
+ ) {
2506
+ rendered = rendered.replace(
2507
+ ' </activity>',
2508
+ ` <intent-filter android:autoVerify="true">
2509
+ <action android:name="android.intent.action.VIEW" />
2510
+ <category android:name="android.intent.category.DEFAULT" />
2511
+ <category android:name="android.intent.category.BROWSABLE" />
2512
+ <data android:scheme="https" />
2513
+ ${app.android.appLinks
2514
+ .map(
2515
+ (link) =>
2516
+ ` <data android:host="${link.host}"${link.pathPrefix ? ` android:pathPrefix="${link.pathPrefix}"` : ''} />`
2517
+ )
2518
+ .join('\n')}
2519
+ </intent-filter>
2520
+ </activity>`
2521
+ )
2522
+ }
2523
+ if (
2524
+ platform === 'android' &&
2525
+ relativePath === 'app/src/main/AndroidManifest.xml' &&
2526
+ app.orientation !== undefined
2527
+ ) {
2528
+ const anchor = 'android:name=".MainActivity"'
2529
+ if (!rendered.includes(anchor)) {
2530
+ throw new Error(
2531
+ '[vxrn] cannot stamp the orientation: expected .MainActivity in app/src/main/AndroidManifest.xml'
2532
+ )
2533
+ }
2534
+ rendered = rendered.replace(
2535
+ anchor,
2536
+ `${anchor}\n android:screenOrientation="${app.orientation === 'default' ? 'unspecified' : app.orientation}"`
2537
+ )
2538
+ }
1531
2539
  if (
1532
2540
  platform === 'android' &&
1533
2541
  relativePath === 'app/src/main/AndroidManifest.xml' &&
@@ -1584,25 +2592,127 @@ ${schemes.map((scheme) => ` <data android:scheme="${scheme}" />`).joi
1584
2592
  )
1585
2593
  }
1586
2594
  }
2595
+ if (platform === 'ios' && relativePath.endsWith('.xcodeproj/project.pbxproj') &&
2596
+ app.ios?.alternateIcons !== undefined) {
2597
+ const anchor = 'ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;'
2598
+ if (!rendered.includes(anchor)) {
2599
+ throw new Error('[vxrn] prebuild template lost its primary app icon build setting')
2600
+ }
2601
+ const names = Object.keys(app.ios.alternateIcons).sort().join(' ')
2602
+ rendered = rendered.replaceAll(anchor,
2603
+ `${anchor}\n\t\t\t\tASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "${names}";`)
2604
+ }
1587
2605
  if (platform === 'ios' && relativePath.endsWith('/Info.plist')) {
1588
2606
  // schemes, usesNonExemptEncryption, and fileSharing stamp above in one
1589
- // anchored block; only the camera description stamps here.
2607
+ // anchored block; only the usage descriptions stamp here.
2608
+ const usage = new Map<string, string>()
1590
2609
  if (app.imagePicker?.camera !== undefined) {
2610
+ usage.set('NSCameraUsageDescription', app.imagePicker.camera)
2611
+ }
2612
+ if (app.imagePicker?.microphone !== undefined) {
2613
+ usage.set('NSMicrophoneUsageDescription', app.imagePicker.microphone)
2614
+ }
2615
+ if (app.photoLibrary !== undefined) {
2616
+ if (app.photoLibrary.addOnly !== undefined) {
2617
+ usage.set('NSPhotoLibraryAddUsageDescription', app.photoLibrary.addOnly)
2618
+ }
2619
+ if (app.photoLibrary.readWrite !== undefined) {
2620
+ usage.set('NSPhotoLibraryUsageDescription', app.photoLibrary.readWrite)
2621
+ }
2622
+ }
2623
+ if (app.contacts !== undefined) {
2624
+ usage.set('NSContactsUsageDescription', app.contacts.usage)
2625
+ }
2626
+ if (app.calendar !== undefined) {
2627
+ if (app.calendar.usage !== undefined) {
2628
+ usage.set('NSCalendarsFullAccessUsageDescription', app.calendar.usage)
2629
+ }
2630
+ if (app.calendar.remindersUsage !== undefined) {
2631
+ usage.set('NSRemindersFullAccessUsageDescription', app.calendar.remindersUsage)
2632
+ }
2633
+ }
2634
+ if (app.location !== undefined) {
2635
+ usage.set('NSLocationWhenInUseUsageDescription', app.location.whenInUse)
2636
+ if (app.location.alwaysAndWhenInUse !== undefined) {
2637
+ usage.set(
2638
+ 'NSLocationAlwaysAndWhenInUseUsageDescription',
2639
+ app.location.alwaysAndWhenInUse
2640
+ )
2641
+ }
2642
+ }
2643
+ if (app.pedometer !== undefined) {
2644
+ usage.set('NSMotionUsageDescription', app.pedometer.usage)
2645
+ }
2646
+ if (app.ios?.faceIdUsageDescription !== undefined) {
2647
+ usage.set('NSFaceIDUsageDescription', app.ios.faceIdUsageDescription)
2648
+ }
2649
+ if (app.speech !== undefined) {
2650
+ usage.set('NSSpeechRecognitionUsageDescription', app.speech.recognition)
2651
+ usage.set('NSMicrophoneUsageDescription', app.speech.microphone)
2652
+ }
2653
+ if (app.audio?.microphone !== undefined) {
2654
+ usage.set('NSMicrophoneUsageDescription', app.audio.microphone)
2655
+ }
2656
+ const additions: [string, string][] = []
2657
+ for (const [key, text] of usage) {
2658
+ const existing = new RegExp(`(<key>${key}</key>\\s*<string>)[^<]*(</string>)`)
2659
+ if (existing.test(rendered)) {
2660
+ rendered = rendered.replace(
2661
+ existing,
2662
+ (_match, before: string, after: string) => `${before}${escapeXml(text)}${after}`
2663
+ )
2664
+ } else {
2665
+ additions.push([key, text])
2666
+ }
2667
+ }
2668
+ if (additions.length) {
1591
2669
  const anchor = '\t<key>LSRequiresIPhoneOS</key>'
1592
2670
  if (!rendered.includes(anchor)) {
1593
2671
  throw new Error(
1594
- '[vxrn] cannot stamp NSCameraUsageDescription: expected LSRequiresIPhoneOS in Info.plist'
2672
+ `[vxrn] cannot stamp ${additions[0][0]}: expected LSRequiresIPhoneOS in Info.plist`
1595
2673
  )
1596
2674
  }
1597
2675
  rendered = rendered.replace(
1598
2676
  anchor,
1599
- `\t<key>NSCameraUsageDescription</key>\n\t<string>${escapeXml(app.imagePicker.camera)}</string>\n${anchor}`
2677
+ `${additions.map(([key, text]) => `\t<key>${key}</key>\n\t<string>${escapeXml(text)}</string>\n`).join('')}${anchor}`
1600
2678
  )
1601
2679
  }
2680
+ // the template ships an empty location purpose string; an empty one is
2681
+ // never valid, so a key the manifest did not fill leaves the plist.
2682
+ rendered = rendered.replace(
2683
+ /\t<key>NS\w+UsageDescription<\/key>\s*<string><\/string>\n/g,
2684
+ ''
2685
+ )
1602
2686
  rendered = patchIosInfoPlistSceneManifest(rendered)
1603
2687
  }
1604
2688
  if (platform === 'ios' && relativePath.endsWith('/AppDelegate.swift')) {
1605
- rendered = patchIosAppDelegateSceneLifecycle(rendered, appName)
2689
+ rendered = patchIosAppDelegateSceneLifecycle(
2690
+ rendered,
2691
+ appName,
2692
+ !!app.ios?.backgroundTasks,
2693
+ !!app.ios?.appIntents
2694
+ )
2695
+ rendered = nativeProjectPatches.holdLaunchScreenOverRootView(rendered)
2696
+ // the one native template uses hermes. request precompiled dev bytes so
2697
+ // lazy routes do not compile on the device while navigation is pending.
2698
+ const bundleURLProvider =
2699
+ 'RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")'
2700
+ if (!rendered.includes(bundleURLProvider)) {
2701
+ throw new Error(
2702
+ '[vxrn] cannot request hermes dev bytecode: expected the template bundle URL provider'
2703
+ )
2704
+ }
2705
+ rendered = rendered.replace(
2706
+ bundleURLProvider,
2707
+ `guard let bundleURL = RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") else { return nil }
2708
+ if bundleURL.isFileURL { return bundleURL }
2709
+ var components = URLComponents(url: bundleURL, resolvingAgainstBaseURL: false)!
2710
+ components.queryItems!.append(URLQueryItem(name: "bytecode", value: "hermes"))
2711
+ return components.url`
2712
+ )
2713
+ if (app.updates !== undefined) {
2714
+ rendered = nativeProjectPatches.pointReleaseBundleURLAtOneUpdates(rendered)
2715
+ }
1606
2716
  }
1607
2717
  if (platform === 'ios' && app.ios?.deploymentTarget) {
1608
2718
  rendered = rendered
@@ -1630,12 +2740,17 @@ end`
1630
2740
  }
1631
2741
  }
1632
2742
  if (platform === 'ios' && relativePath.endsWith('.xcodeproj/project.pbxproj')) {
1633
- rendered = patchIosBundlePhase(rendered)
2743
+ rendered = patchIosBundlePhase(rendered, app.updates?.runtimeVersion)
1634
2744
  rendered = patchIosPbxprojSceneDelegate(rendered, appName)
1635
- if (app.notifications?.push === true && !app.ios?.widgets) {
1636
- rendered = patchIosPbxprojPushEntitlements(rendered, appName)
2745
+ if (app.ios?.appIntents) rendered = patchIosPbxprojAppIntents(rendered, appName)
2746
+ rendered = patchIosPbxprojOneBridgingHeader(rendered, appName)
2747
+ if (Object.keys(appEntitlements(app)).length && !app.ios?.widgets) {
2748
+ rendered = patchIosPbxprojAppEntitlements(rendered, appName)
1637
2749
  }
1638
2750
  if (app.ios?.widgets) rendered = patchIosPbxprojWidgets(rendered, app)
2751
+ if (iosResourceFiles(app).length) {
2752
+ rendered = patchIosPbxprojResources(rendered, appName, iosResourceFiles(app))
2753
+ }
1639
2754
  }
1640
2755
  if (platform === 'ios' && relativePath === 'Podfile') {
1641
2756
  if (app.ios?.ccache) rendered = `ENV['USE_CCACHE'] ||= '1'\n${rendered}`
@@ -1649,11 +2764,20 @@ end`
1649
2764
  rendered = nativeProjectPatches.injectReactNativeScreensGammaIntoPodfile(rendered)
1650
2765
  }
1651
2766
  rendered = nativeProjectPatches.injectFmtCxx17FixIntoPodfile(rendered)
2767
+ rendered = nativeProjectPatches.injectWebGPUHeaderPathsIntoPodfile(rendered)
1652
2768
  rendered = nativeProjectPatches.injectOneSwiftPackagesIntoPodfile(rendered)
1653
2769
  if (nitroWebImage)
1654
2770
  rendered =
1655
2771
  nativeProjectPatches.injectNitroWebImageModularHeaderIntoPodfile(rendered)
1656
2772
  rendered = nativeProjectPatches.injectHermesMinificationPatchIntoPodfile(rendered)
2773
+ const nativeModulesCall = 'config = use_native_modules!'
2774
+ if (rendered.split(nativeModulesCall).length !== 2) {
2775
+ throw new Error('[vxrn] expected one iOS native module discovery call')
2776
+ }
2777
+ rendered = rendered.replace(
2778
+ nativeModulesCall,
2779
+ `config = use_native_modules!(['node', '-e', "process.argv=['', '', 'config'];require(require('module').createRequire(require.resolve('vxrn/package.json')).resolve('@react-native-community/cli')).run()"])`
2780
+ )
1657
2781
  if (
1658
2782
  !rendered.includes('[vxrn/one] fmt c++17 fix') ||
1659
2783
  !rendered.includes('[vxrn/one] minify iOS Hermes Release bundle input')
@@ -1670,6 +2794,12 @@ end`
1670
2794
  ) {
1671
2795
  throw new Error('[vxrn] failed to apply required Android Gradle patches')
1672
2796
  }
2797
+ if (app.updates !== undefined) {
2798
+ rendered = patchAndroidBuildGradleUpdatesManifest(
2799
+ rendered,
2800
+ app.updates.runtimeVersion
2801
+ )
2802
+ }
1673
2803
  if (app.version !== undefined) {
1674
2804
  rendered = rendered.replace(
1675
2805
  /versionName "[^"]*"/g,
@@ -1693,13 +2823,67 @@ end`
1693
2823
  hardcodedGradlePlugin,
1694
2824
  `includeBuild(new File(["node", "--print", "require('module').createRequire(require.resolve('react-native/package.json')).resolve('@react-native/gradle-plugin/package.json')"].execute(null, settingsDir).text.trim()).parentFile.canonicalPath)`
1695
2825
  )
1696
- }
1697
- if (platform === 'android' && app.android?.minSdk !== undefined) {
2826
+ const autolinkCommand = 'ex.autolinkLibrariesFromCommand()'
2827
+ if (rendered.split(autolinkCommand).length !== 2) {
2828
+ throw new Error('[vxrn] expected one React Native autolinking command')
2829
+ }
1698
2830
  rendered = rendered.replace(
1699
- /minSdkVersion = \d+/g,
1700
- `minSdkVersion = ${app.android.minSdk}`
2831
+ autolinkCommand,
2832
+ `ex.autolinkLibrariesFromCommand(["node", ["node", "--print", "require('module').createRequire(require.resolve('vxrn/package.json')).resolve('@react-native-community/cli/build/bin.js')"].execute(null, settingsDir).text.trim(), "config"])`
1701
2833
  )
1702
2834
  }
2835
+ if (platform === 'android' && relativePath === 'app/build.gradle' && app.android?.minify) {
2836
+ const minify = 'def enableProguardInReleaseBuilds = false'
2837
+ const minifyEnabled = 'minifyEnabled enableProguardInReleaseBuilds'
2838
+ if (!rendered.includes(minify) || !rendered.includes(minifyEnabled)) {
2839
+ throw new Error('[vxrn] prebuild template app/build.gradle lost its proguard switch')
2840
+ }
2841
+ rendered = rendered.replace(minify, 'def enableProguardInReleaseBuilds = true')
2842
+ if (app.android.shrinkResources) {
2843
+ rendered = rendered.replace(
2844
+ minifyEnabled,
2845
+ `${minifyEnabled}\n shrinkResources true`
2846
+ )
2847
+ }
2848
+ }
2849
+ if (platform === 'android' && app.android?.googleServicesFile) {
2850
+ const edits: Array<[string, string, string]> = [
2851
+ [
2852
+ 'build.gradle',
2853
+ 'classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")',
2854
+ ` classpath("com.google.gms:google-services:${GOOGLE_SERVICES_VERSION}")`,
2855
+ ],
2856
+ [
2857
+ 'app/build.gradle',
2858
+ 'apply plugin: "com.facebook.react"',
2859
+ 'apply plugin: "com.google.gms.google-services"',
2860
+ ],
2861
+ ]
2862
+ for (const [file, anchor, insertion] of edits) {
2863
+ if (relativePath !== file) continue
2864
+ const next = insertAfterLine(rendered, anchor, insertion)
2865
+ if (next === rendered) {
2866
+ throw new Error(`[vxrn] prebuild template ${file} lost its ${anchor} anchor`)
2867
+ }
2868
+ rendered = next
2869
+ }
2870
+ }
2871
+ if (
2872
+ platform === 'android' &&
2873
+ relativePath === 'app/proguard-rules.pro' &&
2874
+ app.android?.proguardRules
2875
+ ) {
2876
+ rendered = `${rendered.trimEnd()}\n\n${app.android.proguardRules.trim()}\n`
2877
+ }
2878
+ for (const [field, setting] of [
2879
+ ['minSdk', 'minSdkVersion'],
2880
+ ['targetSdk', 'targetSdkVersion'],
2881
+ ['compileSdk', 'compileSdkVersion'],
2882
+ ] as const) {
2883
+ const value = platform === 'android' ? app.android?.[field] : undefined
2884
+ if (value === undefined) continue
2885
+ rendered = rendered.replace(new RegExp(`${setting} = \\d+`, 'g'), `${setting} = ${value}`)
2886
+ }
1703
2887
  }
1704
2888
 
1705
2889
  return { destRelativePath, content: rendered }
@@ -1714,9 +2898,7 @@ export const generateForPlatform = async (
1714
2898
  validatePrebuildApp(app, platform)
1715
2899
  const dest = outDir
1716
2900
  const require = module.createRequire(root + '/')
1717
- const importPath = require.resolve('@react-native-community/cli/build/tools/walk.js', {
1718
- paths: [root],
1719
- })
2901
+ const importPath = requireVxrn.resolve('@react-native-community/cli/build/tools/walk.js')
1720
2902
  const src = path.join(
1721
2903
  path.dirname(
1722
2904
  require.resolve('@react-native-community/template/template/package.json', {
@@ -1770,38 +2952,202 @@ export const generateForPlatform = async (
1770
2952
  }
1771
2953
 
1772
2954
  await generateAppIcons({ root, dest, platform, app })
2955
+ if (platform === 'android') await generateAdaptiveIcon({ root, dest, app })
1773
2956
  await generateSplashScreen({ root, dest, platform, app })
2957
+ if (platform === 'ios') generateAccentColor({ dest, app })
2958
+ copyAppResources({ root, dest, platform, app })
1774
2959
  generateSceneDelegate({ dest, platform, app })
2960
+ generateIosAppIntents(dest, platform, app)
2961
+ if (platform === 'ios') generateOneBridgingHeader(dest, app)
1775
2962
  if (platform === 'ios') generateIosWidgets(dest, app)
1776
- if (platform === 'ios') generateSwiftPackages({ root, dest })
2963
+ if (platform === 'ios') await generateSwiftPackages({ root, dest })
2964
+ if (platform === 'android') await generateKotlinSources({ root, dest })
1777
2965
  }
1778
2966
 
1779
- // every directory under the app root holding a Package.swift becomes one local
1780
- // pod: its sources compile as their own module against VxrnNative (which
1781
- // supplies RNXPackage and JSON), the @main entry is renamed so it does not
1782
- // clash with the app's main, and an objc +load files the entry with the
1783
- // registry the OneSwiftHost view reads. the bundler resolves an import of any
1784
- // .swift file in the package to a host view naming the same package id.
1785
- function generateSwiftPackages({ root, dest }: { root: string; dest: string }) {
1786
- const skip = new Set(['node_modules', 'ios', 'android', 'dist', 'types', 'build'])
1787
- const packageDirs: string[] = []
1788
- const walk = (dir: string) => {
1789
- const entries = FSExtra.readdirSync(dir, { withFileTypes: true })
1790
- if (entries.some((entry) => entry.isFile() && entry.name === 'Package.swift')) {
1791
- packageDirs.push(dir)
2967
+ // an imported kotlin source can reach past the base ui/foundation/material3
2968
+ // closure: each import prefix below names the gradle artifact that supplies
2969
+ // it, so the source compiles without the app declaring the dependency.
2970
+ const KOTLIN_IMPORT_ARTIFACTS = [
2971
+ {
2972
+ importPrefix: 'androidx.compose.material.icons',
2973
+ artifact: 'androidx.compose.material:material-icons-core',
2974
+ versionKey: 'materialIconsVersion',
2975
+ version: '1.7.6',
2976
+ },
2977
+ ]
2978
+
2979
+ function kotlinSourceImports(dir: string): Set<string> {
2980
+ const imports = new Set<string>()
2981
+ if (!FSExtra.existsSync(dir)) return imports
2982
+ const visit = (entry: string) => {
2983
+ if (FSExtra.statSync(entry).isDirectory()) {
2984
+ for (const child of FSExtra.readdirSync(entry)) visit(path.join(entry, child))
1792
2985
  return
1793
2986
  }
1794
- for (const entry of entries) {
1795
- if (!entry.isDirectory() || entry.name.startsWith('.') || skip.has(entry.name))
1796
- continue
1797
- walk(path.join(dir, entry.name))
2987
+ if (!entry.endsWith('.kt')) return
2988
+ for (const line of FSExtra.readFileSync(entry, 'utf8').split('\n')) {
2989
+ const match = /^\s*import\s+([A-Za-z0-9_.]+)/.exec(line)
2990
+ if (match) imports.add(match[1])
2991
+ }
2992
+ }
2993
+ visit(dir)
2994
+ return imports
2995
+ }
2996
+
2997
+ export function enableAppComposeIntegration(dest: string) {
2998
+ const rootBuildGradlePath = path.join(dest, 'build.gradle')
2999
+ if (FSExtra.existsSync(rootBuildGradlePath)) {
3000
+ let content = FSExtra.readFileSync(rootBuildGradlePath, 'utf8')
3001
+ const composePluginClasspath =
3002
+ ' classpath("org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin:$kotlinVersion")'
3003
+ if (!content.includes('org.jetbrains.kotlin.plugin.compose')) {
3004
+ const anchor = content.includes('classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")')
3005
+ ? 'classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")'
3006
+ : 'classpath "org.jetbrains.kotlin:kotlin-gradle-plugin"'
3007
+ content = insertAfterLine(content, anchor, composePluginClasspath)
3008
+ FSExtra.writeFileSync(rootBuildGradlePath, content, 'utf8')
3009
+ }
3010
+ }
3011
+
3012
+ const appBuildGradlePath = path.join(dest, 'app/build.gradle')
3013
+ if (FSExtra.existsSync(appBuildGradlePath)) {
3014
+ let content = FSExtra.readFileSync(appBuildGradlePath, 'utf8')
3015
+ if (!content.includes('org.jetbrains.kotlin.plugin.compose')) {
3016
+ const pluginAnchor = content.includes('apply plugin: "org.jetbrains.kotlin.android"')
3017
+ ? 'apply plugin: "org.jetbrains.kotlin.android"'
3018
+ : "apply plugin: 'org.jetbrains.kotlin.android'"
3019
+ content = insertAfterLine(
3020
+ content,
3021
+ pluginAnchor,
3022
+ 'apply plugin: "org.jetbrains.kotlin.plugin.compose"'
3023
+ )
3024
+ }
3025
+ if (!content.includes('compose true')) {
3026
+ content = insertAfterLine(
3027
+ content,
3028
+ 'android {',
3029
+ ' buildFeatures {\n compose true\n }'
3030
+ )
3031
+ }
3032
+ if (!content.includes('androidx.compose.ui:ui')) {
3033
+ const composeDeps = [
3034
+ ' def composeUiVersion = rootProject.ext.has("composeUiVersion") ? rootProject.ext.get("composeUiVersion") : "1.11.4"',
3035
+ ' def material3Version = rootProject.ext.has("material3Version") ? rootProject.ext.get("material3Version") : "1.5.0-alpha17"',
3036
+ ' implementation "androidx.compose.ui:ui:$composeUiVersion"',
3037
+ ' implementation "androidx.compose.foundation:foundation:$composeUiVersion"',
3038
+ ' implementation "androidx.compose.material3:material3:$material3Version"',
3039
+ // swiftui carries navigation; compose ships it as its own artifact, so a
3040
+ // kotlin view importing NavHost needs it on the app classpath.
3041
+ ' def navigationComposeVersion = rootProject.ext.has("navigationComposeVersion") ? rootProject.ext.get("navigationComposeVersion") : "2.10.2"',
3042
+ ' implementation "androidx.navigation:navigation-compose:$navigationComposeVersion"',
3043
+ ].join('\n')
3044
+ const depsAnchor = content.includes('implementation("com.facebook.react:react-android")')
3045
+ ? 'implementation("com.facebook.react:react-android")'
3046
+ : 'implementation "com.facebook.react:react-android"'
3047
+ content = insertAfterLine(content, depsAnchor, composeDeps)
1798
3048
  }
3049
+ // each import rule carries its own guard, so a re-prebuild adds a newly
3050
+ // needed artifact without touching the rest of the file.
3051
+ const imports = kotlinSourceImports(path.join(dest, 'app/src/main/java/one/source'))
3052
+ for (const rule of KOTLIN_IMPORT_ARTIFACTS) {
3053
+ const needed = [...imports].some(
3054
+ (name) => name === rule.importPrefix || name.startsWith(`${rule.importPrefix}.`)
3055
+ )
3056
+ if (!needed || content.includes(rule.artifact)) continue
3057
+ const reactAnchor = content.includes('implementation("com.facebook.react:react-android")')
3058
+ ? 'implementation("com.facebook.react:react-android")'
3059
+ : 'implementation "com.facebook.react:react-android"'
3060
+ const anchor = content.includes('androidx.compose.material3:material3')
3061
+ ? 'androidx.compose.material3:material3'
3062
+ : reactAnchor
3063
+ content = insertAfterLine(
3064
+ content,
3065
+ anchor,
3066
+ [
3067
+ ` def ${rule.versionKey} = rootProject.ext.has("${rule.versionKey}") ? rootProject.ext.get("${rule.versionKey}") : "${rule.version}"`,
3068
+ ` implementation "${rule.artifact}:$${rule.versionKey}"`,
3069
+ ].join('\n')
3070
+ )
3071
+ }
3072
+ FSExtra.writeFileSync(appBuildGradlePath, content, 'utf8')
1799
3073
  }
1800
- walk(root)
3074
+ }
1801
3075
 
1802
- for (const packageDir of packageDirs) {
1803
- const id = swiftPackageId(packageDir)
3076
+ // the native source contract loads only for an app that has kotlin or swift
3077
+ // sources to generate glue for.
3078
+ export async function generateKotlinSources({ root, dest }: { root: string; dest: string }) {
3079
+ // evidence holds preserved proof sources, never app sources; sweeping it
3080
+ // breaks :app:compileDebugKotlin when two preserved copies declare one class.
3081
+ const skip = new Set(['node_modules', 'ios', 'android', 'dist', 'types', 'build', 'tests', '__tests__', 'scripts', 'evidence'])
3082
+ const sources: string[] = []
3083
+ const manifest = FSExtra.existsSync(path.join(root, 'package.json'))
3084
+ ? FSExtra.readJsonSync(path.join(root, 'package.json'))
3085
+ : {}
3086
+ const workspacePatterns: string[] = Array.isArray(manifest.workspaces)
3087
+ ? manifest.workspaces
3088
+ : manifest.workspaces?.packages ?? []
3089
+ const { default: glob } = await import('fast-glob')
3090
+ const workspaceRoots = new Set(glob.sync(workspacePatterns, {
3091
+ cwd: root, absolute: true, onlyDirectories: true,
3092
+ ignore: ['**/node_modules/**', '**/ios/**', '**/android/**'],
3093
+ }).map((directory) => path.resolve(directory)))
3094
+ const collect = (dir: string) => {
3095
+ // declared workspace packages belong to this build; other nested packages
3096
+ // own their sources and fixtures.
3097
+ if (dir !== root && FSExtra.existsSync(path.join(dir, 'package.json')) && !workspaceRoots.has(dir)) return
3098
+ for (const entry of FSExtra.readdirSync(dir, { withFileTypes: true })) {
3099
+ if (entry.name.startsWith('.') || skip.has(entry.name)) continue
3100
+ const source = path.join(dir, entry.name)
3101
+ if (entry.isDirectory()) collect(source)
3102
+ else if (entry.isFile() && entry.name.endsWith('.kt')) sources.push(source)
3103
+ }
3104
+ }
3105
+ collect(root)
3106
+ // the generator owns this directory: rebuild it so a moved or deleted source
3107
+ // cannot leave a stale copy that redeclares a live class.
3108
+ const output = path.join(dest, 'app/src/main/java/one/source')
3109
+ FSExtra.removeSync(output)
3110
+ if (sources.length === 0) return
3111
+ const { kotlinSourceId, renderKotlinSourceGlue, writeNativeSourceDeclaration } = await import(
3112
+ '../utils/nativeSourceContract'
3113
+ )
3114
+ let hasViews = false
3115
+ for (const source of sources) {
3116
+ const id = kotlinSourceId(root, source)
3117
+ const target = path.join(output, id)
3118
+ FSExtra.mkdirSync(target, { recursive: true })
3119
+ FSExtra.copyFileSync(source, path.join(target, path.basename(source)))
3120
+ const contract = writeNativeSourceDeclaration(source)
3121
+ if (contract.views.length > 0) hasViews = true
3122
+ if (contract.modules.length > 0 || contract.views.length > 0) {
3123
+ FSExtra.writeFileSync(
3124
+ path.join(target, `OneNativeSource_${id}.kt`),
3125
+ renderKotlinSourceGlue(id, contract).source
3126
+ )
3127
+ }
3128
+ }
3129
+ if (hasViews) {
3130
+ enableAppComposeIntegration(dest)
3131
+ }
3132
+ }
3133
+
3134
+ // every directory under the app root holding a Package.swift becomes one local
3135
+ // pod: its sources compile as their own module against One (which
3136
+ // supplies PeachPackage and JSON), the @main entry is renamed so it does not
3137
+ // clash with the app's main, and an objc +load files the entry with the
3138
+ // registry the OneSwiftHost view reads. the bundler resolves an import of any
3139
+ // .swift file in the package to a host view naming the same package id.
3140
+ async function generateSwiftPackages({ root, dest }: { root: string; dest: string }) {
3141
+ const skip = new Set(['node_modules', 'ios', 'android', 'dist', 'types', 'build'])
3142
+ const packages = swiftPackageDirectories(root)
3143
+ if (packages.size === 0) return
3144
+ const { swiftPodManifest, writeSwiftPackageArtifacts } = await import('../utils/nativeSourceContract')
3145
+ for (const [id, packageDir] of packages) {
1804
3146
  const podDir = path.join(dest, 'OneSwiftPackages', id)
3147
+ const manifest = swiftPodManifest(
3148
+ path.join(packageDir, 'Package.swift'),
3149
+ FSExtra.readFileSync(path.join(packageDir, 'Package.swift'), 'utf8')
3150
+ )
1805
3151
  const sources: string[] = []
1806
3152
  const collect = (dir: string) => {
1807
3153
  for (const entry of FSExtra.readdirSync(dir, { withFileTypes: true })) {
@@ -1814,6 +3160,12 @@ function generateSwiftPackages({ root, dest }: { root: string; dest: string }) {
1814
3160
  }
1815
3161
  }
1816
3162
  collect(packageDir)
3163
+ const artifacts = writeSwiftPackageArtifacts(packageDir)
3164
+ const contracts = artifacts.contracts
3165
+ const hasView = contracts.some((contract) => contract.defaultView)
3166
+ if (contracts.filter((contract) => contract.defaultView).length > 1) {
3167
+ throw new Error(`[vxrn] swift package ${packageDir} has more than one @main type`)
3168
+ }
1817
3169
  // cocoapods globs do not descend into symlinked directories, so mirror the
1818
3170
  // tree with real directories and link each file.
1819
3171
  for (const source of sources) {
@@ -1821,6 +3173,12 @@ function generateSwiftPackages({ root, dest }: { root: string; dest: string }) {
1821
3173
  FSExtra.mkdirSync(path.dirname(link), { recursive: true })
1822
3174
  FSExtra.symlinkSync(FSExtra.realpathSync(source), link)
1823
3175
  }
3176
+ if (artifacts.glue) {
3177
+ FSExtra.writeFileSync(
3178
+ path.join(podDir, 'Sources', 'OneNativeSource.generated.swift'),
3179
+ artifacts.glue
3180
+ )
3181
+ }
1824
3182
  FSExtra.writeFileSync(
1825
3183
  path.join(podDir, `${id}.podspec`),
1826
3184
  `Pod::Spec.new do |s|
@@ -1831,18 +3189,18 @@ function generateSwiftPackages({ root, dest }: { root: string; dest: string }) {
1831
3189
  s.license = 'MIT'
1832
3190
  s.author = 'one'
1833
3191
  s.source = { :path => '.' }
1834
- s.swift_version = '6.0'
3192
+ s.swift_version = '${manifest.languageMode}.0'
1835
3193
  s.source_files = 'Sources/**/*.swift', 'Register.m'
1836
- s.dependency 'VxrnNative'
3194
+ s.dependency 'One'
1837
3195
  s.pod_target_xcconfig = {
1838
- 'OTHER_SWIFT_FLAGS' => '$(inherited) -cxx-interoperability-mode=default -Xcc -std=c++20 -Xfrontend -import-module -Xfrontend VxrnNative -Xfrontend -entry-point-function-name -Xfrontend ${id}_main',
3196
+ 'OTHER_SWIFT_FLAGS' => '$(inherited) -cxx-interoperability-mode=default -Xcc -std=c++20 -Xfrontend -import-module -Xfrontend One${manifest.mainActorIsolation ? ' -default-isolation MainActor' : ''}${hasView ? ` -Xfrontend -entry-point-function-name -Xfrontend ${id}_main` : ''}',
1839
3197
  }
1840
3198
  end
1841
3199
  `
1842
3200
  )
1843
3201
  FSExtra.writeFileSync(
1844
3202
  path.join(podDir, 'Register.m'),
1845
- `#import <Foundation/Foundation.h>
3203
+ hasView ? `#import <Foundation/Foundation.h>
1846
3204
 
1847
3205
  extern int ${id}_main(int argc, char **argv);
1848
3206
  extern void OneSwiftRegisterPackage(const char *name, int (*entry)(int, char **));
@@ -1855,7 +3213,7 @@ extern void OneSwiftRegisterPackage(const char *name, int (*entry)(int, char **)
1855
3213
  OneSwiftRegisterPackage("${id}", ${id}_main);
1856
3214
  }
1857
3215
  @end
1858
- `
3216
+ ` : '#import <Foundation/Foundation.h>\n'
1859
3217
  )
1860
3218
  }
1861
3219
  }
@@ -1893,8 +3251,8 @@ export function applyAndroidDependencyPatches(args: {
1893
3251
  ...app.android.applicationId.split('.'),
1894
3252
  'MainActivity.kt'
1895
3253
  )
1896
- const rendered = nativeProjectPatches.addReactNativeScreensFix(
1897
- FSExtra.readFileSync(activityPath, 'utf8')
3254
+ const rendered = nativeProjectPatches.holdLaunchScreenInMainActivity(
3255
+ nativeProjectPatches.addReactNativeScreensFix(FSExtra.readFileSync(activityPath, 'utf8'))
1898
3256
  )
1899
3257
  if (!rendered.includes('RNScreensFragmentFactory')) {
1900
3258
  throw new Error('[vxrn] failed to apply the react-native-screens activity patch')
@@ -1907,10 +3265,7 @@ export function applyAndroidDependencyPatches(args: {
1907
3265
  export async function getNativeDependencyInventory(
1908
3266
  root: string
1909
3267
  ): Promise<NativeDependencyInventory[]> {
1910
- const require = module.createRequire(root + '/')
1911
- const cliConfigPath = require.resolve('@react-native-community/cli-config', {
1912
- paths: [root],
1913
- })
3268
+ const cliConfigPath = requireVxrn.resolve('@react-native-community/cli-config')
1914
3269
  const cliConfig = (await import(pathToFileURL(cliConfigPath).href)) as {
1915
3270
  loadConfigAsync: (options: { projectRoot: string }) => Promise<{
1916
3271
  dependencies: Record<
@@ -1951,8 +3306,13 @@ export function installNativeDependencies(args: {
1951
3306
  }): void {
1952
3307
  const { root, platform } = args
1953
3308
  if (!platform || platform === 'ios') {
3309
+ // cocoapods reads podfiles and native module config as the locale's
3310
+ // encoding; a shell without a utf-8 locale fails on any non-ascii byte.
3311
+ // one owns this pod install, so react native's notice pointing users at
3312
+ // expo or the community cli does not apply.
1954
3313
  execFileSync('pod', ['install', `--project-directory=${path.join(root, 'ios')}`], {
1955
3314
  stdio: 'inherit',
3315
+ env: { ...process.env, LC_ALL: 'en_US.UTF-8', RCT_IGNORE_PODS_DEPRECATION: '1' },
1956
3316
  })
1957
3317
  }
1958
3318
  }