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.
Files changed (111) hide show
  1. package/README.md +22 -3
  2. package/cli/app-config.ts +34 -15
  3. package/cli/app-project.ts +6 -6
  4. package/cli/bridge-flow-runner.ts +10 -23
  5. package/cli/commands/compat.ts +234 -10
  6. package/cli/commands/control.ts +20 -20
  7. package/cli/commands/do-chain.ts +5 -1
  8. package/cli/commands/flow.ts +3 -3
  9. package/cli/commands/inspect/actions.ts +33 -76
  10. package/cli/commands/inspect/resolve-target.ts +67 -2
  11. package/cli/commands/inspect.ts +16 -1
  12. package/cli/drivers/playwright-provisioning.ts +95 -0
  13. package/cli/ws-bridge.ts +7 -7
  14. package/detox/config-loader.ts +1 -1
  15. package/dist-lib/agent-daemon-client.cjs +1 -1
  16. package/dist-lib/agent-events.cjs +1 -1
  17. package/dist-lib/agent-identity.cjs +1 -1
  18. package/dist-lib/agent-sessions.cjs +1 -1
  19. package/dist-lib/attached-projects.cjs +1 -1
  20. package/dist-lib/auth/shared-session.cjs +1 -1
  21. package/dist-lib/backend-origin.cjs +1 -1
  22. package/dist-lib/beta.cjs +1 -1
  23. package/dist-lib/beta.mjs +1 -1
  24. package/dist-lib/bridge-constants.cjs +1 -1
  25. package/dist-lib/bridge-contract-input.cjs +9 -1
  26. package/dist-lib/bridge-contract-input.mjs +9 -1
  27. package/dist-lib/bridge-contract.cjs +1 -1
  28. package/dist-lib/bridge-contract.mjs +1 -1
  29. package/dist-lib/capture-contract.cjs +1 -1
  30. package/dist-lib/capture-contract.mjs +1 -1
  31. package/dist-lib/cli-constants.cjs +1 -1
  32. package/dist-lib/cloud-contract.cjs +1 -1
  33. package/dist-lib/cloud-contract.mjs +1 -1
  34. package/dist-lib/cloud-test-args.cjs +1 -1
  35. package/dist-lib/cloud-test-args.mjs +1 -1
  36. package/dist-lib/cloud.cjs +1 -1
  37. package/dist-lib/cloud.mjs +1 -1
  38. package/dist-lib/config.cjs +27 -26
  39. package/dist-lib/detox/index.cjs +1 -1
  40. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  41. package/dist-lib/home-paths.cjs +1 -1
  42. package/dist-lib/host/bridge-host.cjs +90 -47
  43. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  44. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  45. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  46. package/dist-lib/host/replacement-module-handler.cjs +51 -8
  47. package/dist-lib/host/websocket-proxy.cjs +1 -1
  48. package/dist-lib/index.cjs +317 -70
  49. package/dist-lib/jump-to-source-babel.cjs +1 -1
  50. package/dist-lib/jump-to-source-native.cjs +1 -1
  51. package/dist-lib/kotlin-host.cjs +8 -4
  52. package/dist-lib/menu.cjs +1 -1
  53. package/dist-lib/menu.mjs +1 -1
  54. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  55. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  56. package/dist-lib/metro-production-bundle.cjs +1 -1
  57. package/dist-lib/metro-production-bundle.mjs +1 -1
  58. package/dist-lib/metro.cjs +300 -29
  59. package/dist-lib/native-app-identity.cjs +58 -25
  60. package/dist-lib/native-app-identity.mjs +57 -25
  61. package/dist-lib/plist.cjs +64 -0
  62. package/dist-lib/plist.mjs +29 -0
  63. package/dist-lib/profiles.cjs +1 -1
  64. package/dist-lib/public-brand.cjs +1 -1
  65. package/dist-lib/react-native-host-modules.cjs +1 -1
  66. package/dist-lib/react-native-host-modules.mjs +1 -1
  67. package/dist-lib/render-mode.cjs +1 -1
  68. package/dist-lib/scripts/dev-server-scanner.cjs +14 -16
  69. package/dist-lib/sdk.cjs +36 -49
  70. package/dist-lib/sdk.mjs +36 -49
  71. package/dist-lib/skills.cjs +341 -198
  72. package/dist-lib/swift-compile.cjs +1 -1
  73. package/dist-lib/swift-compile.mjs +1 -1
  74. package/dist-lib/swift-package.cjs +1 -1
  75. package/dist-lib/swift-package.mjs +1 -1
  76. package/dist-lib/swift-submit.cjs +1 -1
  77. package/dist-lib/swift-submit.mjs +1 -1
  78. package/dist-lib/swift.cjs +1 -1
  79. package/dist-lib/vite-swift.cjs +1 -1
  80. package/dist-lib/vite.cjs +51 -8
  81. package/kotlin/AppSources/android/app/UiModeManager.kt +9 -0
  82. package/kotlin/AppSources/android/content/Context.kt +9 -2
  83. package/kotlin/AppSources/android/content/res/Configuration.kt +16 -0
  84. package/kotlin/AppSources/android/content/res/Resources.kt +7 -0
  85. package/kotlin/AppSources/android/graphics/Color.kt +13 -0
  86. package/kotlin/AppSources/android/view/View.kt +2 -0
  87. package/kotlin/AppSources/android/view/Window.kt +99 -8
  88. package/kotlin/AppSources/android/view/WindowInsetsController.kt +12 -0
  89. package/kotlin/AppSources/android/view/WindowManager.kt +16 -0
  90. package/kotlin/AppSources/androidx/activity/EdgeToEdge.kt +71 -0
  91. package/kotlin/AppSources/androidx/core/view/WindowCompat.kt +32 -0
  92. package/kotlin/AppSources/dev/peach/app/PeachApp.kt +16 -11
  93. package/kotlin/AppSources/dev/peach/app/PeachWindowHost.kt +2 -1
  94. package/kotlin/ComposeSources/PeachComposeHost.kt +11 -1
  95. package/package.json +8 -1
  96. package/scripts/dev-server-scanner.ts +3 -3
  97. package/scripts/optional-demo-registry.ts +1 -1
  98. package/skills/peach-setup/SKILL.md +27 -0
  99. package/src/android-theme.ts +167 -0
  100. package/src/android-xml.ts +70 -0
  101. package/src/bridge-contract-input.ts +10 -1
  102. package/src/bridge-contract.ts +7 -1
  103. package/src/config.ts +34 -26
  104. package/src/generated/android-framework-themes.json +6303 -0
  105. package/src/host/replacement-module-handler.ts +33 -0
  106. package/src/index.ts +1 -0
  107. package/src/kotlin-app.ts +75 -61
  108. package/src/kotlin-host.ts +7 -3
  109. package/src/metro-plugin.ts +46 -3
  110. package/src/native-app-identity.ts +75 -26
  111. 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
@@ -33,6 +33,7 @@ export type {
33
33
  SimSemanticQueryResult,
34
34
  SimSemanticResolveResult,
35
35
  SimSemanticResolveSelector,
36
+ SimSemanticTargetSelector,
36
37
  SimSemanticSelector,
37
38
  } from './bridge-contract'
38
39
  export {
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
- optOutEdgeToEdge: boolean
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('&quot;', '"').replaceAll('&amp;', '&')
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
- function themeOptsOut(moduleDir: string, theme: string | undefined): boolean {
193
- const name = theme?.startsWith('@style/') ? theme.slice('@style/'.length) : null
194
- if (!name) return false
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
- if (!fs.existsSync(values)) throw new Error(`${theme} names no style in ${values}`)
197
- for (const file of fs.readdirSync(values).filter((entry) => entry.endsWith('.xml'))) {
198
- const full = path.join(values, file)
199
- const resources = parseXml(fs.readFileSync(full, 'utf8'), full)
200
- for (const style of resources.children.flatMap((element) => element.children)) {
201
- if (style.name !== 'style' || style.attributes.name !== name) continue
202
- if (style.attributes.parent?.startsWith('@style/'))
203
- throw new Error(
204
- `${full}: style ${name} inherits an app style; peach kotlin reads one level`,
205
- )
206
- const items = fs
207
- .readFileSync(full, 'utf8')
208
- .match(new RegExp(`<style\\b[^>]*name="${name}"[\\s\\S]*?</style>`))?.[0]
209
- return /<item\s+name="android:windowOptOutEdgeToEdgeEnforcement"\s*>\s*true\s*</.test(
210
- items ?? '',
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
- throw new Error(`${theme} names no style in ${values}`)
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
- optOutEdgeToEdge: themeOptsOut(moduleDir, theme),
421
+ window: readWindowTheme(moduleDir, theme, targetSdk),
408
422
  applicationClass: applicationName ? className(applicationName) : null,
409
423
  launcher: { name: launcherName, targetClass },
410
424
  }
@@ -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
- ` ${app.targetSdk}, ${app.optOutEdgeToEdge},`,
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'),
@@ -1,4 +1,4 @@
1
- // peach metro plugin for dev shell and bundle module identity annotation.
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
- // the only thing peach adds to an ordinary Metro bundle. production needs the
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
- return appendRNXModulePaths(result, graph, serializerOptions, cfgProjectRoot)
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-Z0-9_]+) = ("(?:[^"\\]|\\.)*"|[^;{}]*);/g
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
- let targetName: string | null = null
293
- for (const path of Object.keys(files)) {
294
- if (path.includes('/Pods/')) continue
295
- const match = path.match(appDelegatePattern)
296
- if (match) {
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 plistPath = Object.keys(files).find(
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 pbxprojPath = Object.keys(files).find((path) =>
310
- /(?:^|\/)ios\/[^/]+\.xcodeproj\/project\.pbxproj$/.test(path),
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
- // Android's shipped identity requires resolving the selected flavor and
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 — never a guessed identity.
361
- function nativeAppIdentityFromAndroid(): NativeAppIdentity | null {
362
- return null
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 === 'android'
374
- ? nativeAppIdentityFromAndroid()
375
- : nativeAppIdentityFromIos(files)
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
- SimSemanticResolveSelector,
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