peachsim 0.1.648 → 0.1.650

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 (132) hide show
  1. package/README.md +10 -9
  2. package/cli/command-registry.ts +2 -1
  3. package/cli/commands/guest-host.ts +67 -0
  4. package/cli/commands/kotlin.ts +231 -0
  5. package/cli/commands/project-report.ts +1 -1
  6. package/cli/commands/report-issue.ts +55 -32
  7. package/cli/commands/setup.ts +10 -14
  8. package/cli/commands/support.ts +399 -0
  9. package/cli/commands/swift.ts +6 -52
  10. package/cli/commands/telemetry.ts +10 -13
  11. package/cli/main.ts +22 -10
  12. package/cli/outbound-endpoints.ts +23 -2
  13. package/cli/privacy.ts +23 -25
  14. package/cli/runtime-notes.ts +3 -2
  15. package/cli/telemetry.ts +109 -14
  16. package/dist-lib/agent-daemon-client.cjs +1 -1
  17. package/dist-lib/agent-events.cjs +1 -1
  18. package/dist-lib/agent-identity.cjs +1 -1
  19. package/dist-lib/agent-sessions.cjs +1 -1
  20. package/dist-lib/attached-projects.cjs +1 -1
  21. package/dist-lib/auth/shared-session.cjs +1 -1
  22. package/dist-lib/backend-origin.cjs +1 -1
  23. package/dist-lib/beta.cjs +1 -1
  24. package/dist-lib/beta.mjs +1 -1
  25. package/dist-lib/bridge-constants.cjs +1 -1
  26. package/dist-lib/bridge-contract-input.cjs +1 -1
  27. package/dist-lib/bridge-contract-input.mjs +1 -1
  28. package/dist-lib/bridge-contract.cjs +1 -1
  29. package/dist-lib/bridge-contract.mjs +1 -1
  30. package/dist-lib/capture-contract.cjs +1 -1
  31. package/dist-lib/capture-contract.mjs +1 -1
  32. package/dist-lib/cli-constants.cjs +1 -1
  33. package/dist-lib/cloud-contract.cjs +1 -1
  34. package/dist-lib/cloud-contract.mjs +1 -1
  35. package/dist-lib/cloud-test-args.cjs +1 -1
  36. package/dist-lib/cloud-test-args.mjs +1 -1
  37. package/dist-lib/cloud.cjs +1 -1
  38. package/dist-lib/cloud.mjs +1 -1
  39. package/dist-lib/config.cjs +1 -1
  40. package/dist-lib/detox/index.cjs +1 -1
  41. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  42. package/dist-lib/home-paths.cjs +3 -3
  43. package/dist-lib/host/bridge-host.cjs +1 -1
  44. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  45. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  46. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  47. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  48. package/dist-lib/host/websocket-proxy.cjs +1 -1
  49. package/dist-lib/index.cjs +1 -1
  50. package/dist-lib/jump-to-source-babel.cjs +1 -1
  51. package/dist-lib/jump-to-source-native.cjs +1 -1
  52. package/dist-lib/kotlin-host.cjs +102 -21
  53. package/dist-lib/menu.cjs +1 -1
  54. package/dist-lib/menu.mjs +1 -1
  55. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  56. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  57. package/dist-lib/metro-production-bundle.cjs +1 -1
  58. package/dist-lib/metro-production-bundle.mjs +1 -1
  59. package/dist-lib/metro.cjs +1 -1
  60. package/dist-lib/native-app-identity.cjs +1 -1
  61. package/dist-lib/native-app-identity.mjs +1 -1
  62. package/dist-lib/profiles.cjs +1 -1
  63. package/dist-lib/public-brand.cjs +1 -1
  64. package/dist-lib/react-native-host-modules.cjs +1 -1
  65. package/dist-lib/react-native-host-modules.mjs +1 -1
  66. package/dist-lib/render-mode.cjs +1 -1
  67. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  68. package/dist-lib/sdk.cjs +1 -1
  69. package/dist-lib/sdk.mjs +1 -1
  70. package/dist-lib/skills.cjs +28 -39
  71. package/dist-lib/swift-compile.cjs +6 -3
  72. package/dist-lib/swift-compile.mjs +6 -3
  73. package/dist-lib/swift-package.cjs +1 -1
  74. package/dist-lib/swift-package.mjs +1 -1
  75. package/dist-lib/swift-submit.cjs +1 -1
  76. package/dist-lib/swift-submit.mjs +1 -1
  77. package/dist-lib/swift.cjs +1 -1
  78. package/dist-lib/vite-swift.cjs +6 -3
  79. package/dist-lib/vite.cjs +1 -1
  80. package/kotlin/AppSources/android/app/Activity.kt +85 -0
  81. package/kotlin/AppSources/android/app/Application.kt +7 -0
  82. package/kotlin/AppSources/android/content/Context.kt +5 -0
  83. package/kotlin/AppSources/android/content/Intent.kt +16 -0
  84. package/kotlin/AppSources/android/os/Bundle.kt +5 -0
  85. package/kotlin/AppSources/android/util/SuperNotCalledException.kt +3 -0
  86. package/kotlin/AppSources/android/view/View.kt +72 -0
  87. package/kotlin/AppSources/android/view/Window.kt +44 -0
  88. package/kotlin/AppSources/android/widget/FrameLayout.kt +6 -0
  89. package/kotlin/AppSources/androidx/activity/ComponentActivity.kt +57 -0
  90. package/kotlin/AppSources/androidx/activity/compose/ComponentActivity.kt +29 -0
  91. package/kotlin/AppSources/androidx/compose/ui/platform/ComposeView.kt +67 -0
  92. package/kotlin/AppSources/dev/peach/app/PeachApp.kt +136 -0
  93. package/kotlin/AppSources/dev/peach/app/PeachWindowHost.kt +13 -0
  94. package/kotlin/ComposeSources/PeachComposeHost.kt +316 -0
  95. package/kotlin/Sources/dev/onejs/one/OneNativeSourceViewDispatch.kt +13 -0
  96. package/kotlin/Sources/org/json/JSON.kt +6 -0
  97. package/kotlin/fixtures/counter/Counter.d.kt.ts +16 -0
  98. package/kotlin/fixtures/counter/Counter.kt +18 -0
  99. package/kotlin/fixtures/counter/CounterApp.native.tsx +34 -0
  100. package/kotlin/fixtures/counter/CounterApp.tsx +3 -0
  101. package/kotlin/fixtures/counter/CounterScreen.d.kt.ts +7 -0
  102. package/kotlin/fixtures/counter/CounterScreen.kt +18 -3
  103. package/kotlin/fixtures/counter/app/_layout.tsx +4 -0
  104. package/kotlin/fixtures/counter/app/index+spa.tsx +1 -0
  105. package/kotlin/fixtures/counter/app/routes.d.ts +18 -0
  106. package/kotlin/fixtures/counter/package.json +7 -0
  107. package/kotlin/fixtures/counter/tsconfig.json +19 -0
  108. package/kotlin/fixtures/counter/vite.config.ts +10 -0
  109. package/kotlin/fixtures/headless-counter/Headless.kt +5 -0
  110. package/kotlin/fixtures/kotlin-app/app/build.gradle.kts +30 -0
  111. package/kotlin/fixtures/kotlin-app/app/src/main/AndroidManifest.xml +13 -0
  112. package/kotlin/fixtures/kotlin-app/app/src/main/java/dev/contrast/kotlinapp/MainActivity.kt +60 -0
  113. package/kotlin/fixtures/kotlin-app/app/src/main/java/dev/contrast/kotlinapp/ProbeApplication.kt +18 -0
  114. package/kotlin/fixtures/kotlin-app/build.gradle.kts +5 -0
  115. package/kotlin/fixtures/kotlin-app/gradle.properties +4 -0
  116. package/kotlin/fixtures/kotlin-app/settings.gradle.kts +4 -0
  117. package/kotlin/fork/NOTICE +7 -5
  118. package/kotlin/fork/README.md +41 -49
  119. package/kotlin/fork/build.sh +3 -2
  120. package/kotlin/fork/compose-worker-globals.patch +22 -11
  121. package/kotlin/fork/fork.json +2 -2
  122. package/kotlin/fork/publish.ts +30 -15
  123. package/kotlin/fork/skiko-worker-clock.patch +38 -0
  124. package/kotlin/toolchain.json +9 -9
  125. package/package.json +1 -1
  126. package/skills/contrast/SKILL.md +2 -1
  127. package/skills/peach-setup/SKILL.md +2 -2
  128. package/src/{swift-guest.ts → guest-bundle.ts} +22 -7
  129. package/src/home-paths.ts +5 -3
  130. package/src/kotlin-app.ts +411 -0
  131. package/src/kotlin-host.ts +148 -25
  132. package/src/swift-compile.ts +7 -2
@@ -0,0 +1,411 @@
1
+ // reads a pure Kotlin Android app module the way its Gradle build and the
2
+ // device resolve it, for the subset Peach boots: one module's `main` source
3
+ // set, its own manifest, literal Gradle values and the pinned Compose closure.
4
+ // anything outside that subset rejects with its location, never approximated.
5
+
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+
9
+ export interface KotlinAppProject {
10
+ projectDir: string
11
+ moduleDir: string
12
+ sources: string[]
13
+ language: string
14
+ packageName: string
15
+ namespace: string
16
+ targetSdk: number
17
+ optOutEdgeToEdge: boolean
18
+ applicationClass: string | null
19
+ launcher: { name: string; targetClass: string }
20
+ }
21
+
22
+ export interface ComposeAndroidPins {
23
+ ui: string
24
+ foundation: string
25
+ runtime: string
26
+ material3: string
27
+ }
28
+
29
+ const ANDROID_NS = 'android:'
30
+ const MAIN = 'android.intent.action.MAIN'
31
+ const LAUNCHER = 'android.intent.category.LAUNCHER'
32
+ const PLUGINS = new Set([
33
+ 'com.android.application',
34
+ 'org.jetbrains.kotlin.android',
35
+ 'org.jetbrains.kotlin.plugin.compose',
36
+ ])
37
+ // the closure Peach links. compose artifacts must match its Android pins; the
38
+ // activity, lifecycle and savedstate artifacts are Peach's platform actuals.
39
+ const PLATFORM_GROUPS = new Set([
40
+ 'androidx.activity',
41
+ 'androidx.lifecycle',
42
+ 'androidx.savedstate',
43
+ ])
44
+ const COMPOSE_PIN: Record<string, keyof ComposeAndroidPins> = {
45
+ 'androidx.compose.ui': 'ui',
46
+ 'androidx.compose.foundation': 'foundation',
47
+ 'androidx.compose.runtime': 'runtime',
48
+ 'androidx.compose.material3': 'material3',
49
+ }
50
+
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
+ function readGradle(dir: string): { file: string; text: string } | null {
90
+ for (const name of ['build.gradle.kts', 'build.gradle']) {
91
+ const file = path.join(dir, name)
92
+ if (fs.existsSync(file)) return { file, text: fs.readFileSync(file, 'utf8') }
93
+ }
94
+ return null
95
+ }
96
+
97
+ function lineOf(text: string, index: number): number {
98
+ return text.slice(0, index).split('\n').length
99
+ }
100
+
101
+ // a literal `name = value` or `name value`; any other assignment of the name
102
+ // is a value Peach cannot read without running Gradle
103
+ function gradleLiteral(
104
+ gradle: { file: string; text: string },
105
+ name: string,
106
+ pattern: string,
107
+ ): string | null {
108
+ const literal = new RegExp(`\\b${name}\\s*=?\\s*(${pattern})\\s*$`, 'm').exec(
109
+ gradle.text,
110
+ )
111
+ if (literal?.[1]) return literal[1].replace(/^"|"$/g, '')
112
+ const any = new RegExp(`\\b${name}\\b\\s*=?\\s*\\S`, 'm').exec(gradle.text)
113
+ if (any)
114
+ throw new Error(
115
+ `${gradle.file}:${lineOf(gradle.text, any.index)}: ${name} must be a literal for peach kotlin`,
116
+ )
117
+ return null
118
+ }
119
+
120
+ function kotlinPluginVersion(
121
+ projectDir: string,
122
+ moduleGradle: { file: string; text: string },
123
+ ) {
124
+ const sources = [readGradle(projectDir), moduleGradle].filter((value) => value !== null)
125
+ for (const gradle of sources) {
126
+ const match =
127
+ /id\(\s*"org\.jetbrains\.kotlin\.android"\s*\)\s*version\s*"([^"]+)"/.exec(
128
+ gradle.text,
129
+ ) ?? /kotlin\(\s*"android"\s*\)\s*version\s*"([^"]+)"/.exec(gradle.text)
130
+ if (match?.[1]) return match[1]
131
+ }
132
+ const catalog = path.join(projectDir, 'gradle', 'libs.versions.toml')
133
+ if (fs.existsSync(catalog)) {
134
+ const match = /^kotlin\s*=\s*"([^"]+)"/m.exec(fs.readFileSync(catalog, 'utf8'))
135
+ if (match?.[1]) return match[1]
136
+ }
137
+ throw new Error(
138
+ `${projectDir} declares no literal org.jetbrains.kotlin.android plugin version`,
139
+ )
140
+ }
141
+
142
+ function checkModuleGradle(
143
+ gradle: { file: string; text: string },
144
+ pins: ComposeAndroidPins,
145
+ ) {
146
+ const at = (index: number) => `${gradle.file}:${lineOf(gradle.text, index)}`
147
+ for (const match of gradle.text.matchAll(
148
+ /\bid\(\s*"([^"]+)"\s*\)|\bkotlin\(\s*"([^"]+)"\s*\)/g,
149
+ )) {
150
+ const plugin = match[1] ?? `org.jetbrains.kotlin.${match[2]}`
151
+ if (!PLUGINS.has(plugin))
152
+ throw new Error(
153
+ `${at(match.index)}: plugin ${plugin} is not supported by peach kotlin`,
154
+ )
155
+ }
156
+ for (const block of gradle.text.matchAll(/\bdependencies\s*\{/g)) {
157
+ // the block runs to its matching brace, on one line or many; every
158
+ // statement in it must be a literal dependency Peach can check
159
+ const start = block.index + block[0].length
160
+ let depth = 1
161
+ let end = start
162
+ for (; end < gradle.text.length && depth > 0; end++) {
163
+ if (gradle.text[end] === '{') depth++
164
+ else if (gradle.text[end] === '}') depth--
165
+ }
166
+ if (depth > 0) throw new Error(`${at(block.index)}: dependencies block never closes`)
167
+ const body = gradle.text.slice(start, end - 1)
168
+ for (const statement of body.matchAll(/[^\n;]+/g)) {
169
+ const text = statement[0].replace(/\/\/.*$/, '').trim()
170
+ if (!text) continue
171
+ const where = at(start + statement.index)
172
+ const call = /^(\w+)\s*\(\s*(.*?)\s*\)$/.exec(text)
173
+ if (!call) throw new Error(`${where}: ${text} is not a literal dependency`)
174
+ const [, configuration = '', value = ''] = call
175
+ if (/^(test|androidTest|debug|release)/.test(configuration)) continue
176
+ const coordinate = /^["']([^"':]+):([^"':]+):([^"']+)["']$/.exec(value)
177
+ if (!coordinate)
178
+ throw new Error(`${where}: ${value} is not a literal group:artifact:version`)
179
+ const [, group = '', artifact, version] = coordinate
180
+ const pin = COMPOSE_PIN[group]
181
+ if (pin) {
182
+ if (version !== pins[pin])
183
+ throw new Error(
184
+ `${where}: ${group}:${artifact}:${version} differs from Peach's Compose ${pin} ${pins[pin]}`,
185
+ )
186
+ } else if (!PLATFORM_GROUPS.has(group))
187
+ throw new Error(`${where}: ${group}:${artifact} is not in Peach's Kotlin closure`)
188
+ }
189
+ }
190
+ }
191
+
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
195
+ 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
+ }
213
+ }
214
+ throw new Error(`${theme} names no style in ${values}`)
215
+ }
216
+
217
+ function enabled(element: XmlElement): boolean {
218
+ return element.attributes[`${ANDROID_NS}enabled`] !== 'false'
219
+ }
220
+
221
+ // PackageParser: a component with an intent filter must declare
222
+ // android:exported from target SDK 31 and defaults to exported below it.
223
+ // launching from the home screen or the shell needs an exported component.
224
+ function exported(element: XmlElement, targetSdk: number, manifestFile: string): boolean {
225
+ const value = element.attributes[`${ANDROID_NS}exported`]
226
+ if (value !== undefined) return value === 'true'
227
+ const filtered = element.children.some((child) => child.name === 'intent-filter')
228
+ if (filtered && targetSdk >= 31)
229
+ throw new Error(
230
+ `${manifestFile}: ${element.attributes[`${ANDROID_NS}name`]} has an intent filter and no android:exported, which fails to install from target SDK 31`,
231
+ )
232
+ return filtered
233
+ }
234
+
235
+ function isLauncher(element: XmlElement): boolean {
236
+ return element.children.some(
237
+ (filter) =>
238
+ filter.name === 'intent-filter' &&
239
+ filter.children.some(
240
+ (child) =>
241
+ child.name === 'action' && child.attributes[`${ANDROID_NS}name`] === MAIN,
242
+ ) &&
243
+ filter.children.some(
244
+ (child) =>
245
+ child.name === 'category' && child.attributes[`${ANDROID_NS}name`] === LAUNCHER,
246
+ ),
247
+ )
248
+ }
249
+
250
+ export function readKotlinApp(
251
+ moduleArg: string,
252
+ options: { activity?: string; pins: ComposeAndroidPins },
253
+ ): KotlinAppProject {
254
+ const moduleDir = fs.realpathSync(path.resolve(moduleArg))
255
+ const manifestFile = path.join(moduleDir, 'src/main/AndroidManifest.xml')
256
+ if (!fs.existsSync(manifestFile))
257
+ throw new Error(
258
+ `${moduleDir} is not an Android app module: no src/main/AndroidManifest.xml`,
259
+ )
260
+ let projectDir = moduleDir
261
+ while (
262
+ !['settings.gradle.kts', 'settings.gradle'].some((name) =>
263
+ fs.existsSync(path.join(projectDir, name)),
264
+ )
265
+ ) {
266
+ const parent = path.dirname(projectDir)
267
+ if (parent === projectDir)
268
+ throw new Error(`${moduleDir} has no settings.gradle above it`)
269
+ projectDir = parent
270
+ }
271
+ const gradle = readGradle(moduleDir)
272
+ if (!gradle) throw new Error(`${moduleDir} has no build.gradle`)
273
+ checkModuleGradle(gradle, options.pins)
274
+ const namespace = gradleLiteral(gradle, 'namespace', '"[\\w.]+"')
275
+ const applicationId = gradleLiteral(gradle, 'applicationId', '"[\\w.]+"')
276
+ const targetSdk = Number(gradleLiteral(gradle, 'targetSdk(?:Version)?', '\\d+'))
277
+ if (!Number.isInteger(targetSdk) || targetSdk <= 0)
278
+ throw new Error(`${gradle.file}: declares no literal targetSdk`)
279
+ const kotlin = kotlinPluginVersion(projectDir, gradle)
280
+ const language = /^(\d+)\.(\d+)/.exec(kotlin)?.slice(1, 3).join('.')
281
+ if (!language)
282
+ throw new Error(`Kotlin plugin version ${kotlin} names no language version`)
283
+
284
+ const manifest = parseXml(fs.readFileSync(manifestFile, 'utf8'), manifestFile)
285
+ const root = manifest.children.find((element) => element.name === 'manifest')
286
+ const application = root?.children.find((element) => element.name === 'application')
287
+ if (!root || !application) throw new Error(`${manifestFile} has no <application>`)
288
+ const base = namespace ?? root.attributes.package
289
+ if (!base)
290
+ throw new Error(`${gradle.file}: declares no namespace and the manifest no package`)
291
+ const className = (name: string) =>
292
+ name.startsWith('.') ? base + name : name.includes('.') ? name : `${base}.${name}`
293
+ if (application.attributes[`${ANDROID_NS}appComponentFactory`])
294
+ throw new Error(
295
+ `${manifestFile}: android:appComponentFactory is not supported by peach kotlin`,
296
+ )
297
+ for (const element of application.children) {
298
+ if (element.name === 'provider')
299
+ throw new Error(
300
+ `${manifestFile}: <provider ${element.attributes[`${ANDROID_NS}name`]}> is not supported by peach kotlin`,
301
+ )
302
+ if (element.attributes[`${ANDROID_NS}process`])
303
+ throw new Error(
304
+ `${manifestFile}: android:process on ${element.attributes[`${ANDROID_NS}name`]} is not supported by peach kotlin`,
305
+ )
306
+ }
307
+ if (application.attributes[`${ANDROID_NS}process`])
308
+ throw new Error(
309
+ `${manifestFile}: android:process on <application> is not supported by peach kotlin`,
310
+ )
311
+ for (const [key, value] of [
312
+ ...[root, application].flatMap((element) => Object.entries(element.attributes)),
313
+ ])
314
+ if (value.includes('${'))
315
+ throw new Error(`${manifestFile}: ${key}="${value}" is a Gradle placeholder`)
316
+
317
+ const activities = application.children.filter((element) => element.name === 'activity')
318
+ const components = application.children.filter(
319
+ (element) => element.name === 'activity' || element.name === 'activity-alias',
320
+ )
321
+ const target = (element: XmlElement) => {
322
+ const name = element.attributes[`${ANDROID_NS}name`]
323
+ if (!name) throw new Error(`${manifestFile}: <${element.name}> has no android:name`)
324
+ if (element.name === 'activity') return className(name)
325
+ const targetName = element.attributes[`${ANDROID_NS}targetActivity`]
326
+ const activity = activities.find(
327
+ (candidate) =>
328
+ className(candidate.attributes[`${ANDROID_NS}name`] ?? '') ===
329
+ className(targetName ?? ''),
330
+ )
331
+ if (!targetName || !activity)
332
+ throw new Error(
333
+ `${manifestFile}: activity-alias ${name} targets no declared activity`,
334
+ )
335
+ if (!enabled(activity)) return null
336
+ return className(targetName)
337
+ }
338
+ const applicationEnabled = enabled(application)
339
+ let chosen: XmlElement | undefined
340
+ if (options.activity) {
341
+ const wanted = className(options.activity)
342
+ chosen = components.find(
343
+ (element) => className(element.attributes[`${ANDROID_NS}name`] ?? '') === wanted,
344
+ )
345
+ if (!chosen)
346
+ throw new Error(`Error type 3: Activity class {${base}/${wanted}} does not exist.`)
347
+ if (!applicationEnabled || !enabled(chosen) || target(chosen) === null)
348
+ throw new Error(`Activity {${base}/${wanted}} is disabled`)
349
+ if (!exported(chosen, targetSdk, manifestFile))
350
+ throw new Error(`Permission Denial: ${base}/${wanted} is not exported`)
351
+ } else {
352
+ const launchers = applicationEnabled
353
+ ? components.filter(
354
+ (element) =>
355
+ enabled(element) &&
356
+ isLauncher(element) &&
357
+ exported(element, targetSdk, manifestFile) &&
358
+ target(element) !== null,
359
+ )
360
+ : []
361
+ if (launchers.length === 0)
362
+ throw new Error(
363
+ `${manifestFile}: no enabled activity resolves the MAIN/LAUNCHER intent`,
364
+ )
365
+ if (launchers.length > 1)
366
+ throw new Error(
367
+ `${manifestFile}: ${launchers.map((element) => element.attributes[`${ANDROID_NS}name`]).join(', ')} all resolve the launcher intent; pick one with --activity`,
368
+ )
369
+ chosen = launchers[0]
370
+ }
371
+ const launcherName = className(chosen?.attributes[`${ANDROID_NS}name`] ?? '')
372
+ const targetClass = chosen ? target(chosen) : null
373
+ if (!targetClass) throw new Error(`${launcherName} targets a disabled activity`)
374
+ const themeOwner = activities.find(
375
+ (element) => className(element.attributes[`${ANDROID_NS}name`] ?? '') === targetClass,
376
+ )
377
+ const theme =
378
+ themeOwner?.attributes[`${ANDROID_NS}theme`] ??
379
+ application.attributes[`${ANDROID_NS}theme`]
380
+ const applicationName = application.attributes[`${ANDROID_NS}name`]
381
+
382
+ const sources: string[] = []
383
+ for (const set of ['java', 'kotlin']) {
384
+ const dir = path.join(moduleDir, 'src/main', set)
385
+ if (!fs.existsSync(dir)) continue
386
+ const walk = (current: string) => {
387
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
388
+ const full = path.join(current, entry.name)
389
+ if (entry.isDirectory()) walk(full)
390
+ else if (entry.name.endsWith('.java'))
391
+ throw new Error(`${full}: Java sources are not supported by peach kotlin`)
392
+ else if (entry.name.endsWith('.kt')) sources.push(full)
393
+ }
394
+ }
395
+ walk(dir)
396
+ }
397
+ if (sources.length === 0)
398
+ throw new Error(`${moduleDir}/src/main holds no Kotlin sources`)
399
+ return {
400
+ projectDir,
401
+ moduleDir,
402
+ sources: sources.sort(),
403
+ language,
404
+ packageName: applicationId ?? base,
405
+ namespace: base,
406
+ targetSdk,
407
+ optOutEdgeToEdge: themeOptsOut(moduleDir, theme),
408
+ applicationClass: applicationName ? className(applicationName) : null,
409
+ launcher: { name: launcherName, targetClass },
410
+ }
411
+ }
@@ -12,6 +12,7 @@ import path from 'node:path'
12
12
  import { promisify } from 'node:util'
13
13
  import { rnxHomeDir } from './home-paths'
14
14
  import { isRecord } from './is-record'
15
+ import type { KotlinAppProject } from './kotlin-app'
15
16
  import type { ServerResponse } from 'node:http'
16
17
 
17
18
  const run = promisify(execFile)
@@ -192,11 +193,16 @@ const SKIP_DIRS = new Set([
192
193
  'scripts',
193
194
  ])
194
195
 
195
- export function kotlinSources(root: string): string[] {
196
+ // peach's own platform sources live in package dirs such as `android`, so
197
+ // they walk with no skip set
198
+ export function kotlinSources(
199
+ root: string,
200
+ skip: ReadonlySet<string> = SKIP_DIRS,
201
+ ): string[] {
196
202
  const sources: string[] = []
197
203
  const walk = (dir: string) => {
198
204
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
199
- if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue
205
+ if (entry.name.startsWith('.') || skip.has(entry.name)) continue
200
206
  const full = path.join(dir, entry.name)
201
207
  if (entry.isDirectory()) walk(full)
202
208
  else if (entry.isFile() && entry.name.endsWith('.kt')) sources.push(full)
@@ -256,7 +262,7 @@ function entrySource(ids: string[]): string {
256
262
  ].join('\n')
257
263
  }
258
264
 
259
- const BUILD_FORMAT = 4
265
+ const BUILD_FORMAT = 7
260
266
  const builds = new Map<string, Promise<KotlinArtifact>>()
261
267
 
262
268
  function buildsDir(): string {
@@ -278,18 +284,105 @@ export async function kotlinArtifact(
278
284
  if (sources.length === 0) throw new Error(`${root} holds no Kotlin sources`)
279
285
  const generated: Record<string, string> = {}
280
286
  const ids: string[] = []
287
+ const viewIds: string[] = []
281
288
  let usesCompose = false
282
289
  for (const file of sources) {
283
290
  const source = fs.readFileSync(file, 'utf8')
284
291
  if (/\bandroidx\.compose\./.test(source)) usesCompose = true
285
292
  const contract = nativeSourceContract(file, source)
286
- if (contract.modules.length === 0) continue
293
+ if (contract.modules.length === 0 && contract.views.length === 0) continue
287
294
  const id = kotlinSourceId(root, file)
288
- ids.push(id)
295
+ if (contract.modules.length > 0) ids.push(id)
296
+ if (contract.views.length > 0) viewIds.push(id)
289
297
  generated[`OneNativeSource_${id}.kt`] = renderKotlinSourceGlue(id, contract).source
290
298
  }
291
299
  generated['PeachEntry.kt'] = entrySource(ids)
292
- const shims = kotlinSources(path.join(moduleRoot, 'Sources'))
300
+ if (viewIds.length > 0) generated['PeachViews.kt'] = viewsSource(viewIds)
301
+ const shims = kotlinSources(path.join(moduleRoot, 'Sources')).filter(
302
+ (file) => usesCompose || !file.endsWith('/OneNativeSourceViewDispatch.kt'),
303
+ )
304
+ if (viewIds.length > 0)
305
+ shims.push(...kotlinSources(path.join(moduleRoot, 'ComposeSources')))
306
+ return compileKotlin(
307
+ { root, toolchain, language, sources, generated, shims, usesCompose },
308
+ origin,
309
+ )
310
+ }
311
+
312
+ function viewsSource(viewIds: string[]): string {
313
+ return [
314
+ 'import dev.onejs.one.OneNativeSourceViewDispatch',
315
+ '',
316
+ 'fun peachViewDispatcher(source: String): OneNativeSourceViewDispatch = when (source) {',
317
+ ...viewIds.map((id) => ` ${JSON.stringify(id)} -> OneNativeSourceViews_${id}()`),
318
+ ' else -> error("composable source $source is not linked; rebuild the app")',
319
+ '}',
320
+ ].join('\n')
321
+ }
322
+
323
+ // a pure Kotlin app: its module's sources, the Android platform actuals and
324
+ // the compose host, with an entry that cold-starts the resolved launcher
325
+ export async function kotlinAppArtifact(
326
+ app: KotlinAppProject,
327
+ moduleRoot: string,
328
+ origin: string,
329
+ ): Promise<KotlinArtifact> {
330
+ const toolchain = readToolchain(moduleRoot)
331
+ const kotlinString = (value: string) => JSON.stringify(value).replaceAll('$', '\\$')
332
+ const generated: Record<string, string> = {
333
+ 'PeachEntry.kt': entrySource([]),
334
+ 'PeachViews.kt': viewsSource([]),
335
+ 'PeachAppEntry.kt': [
336
+ '@file:OptIn(kotlin.js.ExperimentalJsExport::class)',
337
+ '',
338
+ '@JsExport',
339
+ 'fun peachAppMount(density: Float, fontScale: Float, locale: String, backend: String, stencilBits: Int,',
340
+ ' regular: String, medium: String, bold: String, statusBar: Int, navigationBar: Int,',
341
+ ' invalidate: (Boolean) -> Unit): String = dev.peach.app.peachStartApp(',
342
+ ' dev.peach.app.PeachAppSpec(',
343
+ ` android.content.ComponentName(${kotlinString(app.packageName)}, ${kotlinString(app.launcher.name)}),`,
344
+ ` android.content.ComponentName(${kotlinString(app.packageName)}, ${kotlinString(app.launcher.targetClass)}),`,
345
+ ` ${app.targetSdk}, ${app.optOutEdgeToEdge},`,
346
+ ` { ${app.applicationClass ?? 'android.app.Application'}() },`,
347
+ ` { ${app.launcher.targetClass}() },`,
348
+ ' ),',
349
+ ' density, fontScale, locale, backend, stencilBits, regular, medium, bold, statusBar, navigationBar, invalidate,',
350
+ ')',
351
+ '',
352
+ ].join('\n'),
353
+ }
354
+ const shims = [
355
+ ...kotlinSources(path.join(moduleRoot, 'Sources')),
356
+ ...kotlinSources(path.join(moduleRoot, 'ComposeSources')),
357
+ ...kotlinSources(path.join(moduleRoot, 'AppSources'), new Set()),
358
+ ]
359
+ return compileKotlin(
360
+ {
361
+ root: app.projectDir,
362
+ toolchain,
363
+ language: app.language,
364
+ sources: app.sources,
365
+ generated,
366
+ shims,
367
+ usesCompose: true,
368
+ },
369
+ origin,
370
+ )
371
+ }
372
+
373
+ async function compileKotlin(
374
+ input: {
375
+ root: string
376
+ toolchain: KotlinToolchain
377
+ language: string
378
+ sources: string[]
379
+ generated: Record<string, string>
380
+ shims: string[]
381
+ usesCompose: boolean
382
+ },
383
+ origin: string,
384
+ ): Promise<KotlinArtifact> {
385
+ const { root, toolchain, language, sources, generated, shims, usesCompose } = input
293
386
  const digest = createHash('sha256')
294
387
  // BUILD_FORMAT moves when this host changes what it does to a build
295
388
  digest.update(JSON.stringify({ format: BUILD_FORMAT, toolchain, language }))
@@ -424,25 +517,46 @@ export async function kotlinArtifact(
424
517
  `kotlin compile failed:\n${output.trim() || (error instanceof Error ? error.message : String(error))}`,
425
518
  )
426
519
  }
427
- // Kotlin's loader picks node when `process.release.name` says so; the
428
- // Peach tenant defines a react native `process` with no release, so that
429
- // read throws before the browser branch runs. the loader keeps its
430
- // browser path when the read is optional.
431
- let patched = 0
432
- for (const file of fs.readdirSync(path.join(work, 'js'))) {
433
- if (!file.endsWith('.mjs')) continue
434
- const target = path.join(work, 'js', file)
435
- const text = fs.readFileSync(target, 'utf8')
436
- const next = text.replaceAll('process.release.name', 'process.release?.name')
437
- if (next !== text) {
438
- patched++
439
- fs.writeFileSync(target, next)
520
+ // the Kotlin module shares the tenant's react native globals, which a
521
+ // Kotlin process never sees. Kotlin's loader picks node when
522
+ // `process.release.name` says so, and react native's `process` has no
523
+ // release, so the read becomes optional. kotlinx-coroutines dispatches
524
+ // through `window.postMessage(msg, '*')` when it finds a window with
525
+ // `addEventListener`, which the tenant's window has and a worker cannot
526
+ // honor, so the module takes the setTimeout dispatcher a worker gets.
527
+ const seams: Array<[string, string]> = [
528
+ ['process.release.name', 'process.release?.name'],
529
+ ]
530
+ const js = fs
531
+ .readdirSync(path.join(work, 'js'))
532
+ .filter((file) => file.endsWith('.mjs'))
533
+ if (
534
+ js.some((file) =>
535
+ fs
536
+ .readFileSync(path.join(work, 'js', file), 'utf8')
537
+ .includes("'kotlinx.coroutines."),
538
+ )
539
+ )
540
+ seams.push([
541
+ "(typeof(window) !== 'undefined' && window != null && typeof(window.addEventListener) === 'function') ? window : null",
542
+ 'null',
543
+ ])
544
+ for (const [from, to] of seams) {
545
+ let patched = 0
546
+ for (const file of js) {
547
+ const target = path.join(work, 'js', file)
548
+ const text = fs.readFileSync(target, 'utf8')
549
+ const next = text.replaceAll(from, to)
550
+ if (next !== text) {
551
+ patched++
552
+ fs.writeFileSync(target, next)
553
+ }
440
554
  }
555
+ if (patched === 0)
556
+ throw new Error(
557
+ `kotlin output no longer contains ${from}; recheck how it detects its host in Peach`,
558
+ )
441
559
  }
442
- if (patched === 0)
443
- throw new Error(
444
- 'kotlin loader no longer reads process.release.name; recheck its node detection in Peach',
445
- )
446
560
  fs.renameSync(work, out)
447
561
  return artifact
448
562
  })().finally(() => builds.delete(hash))
@@ -502,13 +616,22 @@ export function peachKotlinModuleRoot(root: string): string {
502
616
  export async function serveKotlinRequest(
503
617
  pathname: string,
504
618
  res: ServerResponse,
505
- app: { root: string; moduleRoot: string; origin: string; hmrPath: string },
619
+ app: {
620
+ root: string
621
+ moduleRoot: string
622
+ origin: string
623
+ hmrPath: string
624
+ // a pure Kotlin app (peach kotlin); otherwise the root's imported sources
625
+ project?: KotlinAppProject
626
+ },
506
627
  ): Promise<boolean> {
507
628
  if (serveKotlinArtifact(pathname, res)) return true
508
629
  if (pathname !== KOTLIN_PACKAGE_PATH) return false
509
630
  res.setHeader('access-control-allow-origin', '*')
510
631
  try {
511
- const artifact = await kotlinArtifact(app.root, app.moduleRoot, app.origin)
632
+ const artifact = app.project
633
+ ? await kotlinAppArtifact(app.project, app.moduleRoot, app.origin)
634
+ : await kotlinArtifact(app.root, app.moduleRoot, app.origin)
512
635
  res.setHeader('content-type', 'application/json')
513
636
  res.end(JSON.stringify({ ...artifact, hmrPath: app.hmrPath }))
514
637
  } catch (error) {
@@ -529,6 +529,11 @@ export function writeSwiftProject(
529
529
  ): void {
530
530
  const targets = compileTargets(input)
531
531
  const files = new Map<string, string>()
532
+ // swiftpm plans a build root's build once and reuses the plan until a
533
+ // manifest changes, so a file added to the module would never compile
534
+ // there (RAN: a broken new UIKitCore file built "complete" in 0.1s). the
535
+ // module's file list in the manifest replans exactly when it changes.
536
+ const moduleFiles = `\n// sootsim-swift files ${createHash('sha256').update(swiftModuleInputFiles(moduleRoot).join('\n')).digest('hex').slice(0, 16)}\n`
532
537
  if (targets.length === 0) {
533
538
  for (const [file, contents] of Object.entries(input.files)) {
534
539
  if (file === 'Package.swift') continue
@@ -552,7 +557,7 @@ export function writeSwiftProject(
552
557
  .replace(product, `${product}\n ${ONE_PRODUCT},`)
553
558
  .replace(flags, `${flags}${ONE_IMPLICIT_FLAGS}`)
554
559
  }
555
- files.set(path.join(layout.root, 'Package.swift'), manifest)
560
+ files.set(path.join(layout.root, 'Package.swift'), manifest + moduleFiles)
556
561
  } else {
557
562
  for (const [file, contents] of Object.entries(input.files)) {
558
563
  if (file === 'Package.swift') continue
@@ -582,7 +587,7 @@ export function writeSwiftProject(
582
587
  siblings: swiftSiblingDependencies(input.files, targets),
583
588
  moduleRoot,
584
589
  oneModule: input.oneModule,
585
- }),
590
+ }) + moduleFiles,
586
591
  )
587
592
  }
588
593
  syncSwiftTree(layout.root, files)