peachsim 0.1.656 → 0.1.658
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/README.md +22 -3
- package/cli/app-config.ts +34 -15
- package/cli/app-project.ts +6 -6
- package/cli/bridge-flow-runner.ts +10 -23
- package/cli/commands/compat.ts +234 -10
- package/cli/commands/control.ts +20 -20
- package/cli/commands/do-chain.ts +5 -1
- package/cli/commands/flow.ts +3 -3
- package/cli/commands/inspect/actions.ts +33 -76
- package/cli/commands/inspect/resolve-target.ts +67 -2
- package/cli/commands/inspect.ts +16 -1
- package/cli/drivers/playwright-provisioning.ts +95 -0
- package/cli/ws-bridge.ts +7 -7
- package/detox/config-loader.ts +1 -1
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/bridge-contract-input.cjs +9 -1
- package/dist-lib/bridge-contract-input.mjs +9 -1
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/capture-contract.cjs +1 -1
- package/dist-lib/capture-contract.mjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/cloud-contract.cjs +1 -1
- package/dist-lib/cloud-contract.mjs +1 -1
- package/dist-lib/cloud-test-args.cjs +1 -1
- package/dist-lib/cloud-test-args.mjs +1 -1
- package/dist-lib/cloud.cjs +1 -1
- package/dist-lib/cloud.mjs +1 -1
- package/dist-lib/config.cjs +27 -26
- package/dist-lib/detox/index.cjs +1 -1
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +90 -47
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/replacement-module-handler.cjs +51 -8
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +317 -70
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/kotlin-host.cjs +8 -4
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.mjs +1 -1
- package/dist-lib/metro-fingerprint-registry.cjs +1 -1
- package/dist-lib/metro-fingerprint-registry.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +1 -1
- package/dist-lib/metro-production-bundle.mjs +1 -1
- package/dist-lib/metro.cjs +300 -29
- package/dist-lib/native-app-identity.cjs +58 -25
- package/dist-lib/native-app-identity.mjs +57 -25
- package/dist-lib/plist.cjs +64 -0
- package/dist-lib/plist.mjs +29 -0
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +14 -16
- package/dist-lib/sdk.cjs +36 -49
- package/dist-lib/sdk.mjs +36 -49
- package/dist-lib/skills.cjs +341 -198
- package/dist-lib/swift-compile.cjs +1 -1
- package/dist-lib/swift-compile.mjs +1 -1
- package/dist-lib/swift-package.cjs +1 -1
- package/dist-lib/swift-package.mjs +1 -1
- package/dist-lib/swift-submit.cjs +1 -1
- package/dist-lib/swift-submit.mjs +1 -1
- package/dist-lib/swift.cjs +1 -1
- package/dist-lib/vite-swift.cjs +1 -1
- package/dist-lib/vite.cjs +51 -8
- package/kotlin/AppSources/android/app/UiModeManager.kt +9 -0
- package/kotlin/AppSources/android/content/Context.kt +9 -2
- package/kotlin/AppSources/android/content/res/Configuration.kt +16 -0
- package/kotlin/AppSources/android/content/res/Resources.kt +7 -0
- package/kotlin/AppSources/android/graphics/Color.kt +13 -0
- package/kotlin/AppSources/android/view/View.kt +2 -0
- package/kotlin/AppSources/android/view/Window.kt +99 -8
- package/kotlin/AppSources/android/view/WindowInsetsController.kt +12 -0
- package/kotlin/AppSources/android/view/WindowManager.kt +16 -0
- package/kotlin/AppSources/androidx/activity/EdgeToEdge.kt +71 -0
- package/kotlin/AppSources/androidx/core/view/WindowCompat.kt +32 -0
- package/kotlin/AppSources/dev/peach/app/PeachApp.kt +16 -11
- package/kotlin/AppSources/dev/peach/app/PeachWindowHost.kt +2 -1
- package/kotlin/ComposeSources/PeachComposeHost.kt +11 -1
- package/package.json +8 -1
- package/scripts/dev-server-scanner.ts +3 -3
- package/scripts/optional-demo-registry.ts +1 -1
- package/skills/peach-setup/SKILL.md +27 -0
- package/src/android-theme.ts +167 -0
- package/src/android-xml.ts +70 -0
- package/src/bridge-contract-input.ts +10 -1
- package/src/bridge-contract.ts +7 -1
- package/src/config.ts +34 -26
- package/src/generated/android-framework-themes.json +6303 -0
- package/src/host/replacement-module-handler.ts +33 -0
- package/src/index.ts +1 -0
- package/src/kotlin-app.ts +75 -61
- package/src/kotlin-host.ts +7 -3
- package/src/metro-plugin.ts +46 -3
- package/src/native-app-identity.ts +75 -26
- package/src/sim-client.ts +2 -4
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { statSync } from 'node:fs'
|
|
1
2
|
import fs from 'node:fs/promises'
|
|
2
3
|
import path from 'node:path'
|
|
3
4
|
import {
|
|
@@ -33,6 +34,31 @@ function sendJavaScript(req: IncomingMessage, res: ServerResponse, source: strin
|
|
|
33
34
|
res.end(req.method === 'HEAD' ? undefined : source)
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
// a relative import resolves the way the app's own files do: the exact path
|
|
38
|
+
// when it names an allowed extension, else each extension in turn, else the
|
|
39
|
+
// directory's index file.
|
|
40
|
+
function resolveLocalImport(fromFile: string, specifier: string): string {
|
|
41
|
+
const base = path.resolve(path.dirname(fromFile), specifier)
|
|
42
|
+
const candidates = [
|
|
43
|
+
...(REPLACEMENT_MODULE_EXTENSIONS.some(
|
|
44
|
+
(ext) => ext === path.extname(base).toLowerCase(),
|
|
45
|
+
)
|
|
46
|
+
? [base]
|
|
47
|
+
: []),
|
|
48
|
+
...REPLACEMENT_MODULE_EXTENSIONS.map((ext) => base + ext),
|
|
49
|
+
...REPLACEMENT_MODULE_EXTENSIONS.map((ext) => path.join(base, `index${ext}`)),
|
|
50
|
+
]
|
|
51
|
+
const found = candidates.find((candidate) =>
|
|
52
|
+
statSync(candidate, { throwIfNoEntry: false })?.isFile(),
|
|
53
|
+
)
|
|
54
|
+
if (!found) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`'${specifier}' imported by ${fromFile} matches no ${REPLACEMENT_MODULE_EXTENSIONS.join(', ')} file`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
return found
|
|
60
|
+
}
|
|
61
|
+
|
|
36
62
|
export function isReplacementModuleRequestUrl(rawUrl?: string): boolean {
|
|
37
63
|
if (!rawUrl) return false
|
|
38
64
|
try {
|
|
@@ -140,6 +166,13 @@ export async function handleReplacementModuleRequest(
|
|
|
140
166
|
filePath: resolvedTargetPath,
|
|
141
167
|
source: await fs.readFile(resolvedTargetPath, 'utf8'),
|
|
142
168
|
runtimePackages,
|
|
169
|
+
// the imported file's url is this request's with only its path changed,
|
|
170
|
+
// so the token, cache key and runtime packages carry over.
|
|
171
|
+
localModuleUrl: (specifier) => {
|
|
172
|
+
const local = new URL(url.href)
|
|
173
|
+
local.searchParams.set('path', resolveLocalImport(targetPath, specifier))
|
|
174
|
+
return `${local.pathname}${local.search}`
|
|
175
|
+
},
|
|
143
176
|
})
|
|
144
177
|
// inline the map: the file is the app author's own source, and the
|
|
145
178
|
// simulator page is the only thing that ever loads it.
|
package/src/index.ts
CHANGED
package/src/kotlin-app.ts
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import fs from 'node:fs'
|
|
7
7
|
import path from 'node:path'
|
|
8
|
+
import { readResourceValues, resolveThemeAttribute } from './android-theme'
|
|
9
|
+
import { parseXml, type XmlElement } from './android-xml'
|
|
10
|
+
import frameworkThemes from './generated/android-framework-themes.json' with { type: 'json' }
|
|
8
11
|
|
|
9
12
|
export interface KotlinAppProject {
|
|
10
13
|
projectDir: string
|
|
@@ -14,7 +17,16 @@ export interface KotlinAppProject {
|
|
|
14
17
|
packageName: string
|
|
15
18
|
namespace: string
|
|
16
19
|
targetSdk: number
|
|
17
|
-
|
|
20
|
+
// the launched activity's window as its theme and target SDK configure it;
|
|
21
|
+
// statusBarColor is ARGB
|
|
22
|
+
window: {
|
|
23
|
+
edgeToEdgeEnforced: boolean
|
|
24
|
+
statusBarColor: number
|
|
25
|
+
statusBarContrastEnforced: boolean
|
|
26
|
+
navigationBarContrastEnforced: boolean
|
|
27
|
+
lightStatusBar: boolean
|
|
28
|
+
lightNavigationBar: boolean
|
|
29
|
+
}
|
|
18
30
|
applicationClass: string | null
|
|
19
31
|
launcher: { name: string; targetClass: string }
|
|
20
32
|
}
|
|
@@ -48,44 +60,6 @@ const COMPOSE_PIN: Record<string, keyof ComposeAndroidPins> = {
|
|
|
48
60
|
'androidx.compose.material3': 'material3',
|
|
49
61
|
}
|
|
50
62
|
|
|
51
|
-
interface XmlElement {
|
|
52
|
-
name: string
|
|
53
|
-
attributes: Record<string, string>
|
|
54
|
-
children: XmlElement[]
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// manifest XML: elements and quoted attributes; comments, declarations and
|
|
58
|
-
// text are skipped
|
|
59
|
-
function parseXml(text: string, file: string): XmlElement {
|
|
60
|
-
const root: XmlElement = { name: '#document', attributes: {}, children: [] }
|
|
61
|
-
const stack = [root]
|
|
62
|
-
const body = text.replace(/<!--[\s\S]*?-->/g, '').replace(/<\?[\s\S]*?\?>/g, '')
|
|
63
|
-
for (const match of body.matchAll(
|
|
64
|
-
/<(\/?)([\w:.-]+)((?:\s+[\w:.-]+\s*=\s*"[^"]*")*)\s*(\/?)>/g,
|
|
65
|
-
)) {
|
|
66
|
-
const [, closing, name, rawAttributes, selfClosing] = match
|
|
67
|
-
if (!name) continue
|
|
68
|
-
if (closing) {
|
|
69
|
-
const open = stack.pop()
|
|
70
|
-
if (open?.name !== name)
|
|
71
|
-
throw new Error(`${file}: </${name}> closes <${open?.name}>`)
|
|
72
|
-
continue
|
|
73
|
-
}
|
|
74
|
-
const attributes: Record<string, string> = {}
|
|
75
|
-
for (const [, key, value] of (rawAttributes ?? '').matchAll(
|
|
76
|
-
/([\w:.-]+)\s*=\s*"([^"]*)"/g,
|
|
77
|
-
))
|
|
78
|
-
if (key)
|
|
79
|
-
attributes[key] = (value ?? '').replaceAll('"', '"').replaceAll('&', '&')
|
|
80
|
-
const element: XmlElement = { name, attributes, children: [] }
|
|
81
|
-
stack.at(-1)?.children.push(element)
|
|
82
|
-
if (!selfClosing) stack.push(element)
|
|
83
|
-
}
|
|
84
|
-
if (stack.length !== 1)
|
|
85
|
-
throw new Error(`${file}: <${stack.at(-1)?.name}> is never closed`)
|
|
86
|
-
return root
|
|
87
|
-
}
|
|
88
|
-
|
|
89
63
|
function readGradle(dir: string): { file: string; text: string } | null {
|
|
90
64
|
for (const name of ['build.gradle.kts', 'build.gradle']) {
|
|
91
65
|
const file = path.join(dir, name)
|
|
@@ -189,29 +163,69 @@ function checkModuleGradle(
|
|
|
189
163
|
}
|
|
190
164
|
}
|
|
191
165
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
166
|
+
// Resources.selectDefaultTheme: the theme a context without one gets for its
|
|
167
|
+
// target SDK
|
|
168
|
+
function defaultTheme(targetSdk: number): string {
|
|
169
|
+
if (targetSdk < 11) return '@android:style/Theme'
|
|
170
|
+
if (targetSdk < 14) return '@android:style/Theme.Holo'
|
|
171
|
+
if (targetSdk < 24) return '@android:style/Theme.DeviceDefault'
|
|
172
|
+
return '@android:style/Theme.DeviceDefault.Light.DarkActionBar'
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// PhoneWindow.generateLayout's status bar reads from the activity's theme.
|
|
176
|
+
// enforcement is isEdgeToEdgeEnforced: target 35 and up unless the theme opts
|
|
177
|
+
// out, which only target 35 may. an enforced window's status bar is
|
|
178
|
+
// transparent and setStatusBarColor does nothing. an unset or @null value
|
|
179
|
+
// reads as the default PhoneWindow passes, as TypedArray returns it.
|
|
180
|
+
function readWindowTheme(
|
|
181
|
+
moduleDir: string,
|
|
182
|
+
theme: string | undefined,
|
|
183
|
+
targetSdk: number,
|
|
184
|
+
): KotlinAppProject['window'] {
|
|
195
185
|
const values = path.join(moduleDir, 'src/main/res/values')
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
186
|
+
const app = readResourceValues(
|
|
187
|
+
fs.existsSync(values)
|
|
188
|
+
? fs
|
|
189
|
+
.readdirSync(values)
|
|
190
|
+
.filter((entry) => entry.endsWith('.xml'))
|
|
191
|
+
.map((entry) => {
|
|
192
|
+
const file = path.join(values, entry)
|
|
193
|
+
return { file, text: fs.readFileSync(file, 'utf8') }
|
|
194
|
+
})
|
|
195
|
+
: [],
|
|
196
|
+
)
|
|
197
|
+
const sources = { android: frameworkThemes, app }
|
|
198
|
+
const name = theme ?? defaultTheme(targetSdk)
|
|
199
|
+
const flag = (attribute: string, fallback = false) => {
|
|
200
|
+
const value = resolveThemeAttribute(name, attribute, sources)
|
|
201
|
+
if (value === undefined || value === null) return fallback
|
|
202
|
+
if (typeof value !== 'boolean')
|
|
203
|
+
throw new Error(`${name}: ${attribute} is not a boolean`)
|
|
204
|
+
return value
|
|
205
|
+
}
|
|
206
|
+
const optOut = flag('windowOptOutEdgeToEdgeEnforcement')
|
|
207
|
+
const edgeToEdgeEnforced = targetSdk > 35 || (targetSdk === 35 && !optOut)
|
|
208
|
+
// from target Q the contrast flags read from the theme, navigation on by
|
|
209
|
+
// default; before it both stay off
|
|
210
|
+
const targetQ = targetSdk >= 29
|
|
211
|
+
const window = {
|
|
212
|
+
edgeToEdgeEnforced,
|
|
213
|
+
statusBarContrastEnforced: targetQ && flag('enforceStatusBarContrast'),
|
|
214
|
+
navigationBarContrastEnforced: targetQ && flag('enforceNavigationBarContrast', true),
|
|
215
|
+
lightStatusBar: flag('windowLightStatusBar'),
|
|
216
|
+
lightNavigationBar: flag('windowLightNavigationBar'),
|
|
213
217
|
}
|
|
214
|
-
|
|
218
|
+
// PhoneWindow enforces edge to edge apart from whether the theme draws bar
|
|
219
|
+
// backgrounds: either way the bar is transparent
|
|
220
|
+
if (edgeToEdgeEnforced) return { ...window, statusBarColor: 0 }
|
|
221
|
+
if (!flag('windowDrawsSystemBarBackgrounds'))
|
|
222
|
+
throw new Error(
|
|
223
|
+
`${name} does not draw system bar backgrounds; peach kotlin draws only theme status bar colors`,
|
|
224
|
+
)
|
|
225
|
+
const color = resolveThemeAttribute(name, 'statusBarColor', sources)
|
|
226
|
+
if (typeof color === 'boolean')
|
|
227
|
+
throw new Error(`${name}: statusBarColor is not a color`)
|
|
228
|
+
return { ...window, statusBarColor: color ?? 0xff000000 }
|
|
215
229
|
}
|
|
216
230
|
|
|
217
231
|
function enabled(element: XmlElement): boolean {
|
|
@@ -404,7 +418,7 @@ export function readKotlinApp(
|
|
|
404
418
|
packageName: applicationId ?? base,
|
|
405
419
|
namespace: base,
|
|
406
420
|
targetSdk,
|
|
407
|
-
|
|
421
|
+
window: readWindowTheme(moduleDir, theme, targetSdk),
|
|
408
422
|
applicationClass: applicationName ? className(applicationName) : null,
|
|
409
423
|
launcher: { name: launcherName, targetClass },
|
|
410
424
|
}
|
package/src/kotlin-host.ts
CHANGED
|
@@ -337,16 +337,20 @@ export async function kotlinAppArtifact(
|
|
|
337
337
|
'',
|
|
338
338
|
'@JsExport',
|
|
339
339
|
'fun peachAppMount(density: Float, fontScale: Float, locale: String, backend: String, stencilBits: Int,',
|
|
340
|
-
' regular: String, medium: String, bold: String, statusBar: Int, navigationBar: Int,',
|
|
340
|
+
' regular: String, medium: String, bold: String, statusBar: Int, navigationBar: Int, night: Boolean,',
|
|
341
341
|
' invalidate: (Boolean) -> Unit): String = dev.peach.app.peachStartApp(',
|
|
342
342
|
' dev.peach.app.PeachAppSpec(',
|
|
343
343
|
` android.content.ComponentName(${kotlinString(app.packageName)}, ${kotlinString(app.launcher.name)}),`,
|
|
344
344
|
` android.content.ComponentName(${kotlinString(app.packageName)}, ${kotlinString(app.launcher.targetClass)}),`,
|
|
345
|
-
|
|
345
|
+
' android.view.WindowTheme(',
|
|
346
|
+
` ${app.window.edgeToEdgeEnforced}, 0x${app.window.statusBarColor.toString(16).padStart(8, '0')}.toInt(),`,
|
|
347
|
+
` ${app.window.statusBarContrastEnforced}, ${app.window.navigationBarContrastEnforced},`,
|
|
348
|
+
` ${app.window.lightStatusBar}, ${app.window.lightNavigationBar},`,
|
|
349
|
+
' ),',
|
|
346
350
|
` { ${app.applicationClass ?? 'android.app.Application'}() },`,
|
|
347
351
|
` { ${app.launcher.targetClass}() },`,
|
|
348
352
|
' ),',
|
|
349
|
-
' density, fontScale, locale, backend, stencilBits, regular, medium, bold, statusBar, navigationBar, invalidate,',
|
|
353
|
+
' density, fontScale, locale, backend, stencilBits, regular, medium, bold, statusBar, navigationBar, night, invalidate,',
|
|
350
354
|
')',
|
|
351
355
|
'',
|
|
352
356
|
].join('\n'),
|
package/src/metro-plugin.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// peach metro plugin for dev shell and bundle
|
|
1
|
+
// peach metro plugin for dev shell and bundle identity metadata.
|
|
2
2
|
// shell assets come from an engine runtime under ~/.peach/runtimes/, fetched
|
|
3
3
|
// by `peach runtime install`. ordinary localhost requests
|
|
4
4
|
// follow active; a version-owned localhost origin stays on its exact directory
|
|
@@ -24,7 +24,13 @@ import {
|
|
|
24
24
|
toRNXProductionBundleUrl,
|
|
25
25
|
type RNXMetroModuleIdentityMetadata,
|
|
26
26
|
} from './metro-production-bundle'
|
|
27
|
+
import {
|
|
28
|
+
canExtractNativeAppIdentity,
|
|
29
|
+
extractNativeAppIdentityFromFiles,
|
|
30
|
+
insertNativeAppIdentityFooter,
|
|
31
|
+
} from './native-app-identity'
|
|
27
32
|
import { readNativeFirebaseConfig } from './native-firebase-config'
|
|
33
|
+
import { parsePlistSource } from './plist'
|
|
28
34
|
import {
|
|
29
35
|
isRootRuntimeAssetPath,
|
|
30
36
|
resolveRuntimeFilePath,
|
|
@@ -49,6 +55,7 @@ type MetroSerializerModule = {
|
|
|
49
55
|
|
|
50
56
|
type MetroSerializerGraph = {
|
|
51
57
|
dependencies?: ReadonlyMap<unknown, MetroSerializerModule>
|
|
58
|
+
transformOptions?: { platform?: string | null }
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
type MetroSerializerOptions = {
|
|
@@ -122,7 +129,7 @@ function createMetroDefaultSerializer(projectRoot: string): MetroSerializer {
|
|
|
122
129
|
}
|
|
123
130
|
}
|
|
124
131
|
|
|
125
|
-
//
|
|
132
|
+
// module identity accompanies an ordinary Metro bundle. production needs the
|
|
126
133
|
// footer because Metro drops the fourth `__d` argument when `dev` is false;
|
|
127
134
|
// development keeps that verbose name and receives the same identity without
|
|
128
135
|
// changing resolution, transforms, or the trailing source map.
|
|
@@ -215,7 +222,43 @@ export function withRNX<T extends Record<string, unknown>>(config: T): T {
|
|
|
215
222
|
existingSerializer ||
|
|
216
223
|
(defaultSerializer ||= createMetroDefaultSerializer(cfgProjectRoot))
|
|
217
224
|
const result = await serializer(entryPoint, preModules, graph, serializerOptions)
|
|
218
|
-
|
|
225
|
+
let annotated = appendRNXModulePaths(
|
|
226
|
+
result,
|
|
227
|
+
graph,
|
|
228
|
+
serializerOptions,
|
|
229
|
+
cfgProjectRoot,
|
|
230
|
+
)
|
|
231
|
+
const iosRoot = path.join(cfgProjectRoot, 'ios')
|
|
232
|
+
const bundlePlatform = graph.transformOptions?.platform
|
|
233
|
+
if (canExtractNativeAppIdentity(bundlePlatform) && fs.existsSync(iosRoot)) {
|
|
234
|
+
const files: Record<string, string> = {}
|
|
235
|
+
for (const entry of fs.readdirSync(iosRoot, { withFileTypes: true })) {
|
|
236
|
+
if (!entry.isDirectory() || entry.name === 'Pods') continue
|
|
237
|
+
const names = entry.name.endsWith('.xcodeproj')
|
|
238
|
+
? ['project.pbxproj']
|
|
239
|
+
: ['Info.plist', 'AppDelegate.swift', 'AppDelegate.mm', 'AppDelegate.m']
|
|
240
|
+
for (const name of names) {
|
|
241
|
+
const relative = `ios/${entry.name}/${name}`
|
|
242
|
+
const file = path.join(cfgProjectRoot, relative)
|
|
243
|
+
if (fs.existsSync(file)) files[relative] = fs.readFileSync(file, 'utf8')
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const identity = extractNativeAppIdentityFromFiles(
|
|
247
|
+
files,
|
|
248
|
+
bundlePlatform,
|
|
249
|
+
parsePlistSource,
|
|
250
|
+
)
|
|
251
|
+
if (identity) {
|
|
252
|
+
annotated =
|
|
253
|
+
typeof annotated === 'string'
|
|
254
|
+
? insertNativeAppIdentityFooter(annotated, identity)
|
|
255
|
+
: {
|
|
256
|
+
...annotated,
|
|
257
|
+
code: insertNativeAppIdentityFooter(annotated.code, identity),
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return annotated
|
|
219
262
|
},
|
|
220
263
|
},
|
|
221
264
|
}
|
|
@@ -17,6 +17,7 @@ export interface NativeAppIdentity {
|
|
|
17
17
|
buildVersion: string
|
|
18
18
|
bundleIdentifier: string
|
|
19
19
|
platform: NativeAppIdentityPlatform
|
|
20
|
+
permissionUsageDescriptions?: Readonly<Record<string, unknown>>
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export type NativeAppIdentityFiles = Record<string, string>
|
|
@@ -39,6 +40,8 @@ function nonEmptyString(value: unknown): string | null {
|
|
|
39
40
|
|
|
40
41
|
export function isNativeAppIdentity(value: unknown): value is NativeAppIdentity {
|
|
41
42
|
if (!isRecord(value)) return false
|
|
43
|
+
const descriptions = value.permissionUsageDescriptions
|
|
44
|
+
if (descriptions !== undefined && !isRecord(descriptions)) return false
|
|
42
45
|
const platform = value.platform
|
|
43
46
|
if (platform !== 'ios' && platform !== 'android') return false
|
|
44
47
|
const applicationName = value.applicationName
|
|
@@ -64,6 +67,11 @@ export function readNativeAppIdentity(value: unknown): NativeAppIdentity | null
|
|
|
64
67
|
buildVersion: value.buildVersion,
|
|
65
68
|
bundleIdentifier: value.bundleIdentifier,
|
|
66
69
|
platform: value.platform,
|
|
70
|
+
...(value.permissionUsageDescriptions === undefined
|
|
71
|
+
? {}
|
|
72
|
+
: {
|
|
73
|
+
permissionUsageDescriptions: { ...value.permissionUsageDescriptions },
|
|
74
|
+
}),
|
|
67
75
|
}
|
|
68
76
|
}
|
|
69
77
|
|
|
@@ -230,7 +238,7 @@ function readBuildSettingsBlock(source: string, start: number): string {
|
|
|
230
238
|
|
|
231
239
|
function parseBuildSettings(block: string): Record<string, string> {
|
|
232
240
|
const settings: Record<string, string> = {}
|
|
233
|
-
const matchPattern = /([A-
|
|
241
|
+
const matchPattern = /([A-Za-z0-9_]+) = ("(?:[^"\\]|\\.)*"|[^;{}]*);/g
|
|
234
242
|
let match: RegExpExecArray | null
|
|
235
243
|
while ((match = matchPattern.exec(block)) !== null) {
|
|
236
244
|
const raw = match[2].trim()
|
|
@@ -287,31 +295,39 @@ function appTargetReleaseSettings(
|
|
|
287
295
|
|
|
288
296
|
function nativeAppIdentityFromIos(
|
|
289
297
|
files: NativeAppIdentityFiles,
|
|
298
|
+
parseInfoPlist: (source: string) => unknown,
|
|
290
299
|
): NativeAppIdentity | null {
|
|
300
|
+
const pbxprojPath = Object.keys(files).find((path) =>
|
|
301
|
+
/(?:^|\/)ios\/[^/]+\.xcodeproj\/project\.pbxproj$/.test(path),
|
|
302
|
+
)
|
|
303
|
+
if (!pbxprojPath) return null
|
|
304
|
+
const pbxproj = files[pbxprojPath]
|
|
305
|
+
// the installed app is the target Xcode builds as an application. an App
|
|
306
|
+
// Clip or extension carries its own AppDelegate and Info.plist, so a target
|
|
307
|
+
// directory alone cannot say which binary is the app.
|
|
308
|
+
const applicationTargets = new Set(
|
|
309
|
+
[
|
|
310
|
+
...pbxproj.matchAll(
|
|
311
|
+
/isa = PBXNativeTarget;[^}]*?name = "?([^";]+)"?;[^}]*?productType = "com\.apple\.product-type\.application";/g,
|
|
312
|
+
),
|
|
313
|
+
].map((match) => match[1]),
|
|
314
|
+
)
|
|
291
315
|
const appDelegatePattern = /(?:^|\/)ios\/([^/]+)\/AppDelegate\.(?:swift|mm|m)$/
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
targetName = match[1]
|
|
298
|
-
break
|
|
299
|
-
}
|
|
300
|
-
}
|
|
316
|
+
const targetName =
|
|
317
|
+
Object.keys(files)
|
|
318
|
+
.filter((path) => !path.includes('/Pods/'))
|
|
319
|
+
.map((path) => path.match(appDelegatePattern)?.[1])
|
|
320
|
+
.find((name) => name !== undefined && applicationTargets.has(name)) ?? null
|
|
301
321
|
if (!targetName) return null
|
|
302
322
|
|
|
303
|
-
const
|
|
304
|
-
(path) => path === `ios/${targetName}/Info.plist`,
|
|
305
|
-
)
|
|
306
|
-
const plist = plistPath ? files[plistPath] : null
|
|
323
|
+
const plist = files[`ios/${targetName}/Info.plist`]
|
|
307
324
|
if (!plist) return null
|
|
308
325
|
|
|
309
|
-
const
|
|
310
|
-
|
|
326
|
+
const settings = appTargetReleaseSettings(
|
|
327
|
+
pbxproj,
|
|
328
|
+
targetName,
|
|
329
|
+
`${targetName}/Info.plist`,
|
|
311
330
|
)
|
|
312
|
-
const settings = pbxprojPath
|
|
313
|
-
? appTargetReleaseSettings(files[pbxprojPath], targetName, `${targetName}/Info.plist`)
|
|
314
|
-
: null
|
|
315
331
|
if (!settings) return null
|
|
316
332
|
|
|
317
333
|
const bundleIdentifier = identityFieldFrom(
|
|
@@ -344,22 +360,54 @@ function nativeAppIdentityFromIos(
|
|
|
344
360
|
resolvedIdentityField(targetName)
|
|
345
361
|
if (!applicationName) return null
|
|
346
362
|
|
|
363
|
+
// only native privacy purpose strings travel here, not an invented Bundle
|
|
364
|
+
// dictionary. parse XML with the build-side parser so entities and empty
|
|
365
|
+
// strings survive; the browser identity transport has no XML dependency.
|
|
366
|
+
const dictionary = parseInfoPlist(plist)
|
|
367
|
+
if (!isRecord(dictionary)) throw new Error('native app Info.plist is not a dictionary')
|
|
368
|
+
const permissionUsageDescriptions: Record<string, unknown> = {}
|
|
369
|
+
for (const [key, value] of Object.entries(dictionary)) {
|
|
370
|
+
if (!/^NS.*UsageDescription$/.test(key)) continue
|
|
371
|
+
// retain non-string plist values: the native factory checks key presence.
|
|
372
|
+
if (typeof value !== 'string') {
|
|
373
|
+
permissionUsageDescriptions[key] = value
|
|
374
|
+
continue
|
|
375
|
+
}
|
|
376
|
+
const resolved = resolveBuildSetting(value, settings)
|
|
377
|
+
if (resolved === null) throw new Error(`native app Info.plist ${key} is unresolved`)
|
|
378
|
+
permissionUsageDescriptions[key] = resolved
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Xcode's generated Info.plist uses these build settings only when enabled.
|
|
382
|
+
// source: developer.apple.com/documentation/xcode/build-settings-reference.
|
|
383
|
+
if (settings.GENERATE_INFOPLIST_FILE === 'YES') {
|
|
384
|
+
for (const [setting, value] of Object.entries(settings)) {
|
|
385
|
+
if (!/^INFOPLIST_KEY_NS.*UsageDescription$/.test(setting)) continue
|
|
386
|
+
const key = setting.slice('INFOPLIST_KEY_'.length)
|
|
387
|
+
const resolved = resolveBuildSetting(value, settings)
|
|
388
|
+
if (resolved === null)
|
|
389
|
+
throw new Error(`native app build setting ${setting} is unresolved`)
|
|
390
|
+
permissionUsageDescriptions[key] = resolved
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
347
394
|
return {
|
|
348
395
|
applicationName,
|
|
349
396
|
appVersion,
|
|
350
397
|
buildVersion,
|
|
351
398
|
bundleIdentifier,
|
|
352
399
|
platform: 'ios',
|
|
400
|
+
permissionUsageDescriptions,
|
|
353
401
|
}
|
|
354
402
|
}
|
|
355
403
|
|
|
356
|
-
//
|
|
404
|
+
// android's shipped identity requires resolving the selected flavor and
|
|
357
405
|
// buildType overrides (`productFlavors.prod`, `buildTypes.release`), which a
|
|
358
406
|
// text reader of `defaultConfig` cannot distinguish from the defaults it
|
|
359
407
|
// overrides. an Android project therefore ships no footer until that
|
|
360
|
-
// resolution exists
|
|
361
|
-
function
|
|
362
|
-
return
|
|
408
|
+
// resolution exists. unsupported or absent bundle platforms ship no footer.
|
|
409
|
+
export function canExtractNativeAppIdentity(platform: unknown): platform is 'ios' {
|
|
410
|
+
return platform === 'ios'
|
|
363
411
|
}
|
|
364
412
|
|
|
365
413
|
// read the identity a bundle builder transports. files are project-relative
|
|
@@ -369,8 +417,9 @@ function nativeAppIdentityFromAndroid(): NativeAppIdentity | null {
|
|
|
369
417
|
export function extractNativeAppIdentityFromFiles(
|
|
370
418
|
files: NativeAppIdentityFiles,
|
|
371
419
|
platform: NativeAppIdentityPlatform,
|
|
420
|
+
parseInfoPlist: (source: string) => unknown,
|
|
372
421
|
): NativeAppIdentity | null {
|
|
373
|
-
return platform
|
|
374
|
-
?
|
|
375
|
-
:
|
|
422
|
+
return canExtractNativeAppIdentity(platform)
|
|
423
|
+
? nativeAppIdentityFromIos(files, parseInfoPlist)
|
|
424
|
+
: null
|
|
376
425
|
}
|
package/src/sim-client.ts
CHANGED
|
@@ -58,7 +58,7 @@ import type {
|
|
|
58
58
|
ResetResult,
|
|
59
59
|
SimSemanticNode,
|
|
60
60
|
SimSemanticResolveResult,
|
|
61
|
-
|
|
61
|
+
SimSemanticTargetSelector,
|
|
62
62
|
SimStateOptions,
|
|
63
63
|
SimStateResult,
|
|
64
64
|
WsBridgeCommand,
|
|
@@ -400,9 +400,7 @@ export class SimClient implements WsBridge, InspectBridge {
|
|
|
400
400
|
return Array.isArray(tree) ? tree : []
|
|
401
401
|
}
|
|
402
402
|
|
|
403
|
-
resolve(
|
|
404
|
-
selector: SimSemanticResolveSelector,
|
|
405
|
-
): Promise<SimSemanticResolveResult | null> {
|
|
403
|
+
resolve(selector: SimSemanticTargetSelector): Promise<SimSemanticResolveResult | null> {
|
|
406
404
|
return this.send({ type: 'resolve', selector })
|
|
407
405
|
}
|
|
408
406
|
|