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.
- package/dist/backgroundComputation.mjs +145 -0
- package/dist/backgroundComputation.mjs.map +1 -0
- package/dist/backgroundComputation.native.js +163 -0
- package/dist/backgroundComputation.native.js.map +1 -0
- package/dist/backgroundComputationNative.mjs +16 -0
- package/dist/backgroundComputationNative.mjs.map +1 -0
- package/dist/backgroundComputationNative.native.js +18 -0
- package/dist/backgroundComputationNative.native.js.map +1 -0
- package/dist/config/getBaseViteConfigOnly.mjs +6 -10
- package/dist/config/getBaseViteConfigOnly.mjs.map +1 -1
- package/dist/config/getBaseViteConfigOnly.native.js +6 -10
- package/dist/config/getBaseViteConfigOnly.native.js.map +1 -1
- package/dist/config/getOptimizeDeps.mjs +2 -1
- package/dist/config/getOptimizeDeps.mjs.map +1 -1
- package/dist/config/getOptimizeDeps.native.js +2 -1
- package/dist/config/getOptimizeDeps.native.js.map +1 -1
- package/dist/config/getViteServerConfig.mjs +13 -0
- package/dist/config/getViteServerConfig.mjs.map +1 -1
- package/dist/config/getViteServerConfig.native.js +15 -1
- package/dist/config/getViteServerConfig.native.js.map +1 -1
- package/dist/constants/defaults.mjs +4 -0
- package/dist/constants/defaults.mjs.map +1 -1
- package/dist/constants/defaults.native.js +4 -0
- package/dist/constants/defaults.native.js.map +1 -1
- package/dist/exports/build.native.js +1 -1
- package/dist/exports/build.native.js.map +1 -1
- package/dist/exports/clean.native.js +1 -1
- package/dist/exports/clean.native.js.map +1 -1
- package/dist/exports/createServer.native.js +2 -2
- package/dist/exports/createServer.native.js.map +1 -1
- package/dist/exports/dev.mjs +3 -0
- package/dist/exports/dev.mjs.map +1 -1
- package/dist/exports/dev.native.js +6 -3
- package/dist/exports/dev.native.js.map +1 -1
- package/dist/exports/loadEnv.native.js +1 -1
- package/dist/exports/loadEnv.native.js.map +1 -1
- package/dist/exports/patch.native.js +1 -1
- package/dist/exports/patch.native.js.map +1 -1
- package/dist/exports/prebuild.mjs +0 -22
- package/dist/exports/prebuild.mjs.map +1 -1
- package/dist/exports/prebuild.native.js +1 -23
- package/dist/exports/prebuild.native.js.map +1 -1
- package/dist/exports/prebuildWithoutExpo.mjs +1116 -178
- package/dist/exports/prebuildWithoutExpo.mjs.map +1 -1
- package/dist/exports/prebuildWithoutExpo.native.js +1592 -243
- package/dist/exports/prebuildWithoutExpo.native.js.map +1 -1
- package/dist/exports/runAndroid.native.js +1 -1
- package/dist/exports/runAndroid.native.js.map +1 -1
- package/dist/exports/runIos.native.js +1 -1
- package/dist/exports/runIos.native.js.map +1 -1
- package/dist/exports/serve.native.js +1 -1
- package/dist/exports/serve.native.js.map +1 -1
- package/dist/exports/serveStaticAssets.native.js +1 -1
- package/dist/exports/serveStaticAssets.native.js.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.mjs +4 -1
- package/dist/index.native.js +4 -1
- package/dist/native-source-contract.mjs +3 -0
- package/dist/native-source-contract.native.js +3 -0
- package/dist/patches/builtInDepPatches.mjs +0 -13
- package/dist/patches/builtInDepPatches.mjs.map +1 -1
- package/dist/patches/builtInDepPatches.native.js +0 -13
- package/dist/patches/builtInDepPatches.native.js.map +1 -1
- package/dist/plugins/autoDepOptimizePlugin.mjs +3 -2
- package/dist/plugins/autoDepOptimizePlugin.mjs.map +1 -1
- package/dist/plugins/autoDepOptimizePlugin.native.js +6 -3
- package/dist/plugins/autoDepOptimizePlugin.native.js.map +1 -1
- package/dist/plugins/backgroundComputationPlugin.mjs +27 -0
- package/dist/plugins/backgroundComputationPlugin.mjs.map +1 -0
- package/dist/plugins/backgroundComputationPlugin.native.js +32 -0
- package/dist/plugins/backgroundComputationPlugin.native.js.map +1 -0
- package/dist/plugins/reactNativeCommonJsPlugin.native.js +3 -3
- package/dist/plugins/reactNativeCommonJsPlugin.native.js.map +1 -1
- package/dist/plugins/reactNativeDevAssetPlugin.mjs +2 -1
- package/dist/plugins/reactNativeDevAssetPlugin.mjs.map +1 -1
- package/dist/plugins/reactNativeDevAssetPlugin.native.js +3 -2
- package/dist/plugins/reactNativeDevAssetPlugin.native.js.map +1 -1
- package/dist/plugins/reactNativeDevServer.mjs +87 -24
- package/dist/plugins/reactNativeDevServer.mjs.map +1 -1
- package/dist/plugins/reactNativeDevServer.native.js +162 -45
- package/dist/plugins/reactNativeDevServer.native.js.map +1 -1
- package/dist/plugins/workletImportsPlugin.mjs +9 -30
- package/dist/plugins/workletImportsPlugin.mjs.map +1 -1
- package/dist/plugins/workletImportsPlugin.native.js +10 -35
- package/dist/plugins/workletImportsPlugin.native.js.map +1 -1
- package/dist/rn-commands/bundle/buildBundle.mjs +3 -3
- package/dist/rn-commands/bundle/buildBundle.mjs.map +1 -1
- package/dist/rn-commands/bundle/buildBundle.native.js +3 -3
- package/dist/rn-commands/bundle/buildBundle.native.js.map +1 -1
- package/dist/runtime/hmr-client.native.js +3 -7
- package/dist/runtime/hmr-client.native.js.map +1 -1
- package/dist/runtime/hmr-runtime.native.js +32 -40
- package/dist/runtime/hmr-runtime.native.js.map +1 -1
- package/dist/serve/node.native.js +2 -2
- package/dist/serve/node.native.js.map +1 -1
- package/dist/swift-package-id.mjs +3 -0
- package/dist/swift-package-id.native.js +3 -0
- package/dist/user-interface/index.native.js +6 -6
- package/dist/user-interface/index.native.js.map +1 -1
- package/dist/utils/compileNativeDevBytecode.mjs +93 -0
- package/dist/utils/compileNativeDevBytecode.mjs.map +1 -0
- package/dist/utils/compileNativeDevBytecode.native.js +98 -0
- package/dist/utils/compileNativeDevBytecode.native.js.map +1 -0
- package/dist/utils/createNativeDevEngine.mjs +227 -20
- package/dist/utils/createNativeDevEngine.mjs.map +1 -1
- package/dist/utils/createNativeDevEngine.native.js +249 -37
- package/dist/utils/createNativeDevEngine.native.js.map +1 -1
- package/dist/utils/execSync.native.js +1 -1
- package/dist/utils/execSync.native.js.map +1 -1
- package/dist/utils/getServerEntry.native.js +2 -2
- package/dist/utils/getServerEntry.native.js.map +1 -1
- package/dist/utils/nativeRun.mjs +32 -16
- package/dist/utils/nativeRun.mjs.map +1 -1
- package/dist/utils/nativeRun.native.js +43 -18
- package/dist/utils/nativeRun.native.js.map +1 -1
- package/dist/utils/nativeSourceContract.mjs +1823 -0
- package/dist/utils/nativeSourceContract.mjs.map +1 -0
- package/dist/utils/nativeSourceContract.native.js +2425 -0
- package/dist/utils/nativeSourceContract.native.js.map +1 -0
- package/dist/utils/patches.native.js +15 -24
- package/dist/utils/patches.native.js.map +1 -1
- package/dist/utils/printServerUrls.native.js +1 -1
- package/dist/utils/printServerUrls.native.js.map +1 -1
- package/dist/utils/scanDepsToOptimize.mjs +7 -1
- package/dist/utils/scanDepsToOptimize.mjs.map +1 -1
- package/dist/utils/scanDepsToOptimize.native.js +27 -2
- package/dist/utils/scanDepsToOptimize.native.js.map +1 -1
- package/dist/utils/swiftPackageId.mjs +31 -2
- package/dist/utils/swiftPackageId.mjs.map +1 -1
- package/dist/utils/swiftPackageId.native.js +50 -2
- package/dist/utils/swiftPackageId.native.js.map +1 -1
- package/dist/vxrn-vite-plugin.native.js +1 -1
- package/dist/vxrn-vite-plugin.native.js.map +1 -1
- package/expo-plugin.cjs +792 -2
- package/native-project-patches.cjs +201 -10
- package/package.json +34 -15
- package/src/backgroundComputation.ts +220 -0
- package/src/backgroundComputationNative.ts +23 -0
- package/src/config/getBaseViteConfigOnly.ts +8 -9
- package/src/config/getOptimizeDeps.ts +3 -1
- package/src/config/getViteServerConfig.ts +16 -0
- package/src/config/glbAssets.test.ts +228 -0
- package/src/constants/defaults.ts +6 -0
- package/src/exports/dev.ts +5 -1
- package/src/exports/expoPlugin.test.mjs +337 -15
- package/src/exports/prebuild.ts +0 -31
- package/src/exports/prebuildWithoutExpo.test.ts +1588 -48
- package/src/exports/prebuildWithoutExpo.ts +1573 -213
- package/src/index.ts +8 -0
- package/src/native-source-contract.ts +16 -0
- package/src/patches/builtInDepPatches.ts +0 -21
- package/src/plugins/autoDepOptimizePlugin.ts +7 -2
- package/src/plugins/backgroundComputationPlugin.test.ts +77 -0
- package/src/plugins/backgroundComputationPlugin.ts +29 -0
- package/src/plugins/reactNativeDevAssetPlugin.ts +5 -1
- package/src/plugins/reactNativeDevServer.test.ts +74 -0
- package/src/plugins/reactNativeDevServer.ts +145 -28
- package/src/plugins/rnCodegenPlugin.test.ts +30 -0
- package/src/plugins/workletImportsPlugin.ts +19 -37
- package/src/rn-commands/bundle/buildBundle.ts +5 -3
- package/src/swift-package-id.ts +1 -0
- package/src/utils/compileNativeDevBytecode.test.ts +168 -0
- package/src/utils/compileNativeDevBytecode.ts +117 -0
- package/src/utils/createNativeDevEngine.test.ts +643 -18
- package/src/utils/createNativeDevEngine.ts +278 -48
- package/src/utils/nativeRun.test.ts +125 -23
- package/src/utils/nativeRun.ts +51 -23
- package/src/utils/nativeSourceArtifacts.test.ts +39 -0
- package/src/utils/nativeSourceContract.test.ts +600 -0
- package/src/utils/nativeSourceContract.ts +1897 -0
- package/src/utils/scanDepsToOptimize.test.ts +80 -0
- package/src/utils/scanDepsToOptimize.ts +18 -7
- package/src/utils/swiftPackageId.ts +25 -1
- package/types/backgroundComputation.d.ts +28 -0
- package/types/backgroundComputation.d.ts.map +1 -0
- package/types/backgroundComputationNative.d.ts +2 -0
- package/types/backgroundComputationNative.d.ts.map +1 -0
- package/types/config/getBaseViteConfigOnly.d.ts.map +1 -1
- package/types/config/getOptimizeDeps.d.ts.map +1 -1
- package/types/config/getViteServerConfig.d.ts.map +1 -1
- package/types/constants/defaults.d.ts.map +1 -1
- package/types/exports/dev.d.ts.map +1 -1
- package/types/exports/prebuild.d.ts.map +1 -1
- package/types/exports/prebuildWithoutExpo.d.ts +6 -0
- package/types/exports/prebuildWithoutExpo.d.ts.map +1 -1
- package/types/index.d.ts +2 -0
- package/types/index.d.ts.map +1 -1
- package/types/native-source-contract.d.ts +2 -0
- package/types/native-source-contract.d.ts.map +1 -0
- package/types/patches/builtInDepPatches.d.ts.map +1 -1
- package/types/plugins/autoDepOptimizePlugin.d.ts.map +1 -1
- package/types/plugins/backgroundComputationPlugin.d.ts +3 -0
- package/types/plugins/backgroundComputationPlugin.d.ts.map +1 -0
- package/types/plugins/reactNativeDevAssetPlugin.d.ts.map +1 -1
- package/types/plugins/reactNativeDevServer.d.ts.map +1 -1
- package/types/plugins/workletImportsPlugin.d.ts.map +1 -1
- package/types/rn-commands/bundle/buildBundle.d.ts.map +1 -1
- package/types/swift-package-id.d.ts +2 -0
- package/types/swift-package-id.d.ts.map +1 -0
- package/types/utils/compileNativeDevBytecode.d.ts +3 -0
- package/types/utils/compileNativeDevBytecode.d.ts.map +1 -0
- package/types/utils/createNativeDevEngine.d.ts +7 -22
- package/types/utils/createNativeDevEngine.d.ts.map +1 -1
- package/types/utils/nativeRun.d.ts.map +1 -1
- package/types/utils/nativeSourceContract.d.ts +121 -0
- package/types/utils/nativeSourceContract.d.ts.map +1 -0
- package/types/utils/scanDepsToOptimize.d.ts +1 -0
- package/types/utils/scanDepsToOptimize.d.ts.map +1 -1
- package/types/utils/swiftPackageId.d.ts +1 -0
- 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 {
|
|
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 {
|
|
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
|
|
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
|
|
130
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
)
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
322
|
-
|
|
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
|
|
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
|
|
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
|
|
532
|
-
//
|
|
533
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1114
|
-
const iconDir = path.join(dest, app
|
|
1115
|
-
const
|
|
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
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
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(
|
|
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
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
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
|
|
1183
|
-
|
|
1184
|
-
|
|
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(
|
|
1187
|
-
|
|
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 =
|
|
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
|
-
|
|
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"
|
|
1219
|
-
<constraints
|
|
1220
|
-
|
|
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
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
const
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
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
|
-
|
|
1270
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2022
|
+
// one without notifications keep whatever delegate their own
|
|
1388
2023
|
// push library sets.
|
|
1389
|
-
stamps.push(`\t<key
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
2195
|
+
[
|
|
2196
|
+
anchor,
|
|
2197
|
+
...notificationsHost.androidPermissions.map(
|
|
2198
|
+
(permission) => ` <uses-permission android:name="${permission}" />`
|
|
2199
|
+
),
|
|
2200
|
+
].join('\n')
|
|
1444
2201
|
)
|
|
1445
|
-
if (!rendered.includes(
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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.
|
|
1636
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1700
|
-
`
|
|
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 =
|
|
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
|
-
//
|
|
1780
|
-
//
|
|
1781
|
-
//
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
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
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
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
|
-
|
|
3074
|
+
}
|
|
1801
3075
|
|
|
1802
|
-
|
|
1803
|
-
|
|
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 = '
|
|
3192
|
+
s.swift_version = '${manifest.languageMode}.0'
|
|
1835
3193
|
s.source_files = 'Sources/**/*.swift', 'Register.m'
|
|
1836
|
-
s.dependency '
|
|
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
|
|
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.
|
|
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
|
|
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
|
}
|