vxrn 2.0.0-beta.98.1 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +1133 -182
- package/dist/exports/prebuildWithoutExpo.mjs.map +1 -1
- package/dist/exports/prebuildWithoutExpo.native.js +1606 -244
- 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 +1623 -46
- package/src/exports/prebuildWithoutExpo.ts +1606 -216
- 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. -->',
|
|
@@ -1296,16 +1925,19 @@ async function generateSplashScreen(args: {
|
|
|
1296
1925
|
FSExtra.writeFileSync(stylesPath, styles)
|
|
1297
1926
|
const stylesV31 = path.join(mainRes, 'values-v31')
|
|
1298
1927
|
FSExtra.mkdirSync(stylesV31, { recursive: true })
|
|
1928
|
+
// a values-v31 AppTheme replaces the base one on API 31+, so it is the base
|
|
1929
|
+
// theme (parent included) plus the system splash items, never a bare style.
|
|
1930
|
+
if (!styles.includes('</style>')) {
|
|
1931
|
+
throw new Error('[vxrn] prebuild template styles.xml lost its AppTheme </style> anchor')
|
|
1932
|
+
}
|
|
1299
1933
|
FSExtra.writeFileSync(
|
|
1300
1934
|
path.join(stylesV31, 'styles.xml'),
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
<
|
|
1304
|
-
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
|
|
1935
|
+
styles.replace(
|
|
1936
|
+
'</style>',
|
|
1937
|
+
` <item name="android:windowSplashScreenBackground">@color/splash_background</item>
|
|
1305
1938
|
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash</item>
|
|
1306
|
-
</style
|
|
1307
|
-
|
|
1308
|
-
`
|
|
1939
|
+
</style>`
|
|
1940
|
+
)
|
|
1309
1941
|
)
|
|
1310
1942
|
}
|
|
1311
1943
|
|
|
@@ -1374,20 +2006,107 @@ ${schemes.map((scheme) => `\t\t\t\t<string>${scheme}</string>`).join('\n')}
|
|
|
1374
2006
|
`\t<key>ITSAppUsesNonExemptEncryption</key>\n\t<${app.ios.usesNonExemptEncryption ? 'true' : 'false'}/>`
|
|
1375
2007
|
)
|
|
1376
2008
|
}
|
|
2009
|
+
if (app.ios?.accentColor) {
|
|
2010
|
+
stamps.push('\t<key>NSAccentColorName</key>\n\t<string>AccentColor</string>')
|
|
2011
|
+
}
|
|
1377
2012
|
if (app.ios?.fileSharing) {
|
|
1378
2013
|
stamps.push(
|
|
1379
2014
|
`\t<key>UIFileSharingEnabled</key>\n\t<true/>\n\t<key>LSSupportsOpeningDocumentsInPlace</key>\n\t<true/>`
|
|
1380
2015
|
)
|
|
1381
2016
|
}
|
|
2017
|
+
if (app.photoLibrary?.readWrite !== undefined) {
|
|
2018
|
+
stamps.push('\t<key>PHPhotoLibraryPreventAutomaticLimitedAccessAlert</key>\n\t<true/>')
|
|
2019
|
+
}
|
|
1382
2020
|
if (app.notifications !== undefined) {
|
|
1383
2021
|
// gates the UNUserNotificationCenter delegate install: apps that link
|
|
1384
|
-
//
|
|
2022
|
+
// one without notifications keep whatever delegate their own
|
|
1385
2023
|
// push library sets.
|
|
1386
|
-
stamps.push(`\t<key
|
|
2024
|
+
stamps.push(`\t<key>${notificationsHost.enabledInfoPlistKey}</key>\n\t<true/>`)
|
|
2025
|
+
}
|
|
2026
|
+
if (app.notifications?.push === true) {
|
|
2027
|
+
// without the aps-environment entitlement the simulator never answers
|
|
2028
|
+
// registerForRemoteNotifications, so the token getter reads this to
|
|
2029
|
+
// reject at once like android's nopush flavor.
|
|
2030
|
+
stamps.push(`\t<key>${notificationsHost.pushInfoPlistKey}</key>\n\t<true/>`)
|
|
1387
2031
|
}
|
|
1388
2032
|
if (app.ios?.widgets) {
|
|
1389
2033
|
stamps.push('\t<key>NSSupportsLiveActivities</key>\n\t<true/>')
|
|
1390
2034
|
}
|
|
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) {
|
|
2045
|
+
stamps.push(
|
|
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>`
|
|
2047
|
+
)
|
|
2048
|
+
}
|
|
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
|
+
}
|
|
1391
2110
|
if (stamps.length) {
|
|
1392
2111
|
const anchor = '\t<key>LSRequiresIPhoneOS</key>'
|
|
1393
2112
|
if (!rendered.includes(anchor))
|
|
@@ -1400,17 +2119,64 @@ ${schemes.map((scheme) => `\t\t\t\t<string>${scheme}</string>`).join('\n')}
|
|
|
1400
2119
|
if (
|
|
1401
2120
|
platform === 'android' &&
|
|
1402
2121
|
relativePath === 'app/src/main/AndroidManifest.xml' &&
|
|
1403
|
-
app.imagePicker?.camera !== undefined
|
|
2122
|
+
(app.imagePicker?.camera !== undefined || app.speech !== undefined)
|
|
1404
2123
|
) {
|
|
1405
2124
|
const anchor = '<uses-permission android:name="android.permission.INTERNET" />'
|
|
1406
2125
|
if (!rendered.includes(anchor)) {
|
|
1407
2126
|
throw new Error(
|
|
1408
|
-
'[vxrn] cannot stamp the camera permission: expected the INTERNET permission in app/src/main/AndroidManifest.xml'
|
|
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
|
|
2148
|
+
) {
|
|
2149
|
+
const anchor = '<uses-permission android:name="android.permission.INTERNET" />'
|
|
2150
|
+
if (!rendered.includes(anchor)) {
|
|
2151
|
+
throw new Error(
|
|
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'
|
|
1409
2175
|
)
|
|
1410
2176
|
}
|
|
1411
2177
|
rendered = rendered.replace(
|
|
1412
2178
|
anchor,
|
|
1413
|
-
`${anchor}\n <uses-permission android:name="android.permission.
|
|
2179
|
+
`${anchor}\n <uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />`
|
|
1414
2180
|
)
|
|
1415
2181
|
}
|
|
1416
2182
|
if (
|
|
@@ -1426,9 +2192,14 @@ ${schemes.map((scheme) => `\t\t\t\t<string>${scheme}</string>`).join('\n')}
|
|
|
1426
2192
|
}
|
|
1427
2193
|
rendered = rendered.replace(
|
|
1428
2194
|
anchor,
|
|
1429
|
-
|
|
2195
|
+
[
|
|
2196
|
+
anchor,
|
|
2197
|
+
...notificationsHost.androidPermissions.map(
|
|
2198
|
+
(permission) => ` <uses-permission android:name="${permission}" />`
|
|
2199
|
+
),
|
|
2200
|
+
].join('\n')
|
|
1430
2201
|
)
|
|
1431
|
-
if (!rendered.includes(
|
|
2202
|
+
if (!rendered.includes(notificationsHost.androidPermissions[0])) {
|
|
1432
2203
|
throw new Error(
|
|
1433
2204
|
'[vxrn] failed to stamp notification permissions into app manifest'
|
|
1434
2205
|
)
|
|
@@ -1438,9 +2209,9 @@ ${schemes.map((scheme) => `\t\t\t\t<string>${scheme}</string>`).join('\n')}
|
|
|
1438
2209
|
// arrive as explicit intents; only boot needs the filter.
|
|
1439
2210
|
rendered = rendered.replace(
|
|
1440
2211
|
' </activity>\n </application>',
|
|
1441
|
-
|
|
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>`
|
|
1442
2213
|
)
|
|
1443
|
-
if (!rendered.includes(
|
|
2214
|
+
if (!rendered.includes(notificationsHost.receiver)) {
|
|
1444
2215
|
throw new Error(
|
|
1445
2216
|
'[vxrn] failed to stamp the notification receiver into app manifest'
|
|
1446
2217
|
)
|
|
@@ -1454,7 +2225,7 @@ ${schemes.map((scheme) => `\t\t\t\t<string>${scheme}</string>`).join('\n')}
|
|
|
1454
2225
|
// the fcm refresh service lives in the app manifest, never the
|
|
1455
2226
|
// library one, so apps without push never start it. push implies the
|
|
1456
2227
|
// notifications block above, so the receiver anchor is present.
|
|
1457
|
-
const anchor =
|
|
2228
|
+
const anchor = notificationsHost.receiver
|
|
1458
2229
|
if (!rendered.includes(anchor)) {
|
|
1459
2230
|
throw new Error(
|
|
1460
2231
|
'[vxrn] cannot stamp the push service: expected the notification receiver in app/src/main/AndroidManifest.xml'
|
|
@@ -1462,12 +2233,174 @@ ${schemes.map((scheme) => `\t\t\t\t<string>${scheme}</string>`).join('\n')}
|
|
|
1462
2233
|
}
|
|
1463
2234
|
rendered = rendered.replace(
|
|
1464
2235
|
' </receiver>\n </application>',
|
|
1465
|
-
|
|
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>`
|
|
1466
2237
|
)
|
|
1467
|
-
if (!rendered.includes(
|
|
2238
|
+
if (!rendered.includes(notificationsHost.pushService)) {
|
|
1468
2239
|
throw new Error('[vxrn] failed to stamp the push service into app manifest')
|
|
1469
2240
|
}
|
|
1470
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
|
+
}
|
|
1471
2404
|
if (
|
|
1472
2405
|
platform === 'android' &&
|
|
1473
2406
|
relativePath === 'app/src/main/AndroidManifest.xml' &&
|
|
@@ -1489,11 +2422,11 @@ ${schemes.map((scheme) => `\t\t\t\t<string>${scheme}</string>`).join('\n')}
|
|
|
1489
2422
|
relativePath === 'gradle.properties' &&
|
|
1490
2423
|
app.notifications?.push === true
|
|
1491
2424
|
) {
|
|
1492
|
-
// the flag
|
|
2425
|
+
// the flag one reads to compile the fcm source set in. it
|
|
1493
2426
|
// lives in the root gradle.properties so library builds see it through
|
|
1494
2427
|
// the root project; without it the file is untouched and firebase
|
|
1495
2428
|
// messaging stays out of the app.
|
|
1496
|
-
const line =
|
|
2429
|
+
const line = `${notificationsHost.pushGradleProperty}=true`
|
|
1497
2430
|
if (!rendered.includes(line)) {
|
|
1498
2431
|
const trailed = rendered.endsWith('\n') ? rendered : `${rendered}\n`
|
|
1499
2432
|
rendered = `${trailed}\n# Remote push: set by native.app.notifications.push.\n${line}\n`
|
|
@@ -1504,7 +2437,7 @@ ${schemes.map((scheme) => `\t\t\t\t<string>${scheme}</string>`).join('\n')}
|
|
|
1504
2437
|
relativePath === 'gradle.properties' &&
|
|
1505
2438
|
app.android?.googleMapsApiKey !== undefined
|
|
1506
2439
|
) {
|
|
1507
|
-
// the flag
|
|
2440
|
+
// the flag one reads to compile the maps source set in. it
|
|
1508
2441
|
// lives in the root gradle.properties so library builds see it through
|
|
1509
2442
|
// the root project; without the key the file is untouched and maps
|
|
1510
2443
|
// stays out of the app.
|
|
@@ -1514,6 +2447,111 @@ ${schemes.map((scheme) => `\t\t\t\t<string>${scheme}</string>`).join('\n')}
|
|
|
1514
2447
|
rendered = `${trailed}\n# One.UI.Map: set by native.app.android.googleMapsApiKey.\n${line}\n`
|
|
1515
2448
|
}
|
|
1516
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
|
+
}
|
|
2539
|
+
if (
|
|
2540
|
+
platform === 'android' &&
|
|
2541
|
+
relativePath === 'app/src/main/AndroidManifest.xml' &&
|
|
2542
|
+
app.pictureInPicture
|
|
2543
|
+
) {
|
|
2544
|
+
const anchor = 'android:name=".MainActivity"'
|
|
2545
|
+
if (!rendered.includes(anchor)) {
|
|
2546
|
+
throw new Error(
|
|
2547
|
+
'[vxrn] cannot stamp picture in picture: expected .MainActivity in app/src/main/AndroidManifest.xml'
|
|
2548
|
+
)
|
|
2549
|
+
}
|
|
2550
|
+
rendered = rendered.replace(
|
|
2551
|
+
anchor,
|
|
2552
|
+
`${anchor}\n android:supportsPictureInPicture="true"`
|
|
2553
|
+
)
|
|
2554
|
+
}
|
|
1517
2555
|
if (
|
|
1518
2556
|
platform === 'android' &&
|
|
1519
2557
|
relativePath === 'app/src/main/AndroidManifest.xml' &&
|
|
@@ -1554,25 +2592,127 @@ ${schemes.map((scheme) => ` <data android:scheme="${scheme}" />`).joi
|
|
|
1554
2592
|
)
|
|
1555
2593
|
}
|
|
1556
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
|
+
}
|
|
1557
2605
|
if (platform === 'ios' && relativePath.endsWith('/Info.plist')) {
|
|
1558
2606
|
// schemes, usesNonExemptEncryption, and fileSharing stamp above in one
|
|
1559
|
-
// anchored block; only the
|
|
2607
|
+
// anchored block; only the usage descriptions stamp here.
|
|
2608
|
+
const usage = new Map<string, string>()
|
|
1560
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) {
|
|
1561
2669
|
const anchor = '\t<key>LSRequiresIPhoneOS</key>'
|
|
1562
2670
|
if (!rendered.includes(anchor)) {
|
|
1563
2671
|
throw new Error(
|
|
1564
|
-
|
|
2672
|
+
`[vxrn] cannot stamp ${additions[0][0]}: expected LSRequiresIPhoneOS in Info.plist`
|
|
1565
2673
|
)
|
|
1566
2674
|
}
|
|
1567
2675
|
rendered = rendered.replace(
|
|
1568
2676
|
anchor,
|
|
1569
|
-
`\t<key
|
|
2677
|
+
`${additions.map(([key, text]) => `\t<key>${key}</key>\n\t<string>${escapeXml(text)}</string>\n`).join('')}${anchor}`
|
|
1570
2678
|
)
|
|
1571
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
|
+
)
|
|
1572
2686
|
rendered = patchIosInfoPlistSceneManifest(rendered)
|
|
1573
2687
|
}
|
|
1574
2688
|
if (platform === 'ios' && relativePath.endsWith('/AppDelegate.swift')) {
|
|
1575
|
-
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
|
+
}
|
|
1576
2716
|
}
|
|
1577
2717
|
if (platform === 'ios' && app.ios?.deploymentTarget) {
|
|
1578
2718
|
rendered = rendered
|
|
@@ -1600,12 +2740,17 @@ end`
|
|
|
1600
2740
|
}
|
|
1601
2741
|
}
|
|
1602
2742
|
if (platform === 'ios' && relativePath.endsWith('.xcodeproj/project.pbxproj')) {
|
|
1603
|
-
rendered = patchIosBundlePhase(rendered)
|
|
2743
|
+
rendered = patchIosBundlePhase(rendered, app.updates?.runtimeVersion)
|
|
1604
2744
|
rendered = patchIosPbxprojSceneDelegate(rendered, appName)
|
|
1605
|
-
if (app.
|
|
1606
|
-
|
|
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)
|
|
1607
2749
|
}
|
|
1608
2750
|
if (app.ios?.widgets) rendered = patchIosPbxprojWidgets(rendered, app)
|
|
2751
|
+
if (iosResourceFiles(app).length) {
|
|
2752
|
+
rendered = patchIosPbxprojResources(rendered, appName, iosResourceFiles(app))
|
|
2753
|
+
}
|
|
1609
2754
|
}
|
|
1610
2755
|
if (platform === 'ios' && relativePath === 'Podfile') {
|
|
1611
2756
|
if (app.ios?.ccache) rendered = `ENV['USE_CCACHE'] ||= '1'\n${rendered}`
|
|
@@ -1619,11 +2764,20 @@ end`
|
|
|
1619
2764
|
rendered = nativeProjectPatches.injectReactNativeScreensGammaIntoPodfile(rendered)
|
|
1620
2765
|
}
|
|
1621
2766
|
rendered = nativeProjectPatches.injectFmtCxx17FixIntoPodfile(rendered)
|
|
2767
|
+
rendered = nativeProjectPatches.injectWebGPUHeaderPathsIntoPodfile(rendered)
|
|
1622
2768
|
rendered = nativeProjectPatches.injectOneSwiftPackagesIntoPodfile(rendered)
|
|
1623
2769
|
if (nitroWebImage)
|
|
1624
2770
|
rendered =
|
|
1625
2771
|
nativeProjectPatches.injectNitroWebImageModularHeaderIntoPodfile(rendered)
|
|
1626
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
|
+
)
|
|
1627
2781
|
if (
|
|
1628
2782
|
!rendered.includes('[vxrn/one] fmt c++17 fix') ||
|
|
1629
2783
|
!rendered.includes('[vxrn/one] minify iOS Hermes Release bundle input')
|
|
@@ -1640,6 +2794,12 @@ end`
|
|
|
1640
2794
|
) {
|
|
1641
2795
|
throw new Error('[vxrn] failed to apply required Android Gradle patches')
|
|
1642
2796
|
}
|
|
2797
|
+
if (app.updates !== undefined) {
|
|
2798
|
+
rendered = patchAndroidBuildGradleUpdatesManifest(
|
|
2799
|
+
rendered,
|
|
2800
|
+
app.updates.runtimeVersion
|
|
2801
|
+
)
|
|
2802
|
+
}
|
|
1643
2803
|
if (app.version !== undefined) {
|
|
1644
2804
|
rendered = rendered.replace(
|
|
1645
2805
|
/versionName "[^"]*"/g,
|
|
@@ -1663,13 +2823,67 @@ end`
|
|
|
1663
2823
|
hardcodedGradlePlugin,
|
|
1664
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)`
|
|
1665
2825
|
)
|
|
1666
|
-
|
|
1667
|
-
|
|
2826
|
+
const autolinkCommand = 'ex.autolinkLibrariesFromCommand()'
|
|
2827
|
+
if (rendered.split(autolinkCommand).length !== 2) {
|
|
2828
|
+
throw new Error('[vxrn] expected one React Native autolinking command')
|
|
2829
|
+
}
|
|
1668
2830
|
rendered = rendered.replace(
|
|
1669
|
-
|
|
1670
|
-
`
|
|
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"])`
|
|
1671
2833
|
)
|
|
1672
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
|
+
}
|
|
1673
2887
|
}
|
|
1674
2888
|
|
|
1675
2889
|
return { destRelativePath, content: rendered }
|
|
@@ -1684,9 +2898,7 @@ export const generateForPlatform = async (
|
|
|
1684
2898
|
validatePrebuildApp(app, platform)
|
|
1685
2899
|
const dest = outDir
|
|
1686
2900
|
const require = module.createRequire(root + '/')
|
|
1687
|
-
const importPath =
|
|
1688
|
-
paths: [root],
|
|
1689
|
-
})
|
|
2901
|
+
const importPath = requireVxrn.resolve('@react-native-community/cli/build/tools/walk.js')
|
|
1690
2902
|
const src = path.join(
|
|
1691
2903
|
path.dirname(
|
|
1692
2904
|
require.resolve('@react-native-community/template/template/package.json', {
|
|
@@ -1740,38 +2952,202 @@ export const generateForPlatform = async (
|
|
|
1740
2952
|
}
|
|
1741
2953
|
|
|
1742
2954
|
await generateAppIcons({ root, dest, platform, app })
|
|
2955
|
+
if (platform === 'android') await generateAdaptiveIcon({ root, dest, app })
|
|
1743
2956
|
await generateSplashScreen({ root, dest, platform, app })
|
|
2957
|
+
if (platform === 'ios') generateAccentColor({ dest, app })
|
|
2958
|
+
copyAppResources({ root, dest, platform, app })
|
|
1744
2959
|
generateSceneDelegate({ dest, platform, app })
|
|
2960
|
+
generateIosAppIntents(dest, platform, app)
|
|
2961
|
+
if (platform === 'ios') generateOneBridgingHeader(dest, app)
|
|
1745
2962
|
if (platform === 'ios') generateIosWidgets(dest, app)
|
|
1746
|
-
if (platform === 'ios') generateSwiftPackages({ root, dest })
|
|
2963
|
+
if (platform === 'ios') await generateSwiftPackages({ root, dest })
|
|
2964
|
+
if (platform === 'android') await generateKotlinSources({ root, dest })
|
|
1747
2965
|
}
|
|
1748
2966
|
|
|
1749
|
-
//
|
|
1750
|
-
//
|
|
1751
|
-
//
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
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))
|
|
1762
2985
|
return
|
|
1763
2986
|
}
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
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])
|
|
1768
2991
|
}
|
|
1769
2992
|
}
|
|
1770
|
-
|
|
2993
|
+
visit(dir)
|
|
2994
|
+
return imports
|
|
2995
|
+
}
|
|
1771
2996
|
|
|
1772
|
-
|
|
1773
|
-
|
|
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)
|
|
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')
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
|
|
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) {
|
|
1774
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
|
+
)
|
|
1775
3151
|
const sources: string[] = []
|
|
1776
3152
|
const collect = (dir: string) => {
|
|
1777
3153
|
for (const entry of FSExtra.readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -1784,6 +3160,12 @@ function generateSwiftPackages({ root, dest }: { root: string; dest: string }) {
|
|
|
1784
3160
|
}
|
|
1785
3161
|
}
|
|
1786
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
|
+
}
|
|
1787
3169
|
// cocoapods globs do not descend into symlinked directories, so mirror the
|
|
1788
3170
|
// tree with real directories and link each file.
|
|
1789
3171
|
for (const source of sources) {
|
|
@@ -1791,6 +3173,12 @@ function generateSwiftPackages({ root, dest }: { root: string; dest: string }) {
|
|
|
1791
3173
|
FSExtra.mkdirSync(path.dirname(link), { recursive: true })
|
|
1792
3174
|
FSExtra.symlinkSync(FSExtra.realpathSync(source), link)
|
|
1793
3175
|
}
|
|
3176
|
+
if (artifacts.glue) {
|
|
3177
|
+
FSExtra.writeFileSync(
|
|
3178
|
+
path.join(podDir, 'Sources', 'OneNativeSource.generated.swift'),
|
|
3179
|
+
artifacts.glue
|
|
3180
|
+
)
|
|
3181
|
+
}
|
|
1794
3182
|
FSExtra.writeFileSync(
|
|
1795
3183
|
path.join(podDir, `${id}.podspec`),
|
|
1796
3184
|
`Pod::Spec.new do |s|
|
|
@@ -1801,18 +3189,18 @@ function generateSwiftPackages({ root, dest }: { root: string; dest: string }) {
|
|
|
1801
3189
|
s.license = 'MIT'
|
|
1802
3190
|
s.author = 'one'
|
|
1803
3191
|
s.source = { :path => '.' }
|
|
1804
|
-
s.swift_version = '
|
|
3192
|
+
s.swift_version = '${manifest.languageMode}.0'
|
|
1805
3193
|
s.source_files = 'Sources/**/*.swift', 'Register.m'
|
|
1806
|
-
s.dependency '
|
|
3194
|
+
s.dependency 'One'
|
|
1807
3195
|
s.pod_target_xcconfig = {
|
|
1808
|
-
'OTHER_SWIFT_FLAGS' => '$(inherited) -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` : ''}',
|
|
1809
3197
|
}
|
|
1810
3198
|
end
|
|
1811
3199
|
`
|
|
1812
3200
|
)
|
|
1813
3201
|
FSExtra.writeFileSync(
|
|
1814
3202
|
path.join(podDir, 'Register.m'),
|
|
1815
|
-
`#import <Foundation/Foundation.h>
|
|
3203
|
+
hasView ? `#import <Foundation/Foundation.h>
|
|
1816
3204
|
|
|
1817
3205
|
extern int ${id}_main(int argc, char **argv);
|
|
1818
3206
|
extern void OneSwiftRegisterPackage(const char *name, int (*entry)(int, char **));
|
|
@@ -1825,7 +3213,7 @@ extern void OneSwiftRegisterPackage(const char *name, int (*entry)(int, char **)
|
|
|
1825
3213
|
OneSwiftRegisterPackage("${id}", ${id}_main);
|
|
1826
3214
|
}
|
|
1827
3215
|
@end
|
|
1828
|
-
`
|
|
3216
|
+
` : '#import <Foundation/Foundation.h>\n'
|
|
1829
3217
|
)
|
|
1830
3218
|
}
|
|
1831
3219
|
}
|
|
@@ -1863,8 +3251,8 @@ export function applyAndroidDependencyPatches(args: {
|
|
|
1863
3251
|
...app.android.applicationId.split('.'),
|
|
1864
3252
|
'MainActivity.kt'
|
|
1865
3253
|
)
|
|
1866
|
-
const rendered = nativeProjectPatches.
|
|
1867
|
-
FSExtra.readFileSync(activityPath, 'utf8')
|
|
3254
|
+
const rendered = nativeProjectPatches.holdLaunchScreenInMainActivity(
|
|
3255
|
+
nativeProjectPatches.addReactNativeScreensFix(FSExtra.readFileSync(activityPath, 'utf8'))
|
|
1868
3256
|
)
|
|
1869
3257
|
if (!rendered.includes('RNScreensFragmentFactory')) {
|
|
1870
3258
|
throw new Error('[vxrn] failed to apply the react-native-screens activity patch')
|
|
@@ -1877,10 +3265,7 @@ export function applyAndroidDependencyPatches(args: {
|
|
|
1877
3265
|
export async function getNativeDependencyInventory(
|
|
1878
3266
|
root: string
|
|
1879
3267
|
): Promise<NativeDependencyInventory[]> {
|
|
1880
|
-
const
|
|
1881
|
-
const cliConfigPath = require.resolve('@react-native-community/cli-config', {
|
|
1882
|
-
paths: [root],
|
|
1883
|
-
})
|
|
3268
|
+
const cliConfigPath = requireVxrn.resolve('@react-native-community/cli-config')
|
|
1884
3269
|
const cliConfig = (await import(pathToFileURL(cliConfigPath).href)) as {
|
|
1885
3270
|
loadConfigAsync: (options: { projectRoot: string }) => Promise<{
|
|
1886
3271
|
dependencies: Record<
|
|
@@ -1921,8 +3306,13 @@ export function installNativeDependencies(args: {
|
|
|
1921
3306
|
}): void {
|
|
1922
3307
|
const { root, platform } = args
|
|
1923
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.
|
|
1924
3313
|
execFileSync('pod', ['install', `--project-directory=${path.join(root, 'ios')}`], {
|
|
1925
3314
|
stdio: 'inherit',
|
|
3315
|
+
env: { ...process.env, LC_ALL: 'en_US.UTF-8', RCT_IGNORE_PODS_DEPRECATION: '1' },
|
|
1926
3316
|
})
|
|
1927
3317
|
}
|
|
1928
3318
|
}
|