peachsim 0.1.629 → 0.1.631

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 (128) hide show
  1. package/README.md +8 -4
  2. package/cli/app-fonts.ts +23 -4
  3. package/cli/bridge-flow-runner.ts +45 -31
  4. package/cli/cli-update.ts +2 -7
  5. package/cli/cloud-test-tunnel.ts +16 -3
  6. package/cli/command-registry.ts +1 -0
  7. package/cli/commands/compat.ts +11 -60
  8. package/cli/commands/config.ts +1 -1
  9. package/cli/commands/control.ts +40 -8
  10. package/cli/commands/daemon-mac-app.ts +4 -2
  11. package/cli/commands/daemon.ts +4 -4
  12. package/cli/commands/detect.ts +289 -26
  13. package/cli/commands/device.ts +2 -3
  14. package/cli/commands/inspect/actions.ts +125 -90
  15. package/cli/commands/inspect/core.ts +1 -1
  16. package/cli/commands/inspect.ts +10 -1
  17. package/cli/commands/maestro.ts +3 -1
  18. package/cli/commands/platform.ts +1 -1
  19. package/cli/commands/project-report.ts +386 -0
  20. package/cli/commands/projectReportContract.ts +42 -0
  21. package/cli/commands/report-issue.ts +38 -54
  22. package/cli/commands/screenshot-layers.ts +1 -1
  23. package/cli/commands/screenshot.ts +1 -1
  24. package/cli/commands/setup.ts +6 -4
  25. package/cli/commands/skills.ts +0 -31
  26. package/cli/commands/telemetry.ts +8 -3
  27. package/cli/commands/test.ts +347 -134
  28. package/cli/drivers/playwright-provisioning.ts +3 -2
  29. package/cli/drivers/playwright-sim-host.ts +25 -9
  30. package/cli/main.ts +19 -3
  31. package/cli/outbound-endpoints.ts +2 -5
  32. package/cli/privacy.ts +1 -2
  33. package/cli/runtime-diagnostics.ts +51 -0
  34. package/cli/telemetry.ts +78 -7
  35. package/detox/index.ts +77 -51
  36. package/dist-lib/agent-daemon-client.cjs +1 -1
  37. package/dist-lib/agent-events.cjs +1 -1
  38. package/dist-lib/agent-identity.cjs +1 -1
  39. package/dist-lib/agent-sessions.cjs +1 -1
  40. package/dist-lib/attached-projects.cjs +1 -1
  41. package/dist-lib/auth/shared-session.cjs +1 -1
  42. package/dist-lib/backend-origin.cjs +1 -1
  43. package/dist-lib/beta.cjs +1 -1
  44. package/dist-lib/beta.mjs +1 -1
  45. package/dist-lib/bridge-constants.cjs +1 -1
  46. package/dist-lib/bridge-contract-input.cjs +1 -1
  47. package/dist-lib/bridge-contract-input.mjs +1 -1
  48. package/dist-lib/bridge-contract.cjs +1 -1
  49. package/dist-lib/bridge-contract.mjs +1 -1
  50. package/dist-lib/capture-contract.cjs +1 -1
  51. package/dist-lib/capture-contract.mjs +1 -1
  52. package/dist-lib/cli-constants.cjs +1 -1
  53. package/dist-lib/cloud-contract.cjs +1 -1
  54. package/dist-lib/cloud-contract.mjs +1 -1
  55. package/dist-lib/cloud-test-args.cjs +1 -1
  56. package/dist-lib/cloud-test-args.mjs +1 -1
  57. package/dist-lib/cloud.cjs +1 -1
  58. package/dist-lib/cloud.mjs +1 -1
  59. package/dist-lib/config.cjs +1 -1
  60. package/dist-lib/detox/index.cjs +130 -33
  61. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  62. package/dist-lib/home-paths.cjs +8 -5
  63. package/dist-lib/host/bridge-host.cjs +353 -290
  64. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  65. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  66. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  67. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  68. package/dist-lib/host/websocket-proxy.cjs +1 -1
  69. package/dist-lib/index.cjs +257 -121
  70. package/dist-lib/jump-to-source-babel.cjs +1 -1
  71. package/dist-lib/jump-to-source-native.cjs +1 -1
  72. package/dist-lib/{vite-kotlin.cjs → kotlin-host.cjs} +130 -19
  73. package/dist-lib/menu.cjs +11 -1
  74. package/dist-lib/menu.mjs +11 -1
  75. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  76. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  77. package/dist-lib/metro-production-bundle.cjs +1 -1
  78. package/dist-lib/metro-production-bundle.mjs +1 -1
  79. package/dist-lib/metro.cjs +132 -9
  80. package/dist-lib/native-app-identity.cjs +278 -0
  81. package/dist-lib/native-app-identity.mjs +245 -0
  82. package/dist-lib/profiles.cjs +1 -1
  83. package/dist-lib/public-brand.cjs +1 -1
  84. package/dist-lib/react-native-host-modules.cjs +1 -1
  85. package/dist-lib/react-native-host-modules.mjs +1 -1
  86. package/dist-lib/render-mode.cjs +1 -1
  87. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  88. package/dist-lib/sdk.cjs +112 -87
  89. package/dist-lib/sdk.mjs +111 -87
  90. package/dist-lib/skills.cjs +1270 -709
  91. package/dist-lib/swift-compile.cjs +1 -1
  92. package/dist-lib/swift-compile.mjs +1 -1
  93. package/dist-lib/swift-package.cjs +1 -1
  94. package/dist-lib/swift-package.mjs +1 -1
  95. package/dist-lib/swift-submit.cjs +1 -1
  96. package/dist-lib/swift-submit.mjs +1 -1
  97. package/dist-lib/swift.cjs +672 -275
  98. package/dist-lib/vite-swift.cjs +1 -1
  99. package/dist-lib/vite.cjs +11 -28
  100. package/kotlin/README.md +32 -4
  101. package/kotlin/fixtures/counter/Counter.kt +26 -0
  102. package/kotlin/fixtures/counter/CounterScreen.kt +40 -0
  103. package/kotlin/fixtures/headless-counter/Headless.kt +228 -0
  104. package/kotlin/fixtures/runtime-state/RuntimeState.kt +200 -0
  105. package/kotlin/fork/LICENSE +178 -0
  106. package/kotlin/fork/NOTICE +25 -0
  107. package/kotlin/fork/README.md +59 -0
  108. package/kotlin/fork/build.sh +128 -0
  109. package/kotlin/fork/compose-worker-globals.patch +96 -0
  110. package/kotlin/fork/fork.json +33 -0
  111. package/kotlin/fork/publish.ts +77 -0
  112. package/kotlin/fork/skiko-worker-clock.patch +33 -0
  113. package/kotlin/toolchain.json +288 -0
  114. package/package.json +13 -5
  115. package/skills/contrast/SKILL.md +1 -0
  116. package/src/automatic-update-policy.ts +17 -0
  117. package/src/home-paths.ts +11 -9
  118. package/src/host/bridge-host.ts +3 -1
  119. package/src/{vite-plugin-kotlin.ts → kotlin-host.ts} +155 -12
  120. package/src/menu.ts +11 -0
  121. package/src/metro-plugin.ts +18 -0
  122. package/src/native-app-identity.ts +376 -0
  123. package/src/native-firebase-config.ts +90 -0
  124. package/src/screenshots/frame-compose.ts +1 -1
  125. package/src/screenshots/registry.ts +1 -1
  126. package/src/screenshots/schema.ts +2 -2
  127. package/src/vite-plugin-one.ts +11 -28
  128. package/src/vite-plugin.ts +6 -1
@@ -1,8 +1,8 @@
1
1
  // Peach's Kotlin host for One apps on an Android device: every Kotlin source
2
2
  // the app holds compiles, with One's generated dispatch glue, into one
3
3
  // Kotlin/Wasm module, as prebuild compiles them all into the one app process.
4
- // the vite plugin loads this on the first /__peach/kotlin request, so an app
5
- // with no Kotlin never evaluates it.
4
+ // any http host loads this on the first /__peach/kotlin request and answers
5
+ // it with serveKotlinRequest, so an app with no Kotlin never evaluates it.
6
6
 
7
7
  import { execFile } from 'node:child_process'
8
8
  import { createHash } from 'node:crypto'
@@ -26,6 +26,14 @@ interface Download {
26
26
  interface KotlinToolchain {
27
27
  compiler: Download & { version: string }
28
28
  libraries: (Download & { name: string })[]
29
+ composeLibraries: (Download & { name: string })[]
30
+ composeRuntime: Download & { name: string }
31
+ composeJsLibraries: (Download & {
32
+ name: string
33
+ module: string
34
+ file: string
35
+ archiveEntry: string
36
+ })[]
29
37
  }
30
38
 
31
39
  export interface KotlinArtifact {
@@ -49,17 +57,49 @@ function readToolchain(moduleRoot: string): KotlinToolchain {
49
57
  )
50
58
  return { url: value.url, sha256: value.sha256 }
51
59
  }
52
- if (!isRecord(parsed) || !isRecord(parsed.compiler) || !Array.isArray(parsed.libraries))
60
+ if (
61
+ !isRecord(parsed) ||
62
+ !isRecord(parsed.compiler) ||
63
+ !Array.isArray(parsed.libraries) ||
64
+ !Array.isArray(parsed.composeLibraries) ||
65
+ !Array.isArray(parsed.composeJsLibraries) ||
66
+ !isRecord(parsed.composeRuntime) ||
67
+ typeof parsed.composeRuntime.name !== 'string'
68
+ )
53
69
  throw new Error('peachsim kotlin/toolchain.json has no compiler and libraries')
54
70
  const version = parsed.compiler.version
55
71
  if (typeof version !== 'string')
56
72
  throw new Error('peachsim kotlin/toolchain.json has no compiler version')
57
- return {
58
- compiler: { ...download(parsed.compiler), version },
59
- libraries: parsed.libraries.map((library) => {
73
+ const libraries = (values: unknown[]) =>
74
+ values.map((library) => {
60
75
  if (!isRecord(library) || typeof library.name !== 'string')
61
76
  throw new Error('peachsim kotlin/toolchain.json names a library without a name')
62
77
  return { ...download(library), name: library.name }
78
+ })
79
+ return {
80
+ compiler: { ...download(parsed.compiler), version },
81
+ libraries: libraries(parsed.libraries),
82
+ composeLibraries: libraries(parsed.composeLibraries),
83
+ composeRuntime: {
84
+ ...download(parsed.composeRuntime),
85
+ name: parsed.composeRuntime.name,
86
+ },
87
+ composeJsLibraries: parsed.composeJsLibraries.map((library) => {
88
+ if (
89
+ !isRecord(library) ||
90
+ typeof library.name !== 'string' ||
91
+ typeof library.module !== 'string' ||
92
+ typeof library.file !== 'string' ||
93
+ typeof library.archiveEntry !== 'string'
94
+ )
95
+ throw new Error('peachsim kotlin/toolchain.json has an incomplete JS library')
96
+ return {
97
+ ...download(library),
98
+ name: library.name,
99
+ module: library.module,
100
+ file: library.file,
101
+ archiveEntry: library.archiveEntry,
102
+ }
63
103
  }),
64
104
  }
65
105
  }
@@ -207,7 +247,7 @@ function entrySource(ids: string[]): string {
207
247
  ' } else {',
208
248
  ' reply.put("ok", true).put("value", dispatch.call(module, method, argsJson))',
209
249
  ' }',
210
- ' } catch (error: Exception) {',
250
+ ' } catch (error: Throwable) {',
211
251
  ' reply.put("ok", false).put("code", "E_NATIVE_SOURCE").put("error", "$module.$method: ${error.message}")',
212
252
  ' }',
213
253
  ' reply.toString().toJsString()',
@@ -216,7 +256,7 @@ function entrySource(ids: string[]): string {
216
256
  ].join('\n')
217
257
  }
218
258
 
219
- const BUILD_FORMAT = 1
259
+ const BUILD_FORMAT = 4
220
260
  const builds = new Map<string, Promise<KotlinArtifact>>()
221
261
 
222
262
  function buildsDir(): string {
@@ -238,8 +278,11 @@ export async function kotlinArtifact(
238
278
  if (sources.length === 0) throw new Error(`${root} holds no Kotlin sources`)
239
279
  const generated: Record<string, string> = {}
240
280
  const ids: string[] = []
281
+ let usesCompose = false
241
282
  for (const file of sources) {
242
- const contract = nativeSourceContract(file, fs.readFileSync(file, 'utf8'))
283
+ const source = fs.readFileSync(file, 'utf8')
284
+ if (/\bandroidx\.compose\./.test(source)) usesCompose = true
285
+ const contract = nativeSourceContract(file, source)
243
286
  if (contract.modules.length === 0) continue
244
287
  const id = kotlinSourceId(root, file)
245
288
  ids.push(id)
@@ -255,25 +298,54 @@ export async function kotlinArtifact(
255
298
  for (const [name, text] of Object.entries(generated))
256
299
  digest.update(`${name}\0${text}\0`)
257
300
  const hash = digest.digest('hex').slice(0, 24)
258
- const artifact = { url: `${origin}${KOTLIN_ARTIFACT_PREFIX}${hash}/app.mjs`, hash }
301
+ const artifact = {
302
+ url: `${origin}${KOTLIN_ARTIFACT_PREFIX}${hash}/app.mjs`,
303
+ hash,
304
+ }
259
305
  const out = path.join(buildsDir(), hash)
260
306
  if (fs.existsSync(path.join(out, 'js', 'app.wasm'))) return artifact
261
307
  const pending = builds.get(hash)
262
308
  if (pending) return pending
263
309
  const build = (async () => {
264
- const { kotlinc, libraries } = await ensureToolchain(toolchain)
310
+ const ready = await ensureToolchain(toolchain)
311
+ const { kotlinc } = ready
312
+ const libraries = [...ready.libraries]
313
+ const composeRuntime = path.join(
314
+ kotlinHome(),
315
+ toolchain.compiler.version,
316
+ toolchain.composeRuntime.name,
317
+ )
318
+ if (usesCompose) {
319
+ for (const library of toolchain.composeLibraries) {
320
+ const target = path.join(kotlinHome(), toolchain.compiler.version, library.name)
321
+ await fetchVerified(library, target)
322
+ libraries.push(target)
323
+ }
324
+ await fetchVerified(toolchain.composeRuntime, composeRuntime)
325
+ }
265
326
  fs.mkdirSync(buildsDir(), { recursive: true })
266
327
  const work = fs.mkdtempSync(path.join(buildsDir(), `${hash}-`))
267
328
  const generatedDir = path.join(work, 'generated')
268
329
  fs.mkdirSync(generatedDir)
269
330
  for (const [name, text] of Object.entries(generated))
270
331
  fs.writeFileSync(path.join(generatedDir, name), text)
271
- const common = ['-Xwasm-target=js', '-libraries', libraries.join(path.delimiter)]
332
+ const common = [
333
+ // the full upstream Compose graph exceeds kotlinc's 512 mib default heap.
334
+ '-J-Xmx2g',
335
+ '-Xwasm-target=js',
336
+ '-libraries',
337
+ libraries.join(path.delimiter),
338
+ ]
272
339
  try {
273
340
  await run(
274
341
  kotlinc,
275
342
  [
276
343
  ...common,
344
+ ...(usesCompose
345
+ ? [
346
+ `-Xplugin=${path.resolve(path.dirname(kotlinc), '../lib/compose-compiler-plugin.jar')}`,
347
+ ]
348
+ : []),
277
349
  '-language-version',
278
350
  language,
279
351
  '-api-version',
@@ -304,6 +376,45 @@ export async function kotlinArtifact(
304
376
  ],
305
377
  { maxBuffer: 16 * 1024 * 1024 },
306
378
  )
379
+ if (usesCompose)
380
+ await run('unzip', [
381
+ '-q',
382
+ '-j',
383
+ composeRuntime,
384
+ 'skiko.mjs',
385
+ 'skiko.wasm',
386
+ '-d',
387
+ path.join(work, 'js'),
388
+ ])
389
+ // kotlin's npm externals remain bare imports after linking. publish the
390
+ // exact upstream ES modules beside the generated loader for workers.
391
+ if (usesCompose) {
392
+ for (const library of toolchain.composeJsLibraries) {
393
+ for (const file of fs.readdirSync(path.join(work, 'js'))) {
394
+ if (!file.endsWith('.mjs')) continue
395
+ const target = path.join(work, 'js', file)
396
+ const source = fs.readFileSync(target, 'utf8')
397
+ let next = source
398
+ for (const quote of ["'", '"'])
399
+ next = next.replaceAll(
400
+ `from ${quote}${library.module}${quote}`,
401
+ `from ${quote}./${library.file}${quote}`,
402
+ )
403
+ if (next === source) continue
404
+ const archive = path.join(
405
+ kotlinHome(),
406
+ toolchain.compiler.version,
407
+ library.name,
408
+ )
409
+ await fetchVerified(library, archive)
410
+ const { stdout } = await run('tar', ['-xOf', archive, library.archiveEntry], {
411
+ maxBuffer: 16 * 1024 * 1024,
412
+ })
413
+ fs.writeFileSync(path.join(work, 'js', library.file), stdout)
414
+ fs.writeFileSync(target, next)
415
+ }
416
+ }
417
+ }
307
418
  } catch (error) {
308
419
  fs.rmSync(work, { recursive: true, force: true })
309
420
  const output = isRecord(error)
@@ -374,3 +485,35 @@ export function serveKotlinArtifact(pathname: string, res: ServerResponse): bool
374
485
  res.end(fs.readFileSync(target))
375
486
  return true
376
487
  }
488
+
489
+ export const KOTLIN_PACKAGE_PATH = '/__peach/kotlin-package'
490
+
491
+ // peachsim's own Kotlin module (toolchain pins and shims) beside the app's
492
+ // installed peachsim
493
+ export function peachKotlinModuleRoot(root: string): string {
494
+ const peachsimPackage = createRequire(path.join(root, 'package.json')).resolve(
495
+ 'peachsim/package.json',
496
+ )
497
+ return path.join(path.dirname(peachsimPackage), 'kotlin')
498
+ }
499
+
500
+ // answers one /__peach/kotlin* request: the app's compiled module description,
501
+ // then the files it names. false leaves the request to the host.
502
+ export async function serveKotlinRequest(
503
+ pathname: string,
504
+ res: ServerResponse,
505
+ app: { root: string; moduleRoot: string; origin: string; hmrPath: string },
506
+ ): Promise<boolean> {
507
+ if (serveKotlinArtifact(pathname, res)) return true
508
+ if (pathname !== KOTLIN_PACKAGE_PATH) return false
509
+ res.setHeader('access-control-allow-origin', '*')
510
+ try {
511
+ const artifact = await kotlinArtifact(app.root, app.moduleRoot, app.origin)
512
+ res.setHeader('content-type', 'application/json')
513
+ res.end(JSON.stringify({ ...artifact, hmrPath: app.hmrPath }))
514
+ } catch (error) {
515
+ res.statusCode = 500
516
+ res.end(error instanceof Error ? error.message : String(error))
517
+ }
518
+ return true
519
+ }
package/src/menu.ts CHANGED
@@ -81,6 +81,7 @@ export interface SootSimMenuState {
81
81
  developmentMode: boolean
82
82
  showBezels: boolean
83
83
  showTouches: boolean
84
+ slowAnimations: boolean
84
85
  showMenuBar: boolean
85
86
  showRail: boolean
86
87
  solidWindow: boolean
@@ -379,6 +380,16 @@ function debugMenu(state: SootSimMenuState): SootSimMenuDef {
379
380
  return {
380
381
  label: 'Debug',
381
382
  items: [
383
+ {
384
+ label: 'Slow Animations',
385
+ type: 'checkbox',
386
+ checked: state.slowAnimations,
387
+ accelerator: 'Shift+Alt+S',
388
+ action: 'toggle-slow-animations',
389
+ browserAccelerator: true,
390
+ showInShortcutDocs: true,
391
+ },
392
+ { type: 'separator' },
382
393
  // both toggles are stateful in the browser, where the open state lives
383
394
  // in the same document. electron's menu process cannot see it, so it
384
395
  // keeps the plain (stateless) items rather than a permanently
@@ -24,6 +24,7 @@ import {
24
24
  toRNXProductionBundleUrl,
25
25
  type RNXMetroModuleIdentityMetadata,
26
26
  } from './metro-production-bundle'
27
+ import { readNativeFirebaseConfig } from './native-firebase-config'
27
28
  import {
28
29
  isRootRuntimeAssetPath,
29
30
  resolveRuntimeFilePath,
@@ -249,6 +250,23 @@ export function withRNX<T extends Record<string, unknown>>(config: T): T {
249
250
  return
250
251
  }
251
252
 
253
+ if ((req.url || '').split('?')[0] === '/__peach-native-firebase-config') {
254
+ try {
255
+ res.setHeader('Content-Type', 'application/json')
256
+ res.setHeader('Cache-Control', 'no-store')
257
+ res.setHeader('Access-Control-Allow-Origin', '*')
258
+ res.end(JSON.stringify(readNativeFirebaseConfig(cfgProjectRoot)))
259
+ } catch (error) {
260
+ res.statusCode = 400
261
+ res.end(
262
+ JSON.stringify({
263
+ error: error instanceof Error ? error.message : String(error),
264
+ }),
265
+ )
266
+ }
267
+ return
268
+ }
269
+
252
270
  const url = req.url || ''
253
271
  const pathname = url.split('?')[0]
254
272
 
@@ -0,0 +1,376 @@
1
+ // generic native app identity: the values an installed iOS/Android binary
2
+ // carries, read from the app's own native project (`Info.plist` resolved
3
+ // against the Xcode Release build settings, or `android/app/build.gradle`), so
4
+ // a simulator tenant reports the same identity the native Release build does.
5
+ //
6
+ // the values travel in a footer a bundle builder appends to the artifact — the
7
+ // same transport shape the Metro module identity footer already uses — and the
8
+ // tenant boot reads it from the bundle text before the guest evaluates, then
9
+ // stores it on the tenant realm for the native app-information seams. a bundle
10
+ // with no native identity footer leaves every seam exactly where it was.
11
+
12
+ export type NativeAppIdentityPlatform = 'ios' | 'android'
13
+
14
+ export interface NativeAppIdentity {
15
+ applicationName: string | null
16
+ appVersion: string
17
+ buildVersion: string
18
+ bundleIdentifier: string
19
+ platform: NativeAppIdentityPlatform
20
+ }
21
+
22
+ export type NativeAppIdentityFiles = Record<string, string>
23
+
24
+ const NATIVE_APP_IDENTITY_PREFIX = 'globalThis.__sootsimNativeAppIdentity='
25
+ const METRO_MODULE_PATHS_PREFIX = 'globalThis.__sootsimModulePaths='
26
+ const APPLIED_NATIVE_APP_IDENTITY_KEY = '__sootsim_native_app_identity'
27
+
28
+ interface NativeAppIdentityGlobals {
29
+ __sootsim_native_app_identity?: NativeAppIdentity | null
30
+ }
31
+
32
+ function isRecord(value: unknown): value is Record<string, unknown> {
33
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
34
+ }
35
+
36
+ function nonEmptyString(value: unknown): string | null {
37
+ return typeof value === 'string' && value.length > 0 ? value : null
38
+ }
39
+
40
+ export function isNativeAppIdentity(value: unknown): value is NativeAppIdentity {
41
+ if (!isRecord(value)) return false
42
+ const platform = value.platform
43
+ if (platform !== 'ios' && platform !== 'android') return false
44
+ const applicationName = value.applicationName
45
+ if (applicationName !== null && nonEmptyString(applicationName) === null) return false
46
+ if (nonEmptyString(value.appVersion) === null) return false
47
+ if (nonEmptyString(value.bundleIdentifier) === null) return false
48
+ const buildVersion = nonEmptyString(value.buildVersion)
49
+ if (buildVersion === null) return false
50
+ if (platform === 'android' && !/^[1-9][0-9]*$/.test(buildVersion)) return false
51
+ return true
52
+ }
53
+
54
+ // read a host-supplied identity. absent/null clears it; a present value that is
55
+ // neither is a contract violation the caller must not paper over.
56
+ export function readNativeAppIdentity(value: unknown): NativeAppIdentity | null {
57
+ if (value === undefined || value === null) return null
58
+ if (!isNativeAppIdentity(value)) {
59
+ throw new Error('native app identity is malformed')
60
+ }
61
+ return {
62
+ applicationName: value.applicationName,
63
+ appVersion: value.appVersion,
64
+ buildVersion: value.buildVersion,
65
+ bundleIdentifier: value.bundleIdentifier,
66
+ platform: value.platform,
67
+ }
68
+ }
69
+
70
+ // the one realm store every seam reads. set by the tenant boot before the
71
+ // guest evaluates, cleared on a boot without an identity so a previous app's
72
+ // identity can never leak into the next load.
73
+ export function applyNativeAppIdentity(identity: NativeAppIdentity | null): void {
74
+ const globals = globalThis as NativeAppIdentityGlobals
75
+ if (identity) globals.__sootsim_native_app_identity = identity
76
+ else delete globals.__sootsim_native_app_identity
77
+ }
78
+
79
+ export function getAppliedNativeAppIdentity(): NativeAppIdentity | null {
80
+ const value = (globalThis as NativeAppIdentityGlobals).__sootsim_native_app_identity
81
+ return isNativeAppIdentity(value) ? value : null
82
+ }
83
+
84
+ // one line, so the footer survives a bundle whose last statement is the Metro
85
+ // module identity footer and can be found again by reading the text.
86
+ export function createNativeAppIdentityFooter(identity: NativeAppIdentity): string {
87
+ const payload = JSON.stringify(identity)
88
+ .replace(/\u2028/g, '\\u2028')
89
+ .replace(/\u2029/g, '\\u2029')
90
+ .replace(/<\//g, '<\\/')
91
+ return `\n${NATIVE_APP_IDENTITY_PREFIX}${payload};`
92
+ }
93
+
94
+ function nativeAppIdentityFooterStart(source: string): number {
95
+ return source.lastIndexOf(`\n${NATIVE_APP_IDENTITY_PREFIX}`)
96
+ }
97
+
98
+ export function readNativeAppIdentityFooter(source: string): NativeAppIdentity | null {
99
+ const start = nativeAppIdentityFooterStart(source)
100
+ if (start < 0) return null
101
+ const lineEnd = source.indexOf('\n', start + 1)
102
+ const line = source.slice(start + 1, lineEnd < 0 ? undefined : lineEnd)
103
+ if (!line.endsWith(';')) {
104
+ throw new Error('native app identity footer is truncated')
105
+ }
106
+ let parsed: unknown
107
+ try {
108
+ parsed = JSON.parse(line.slice(NATIVE_APP_IDENTITY_PREFIX.length, -1))
109
+ } catch {
110
+ throw new Error('native app identity footer is not valid JSON')
111
+ }
112
+ if (!isNativeAppIdentity(parsed)) {
113
+ throw new Error('native app identity footer is malformed')
114
+ }
115
+ return parsed
116
+ }
117
+
118
+ // remove the canonical footer from a bundle builder's input, returning the
119
+ // identity it carried. the cloud artifact normalizer detaches before
120
+ // minifying — a minifier rewrites the payload (`{key:…}`, joined statements) —
121
+ // and reattaches the same canonical statement afterwards.
122
+ export function detachNativeAppIdentityFooter(source: string): {
123
+ source: string
124
+ identity: NativeAppIdentity | null
125
+ } {
126
+ const start = nativeAppIdentityFooterStart(source)
127
+ if (start < 0) return { source, identity: null }
128
+ const identity = readNativeAppIdentityFooter(source)
129
+ const lineEnd = source.indexOf('\n', start + 1)
130
+ const end = lineEnd < 0 ? source.length : lineEnd
131
+ return { source: source.slice(0, start) + source.slice(end), identity }
132
+ }
133
+
134
+ // insert the footer ahead of the Metro module identity footer when the bundle
135
+ // carries one: readers of that contract slice the bundle at its start, and the
136
+ // native identity has to stay in the text they hand to the guest. no footer
137
+ // present (a development or legacy artifact) appends.
138
+ export function insertNativeAppIdentityFooter(
139
+ source: string,
140
+ identity: NativeAppIdentity,
141
+ ): string {
142
+ const existing = nativeAppIdentityFooterStart(source)
143
+ const existingEnd =
144
+ existing < 0
145
+ ? -1
146
+ : source.includes('\n', existing + 1)
147
+ ? source.indexOf('\n', existing + 1)
148
+ : -1
149
+ const withoutExisting =
150
+ existing < 0
151
+ ? source
152
+ : source.slice(0, existing) +
153
+ source.slice(existingEnd < 0 ? source.length : existingEnd)
154
+ const moduleFooter = withoutExisting.lastIndexOf(`\n${METRO_MODULE_PATHS_PREFIX}`)
155
+ const footer = createNativeAppIdentityFooter(identity)
156
+ if (moduleFooter < 0) return withoutExisting + footer
157
+ return (
158
+ withoutExisting.slice(0, moduleFooter) + footer + withoutExisting.slice(moduleFooter)
159
+ )
160
+ }
161
+
162
+ // ── native project extraction ────────────────────────────────────────────────
163
+
164
+ function plistString(plist: string, key: string): string | null {
165
+ const match = plist.match(new RegExp(`<key>${key}</key>\\s*<string>([^<]*)</string>`))
166
+ return match ? match[1].trim() : null
167
+ }
168
+
169
+ // Xcode build settings support `$(VAR)` references, so a plist value can only
170
+ // be read against the settings of the configuration the binary ships with.
171
+ // references recurse because a setting may itself reference another, and a
172
+ // value whose reference cannot be fully resolved — an unknown variable or a
173
+ // modifier this reader does not implement — is rejected rather than asserted.
174
+ function resolveBuildSetting(
175
+ value: string,
176
+ settings: Record<string, string>,
177
+ depth = 0,
178
+ ): string | null {
179
+ if (depth > 8) return null
180
+ let unresolved = false
181
+ const resolved = value.replace(/\$\(([^()]+)\)/g, (_match, reference: string) => {
182
+ const separator = reference.indexOf(':')
183
+ if (separator >= 0) {
184
+ unresolved = true
185
+ return ''
186
+ }
187
+ const replacement = settings[reference]
188
+ if (replacement === undefined) {
189
+ unresolved = true
190
+ return ''
191
+ }
192
+ return replacement
193
+ })
194
+ if (unresolved) return null
195
+ if (resolved.includes('$(')) return resolveBuildSetting(resolved, settings, depth + 1)
196
+ return resolved
197
+ }
198
+
199
+ // an identity field is only usable when it is a real value: non-empty and free
200
+ // of any unresolved build-setting syntax.
201
+ function resolvedIdentityField(value: string | null): string | null {
202
+ const trimmed = value?.trim() ?? ''
203
+ if (!trimmed || trimmed.includes('$(')) return null
204
+ return trimmed
205
+ }
206
+
207
+ function identityFieldFrom(
208
+ settings: Record<string, string>,
209
+ raw: string | null,
210
+ fallbackSetting: string | undefined,
211
+ ): string | null {
212
+ if (raw !== null) return resolvedIdentityField(resolveBuildSetting(raw, settings))
213
+ return resolvedIdentityField(nonEmptyString(fallbackSetting ?? null))
214
+ }
215
+
216
+ function readBuildSettingsBlock(source: string, start: number): string {
217
+ // start is the index of `{`; walk to its matching close so nested arrays and
218
+ // dictionaries inside buildSettings stay part of the block.
219
+ let depth = 0
220
+ for (let index = start; index < source.length; index++) {
221
+ const character = source[index]
222
+ if (character === '{') depth++
223
+ else if (character === '}') {
224
+ depth--
225
+ if (depth === 0) return source.slice(start, index + 1)
226
+ }
227
+ }
228
+ return source.slice(start)
229
+ }
230
+
231
+ function parseBuildSettings(block: string): Record<string, string> {
232
+ const settings: Record<string, string> = {}
233
+ const matchPattern = /([A-Z0-9_]+) = ("(?:[^"\\]|\\.)*"|[^;{}]*);/g
234
+ let match: RegExpExecArray | null
235
+ while ((match = matchPattern.exec(block)) !== null) {
236
+ const raw = match[2].trim()
237
+ settings[match[1]] = raw.startsWith('"') ? raw.slice(1, -1) : raw.trim()
238
+ }
239
+ return settings
240
+ }
241
+
242
+ // the Release XCBuildConfiguration of the app target: the configuration whose
243
+ // INFOPLIST_FILE is the plist being resolved, else the one whose PRODUCT_NAME
244
+ // names the target directory. Debug settings (suffixes, dev bundle ids) must
245
+ // never win.
246
+ function appTargetReleaseSettings(
247
+ pbxproj: string,
248
+ targetName: string,
249
+ infoPlistRelativePath: string,
250
+ ): Record<string, string> | null {
251
+ const configurationPattern = /\/\* (Release|Debug) \*\/ = \{/g
252
+ const candidates: Array<{ name: string; settings: Record<string, string> }> = []
253
+ let match: RegExpExecArray | null
254
+ while ((match = configurationPattern.exec(pbxproj)) !== null) {
255
+ const blockStart = pbxproj.indexOf('{', match.index)
256
+ if (blockStart < 0) continue
257
+ const block = readBuildSettingsBlock(pbxproj, blockStart)
258
+ const buildSettingsStart = block.indexOf('buildSettings = {')
259
+ if (buildSettingsStart < 0) continue
260
+ const settings = parseBuildSettings(
261
+ readBuildSettingsBlock(block, block.indexOf('{', buildSettingsStart)),
262
+ )
263
+ candidates.push({ name: match[1], settings })
264
+ }
265
+ const plistMatch = (settings: Record<string, string>): boolean => {
266
+ const plist = settings.INFOPLIST_FILE
267
+ if (!plist) return false
268
+ const normalized = plist.replace(/^"|"$/g, '')
269
+ return (
270
+ normalized === infoPlistRelativePath ||
271
+ infoPlistRelativePath.endsWith(`/${normalized}`) ||
272
+ normalized.endsWith(`/${infoPlistRelativePath}`)
273
+ )
274
+ }
275
+ const named = (settings: Record<string, string>): boolean =>
276
+ resolveBuildSetting(settings.PRODUCT_NAME ?? '', settings) === targetName
277
+ return (
278
+ candidates.find(
279
+ (candidate) => candidate.name === 'Release' && plistMatch(candidate.settings),
280
+ )?.settings ??
281
+ candidates.find(
282
+ (candidate) => candidate.name === 'Release' && named(candidate.settings),
283
+ )?.settings ??
284
+ null
285
+ )
286
+ }
287
+
288
+ function nativeAppIdentityFromIos(
289
+ files: NativeAppIdentityFiles,
290
+ ): NativeAppIdentity | null {
291
+ 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
+ }
301
+ if (!targetName) return null
302
+
303
+ const plistPath = Object.keys(files).find(
304
+ (path) => path === `ios/${targetName}/Info.plist`,
305
+ )
306
+ const plist = plistPath ? files[plistPath] : null
307
+ if (!plist) return null
308
+
309
+ const pbxprojPath = Object.keys(files).find((path) =>
310
+ /(?:^|\/)ios\/[^/]+\.xcodeproj\/project\.pbxproj$/.test(path),
311
+ )
312
+ const settings = pbxprojPath
313
+ ? appTargetReleaseSettings(files[pbxprojPath], targetName, `${targetName}/Info.plist`)
314
+ : null
315
+ if (!settings) return null
316
+
317
+ const bundleIdentifier = identityFieldFrom(
318
+ settings,
319
+ plistString(plist, 'CFBundleIdentifier'),
320
+ settings.PRODUCT_BUNDLE_IDENTIFIER,
321
+ )
322
+ if (!bundleIdentifier) return null
323
+
324
+ const appVersion = identityFieldFrom(
325
+ settings,
326
+ plistString(plist, 'CFBundleShortVersionString'),
327
+ settings.MARKETING_VERSION,
328
+ )
329
+ const buildVersion = identityFieldFrom(
330
+ settings,
331
+ plistString(plist, 'CFBundleVersion'),
332
+ settings.CURRENT_PROJECT_VERSION,
333
+ )
334
+ if (!appVersion || !buildVersion) return null
335
+
336
+ const applicationName =
337
+ identityFieldFrom(settings, plistString(plist, 'CFBundleDisplayName'), undefined) ??
338
+ identityFieldFrom(
339
+ settings,
340
+ plistString(plist, 'CFBundleName'),
341
+ settings.PRODUCT_NAME,
342
+ ) ??
343
+ resolvedIdentityField(resolveBuildSetting(settings.PRODUCT_NAME ?? '', settings)) ??
344
+ resolvedIdentityField(targetName)
345
+ if (!applicationName) return null
346
+
347
+ return {
348
+ applicationName,
349
+ appVersion,
350
+ buildVersion,
351
+ bundleIdentifier,
352
+ platform: 'ios',
353
+ }
354
+ }
355
+
356
+ // Android's shipped identity requires resolving the selected flavor and
357
+ // buildType overrides (`productFlavors.prod`, `buildTypes.release`), which a
358
+ // text reader of `defaultConfig` cannot distinguish from the defaults it
359
+ // 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
363
+ }
364
+
365
+ // read the identity a bundle builder transports. files are project-relative
366
+ // paths to their text, so both a checked-out app directory and a stored
367
+ // project file map resolve identically. null when the project carries no
368
+ // resolvable native config — the builder then ships no footer.
369
+ export function extractNativeAppIdentityFromFiles(
370
+ files: NativeAppIdentityFiles,
371
+ platform: NativeAppIdentityPlatform,
372
+ ): NativeAppIdentity | null {
373
+ return platform === 'android'
374
+ ? nativeAppIdentityFromAndroid()
375
+ : nativeAppIdentityFromIos(files)
376
+ }