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.
- package/README.md +8 -4
- package/cli/app-fonts.ts +23 -4
- package/cli/bridge-flow-runner.ts +45 -31
- package/cli/cli-update.ts +2 -7
- package/cli/cloud-test-tunnel.ts +16 -3
- package/cli/command-registry.ts +1 -0
- package/cli/commands/compat.ts +11 -60
- package/cli/commands/config.ts +1 -1
- package/cli/commands/control.ts +40 -8
- package/cli/commands/daemon-mac-app.ts +4 -2
- package/cli/commands/daemon.ts +4 -4
- package/cli/commands/detect.ts +289 -26
- package/cli/commands/device.ts +2 -3
- package/cli/commands/inspect/actions.ts +125 -90
- package/cli/commands/inspect/core.ts +1 -1
- package/cli/commands/inspect.ts +10 -1
- package/cli/commands/maestro.ts +3 -1
- package/cli/commands/platform.ts +1 -1
- package/cli/commands/project-report.ts +386 -0
- package/cli/commands/projectReportContract.ts +42 -0
- package/cli/commands/report-issue.ts +38 -54
- package/cli/commands/screenshot-layers.ts +1 -1
- package/cli/commands/screenshot.ts +1 -1
- package/cli/commands/setup.ts +6 -4
- package/cli/commands/skills.ts +0 -31
- package/cli/commands/telemetry.ts +8 -3
- package/cli/commands/test.ts +347 -134
- package/cli/drivers/playwright-provisioning.ts +3 -2
- package/cli/drivers/playwright-sim-host.ts +25 -9
- package/cli/main.ts +19 -3
- package/cli/outbound-endpoints.ts +2 -5
- package/cli/privacy.ts +1 -2
- package/cli/runtime-diagnostics.ts +51 -0
- package/cli/telemetry.ts +78 -7
- package/detox/index.ts +77 -51
- 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 +1 -1
- package/dist-lib/bridge-contract-input.mjs +1 -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 +1 -1
- package/dist-lib/detox/index.cjs +130 -33
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +8 -5
- package/dist-lib/host/bridge-host.cjs +353 -290
- 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 +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +257 -121
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/{vite-kotlin.cjs → kotlin-host.cjs} +130 -19
- package/dist-lib/menu.cjs +11 -1
- package/dist-lib/menu.mjs +11 -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 +132 -9
- package/dist-lib/native-app-identity.cjs +278 -0
- package/dist-lib/native-app-identity.mjs +245 -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 +1 -1
- package/dist-lib/sdk.cjs +112 -87
- package/dist-lib/sdk.mjs +111 -87
- package/dist-lib/skills.cjs +1270 -709
- 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 +672 -275
- package/dist-lib/vite-swift.cjs +1 -1
- package/dist-lib/vite.cjs +11 -28
- package/kotlin/README.md +32 -4
- package/kotlin/fixtures/counter/Counter.kt +26 -0
- package/kotlin/fixtures/counter/CounterScreen.kt +40 -0
- package/kotlin/fixtures/headless-counter/Headless.kt +228 -0
- package/kotlin/fixtures/runtime-state/RuntimeState.kt +200 -0
- package/kotlin/fork/LICENSE +178 -0
- package/kotlin/fork/NOTICE +25 -0
- package/kotlin/fork/README.md +59 -0
- package/kotlin/fork/build.sh +128 -0
- package/kotlin/fork/compose-worker-globals.patch +96 -0
- package/kotlin/fork/fork.json +33 -0
- package/kotlin/fork/publish.ts +77 -0
- package/kotlin/fork/skiko-worker-clock.patch +33 -0
- package/kotlin/toolchain.json +288 -0
- package/package.json +13 -5
- package/skills/contrast/SKILL.md +1 -0
- package/src/automatic-update-policy.ts +17 -0
- package/src/home-paths.ts +11 -9
- package/src/host/bridge-host.ts +3 -1
- package/src/{vite-plugin-kotlin.ts → kotlin-host.ts} +155 -12
- package/src/menu.ts +11 -0
- package/src/metro-plugin.ts +18 -0
- package/src/native-app-identity.ts +376 -0
- package/src/native-firebase-config.ts +90 -0
- package/src/screenshots/frame-compose.ts +1 -1
- package/src/screenshots/registry.ts +1 -1
- package/src/screenshots/schema.ts +2 -2
- package/src/vite-plugin-one.ts +11 -28
- 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
|
-
//
|
|
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 (
|
|
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
|
-
|
|
58
|
-
|
|
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:
|
|
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 =
|
|
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
|
|
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 = {
|
|
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
|
|
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 = [
|
|
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
|
package/src/metro-plugin.ts
CHANGED
|
@@ -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
|
+
}
|