peachsim 0.1.649 → 0.1.651

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 (102) 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 +35 -2
  13. package/cli/privacy.ts +23 -25
  14. package/cli/runtime-notes.ts +3 -2
  15. package/cli/telemetry.ts +111 -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 +93 -28
  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 +16 -26
  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 +58 -37
  95. package/package.json +1 -1
  96. package/skills/contrast/SKILL.md +2 -1
  97. package/skills/peach-setup/SKILL.md +2 -2
  98. package/src/{swift-guest.ts → guest-bundle.ts} +22 -7
  99. package/src/home-paths.ts +5 -3
  100. package/src/kotlin-app.ts +411 -0
  101. package/src/kotlin-host.ts +139 -32
  102. package/src/swift-compile.ts +7 -2
@@ -20,6 +20,7 @@ automation, inspecting the render tree, and managing sims.
20
20
  | command | description |
21
21
  | --- | --- |
22
22
  | `peach swift` | preview a SwiftPM app with hosted compilation and live Swift edits |
23
+ | `peach kotlin` | run an Android Compose app module from its launcher activity |
23
24
  | `peach maestro` | author, generate, and run Maestro YAML flows against peach |
24
25
  | `peach detox` | run existing Detox suites against peach on headless Chromium |
25
26
  | `peach cleanup` | inspect and reclaim local peach disk usage |
@@ -56,7 +57,7 @@ automation, inspecting the render tree, and managing sims.
56
57
  | `peach setup` | configure optional local conveniences |
57
58
  | `peach compat` | check package compatibility |
58
59
  | `peach project-report` | print local project and toolchain metadata |
59
- | `peach report-issue` | send an explicitly approved compatibility report |
60
+ | `peach support` | ask for help, report a problem, and read our replies |
60
61
  | `peach desktop` | launch or install the desktop companion |
61
62
  | `peach daemon` | manage the recommended background bridge service |
62
63
  | `peach version` | show the CLI + engine runtime versions you are running |
@@ -119,13 +119,13 @@ run `peach compat --json` from the app project root during setup. if the scan
119
119
  identifies a plausible missing Peach seam, or the running app gives concrete
120
120
  evidence that behavior is missing or broken specifically in Peach, summarize
121
121
  the package, expected behavior, actual behavior, and runtime evidence for the
122
- user. then offer to send that finding with `peach report-issue`.
122
+ user. then offer to send that finding with `peach support report`.
123
123
 
124
124
  **never submit a report automatically or behind the user's back.** wait for an
125
125
  explicit yes. only then run this from the app project root:
126
126
 
127
127
  ```sh
128
- peach report-issue --yes "<package; expected behavior; actual behavior; runtime evidence>"
128
+ peach support report --yes "<package; expected behavior; actual behavior; runtime evidence>"
129
129
  ```
130
130
 
131
131
  the command automatically attaches the local compatibility scan plus bounded
@@ -1,5 +1,6 @@
1
- // the built-in guest uses the existing Metro runtime and host module seam.
2
- // the author supplies Swift; this entry carries no engine or compiler code.
1
+ // the built-in guests use the existing Metro runtime and host module seam.
2
+ // the author supplies Swift or Kotlin; an entry carries no engine or compiler
3
+ // code, only the registration of the host module's root.
3
4
  import {
4
5
  appendRNXMetroModuleIdentityFooter,
5
6
  RNX_METRO_MODULE_IDENTITY_VERSION,
@@ -7,12 +8,26 @@ import {
7
8
  import { METRO_REQUIRE_POLYFILL } from './metro-runtime-source'
8
9
 
9
10
  export function swiftGuestBundle(packageName: string): string {
11
+ return guestBundle(
12
+ 'peachsim/swift',
13
+ `AppRegistry.registerComponent('PeachSwift', () => host.createSwiftPreviewEntry(${JSON.stringify(packageName)}))`,
14
+ )
15
+ }
16
+
17
+ export function kotlinGuestBundle(): string {
18
+ return guestBundle(
19
+ 'peachsim/kotlin',
20
+ `AppRegistry.registerComponent('PeachKotlin', () => host.KotlinAppRoot)`,
21
+ )
22
+ }
23
+
24
+ function guestBundle(hostModule: string, registration: string): string {
10
25
  const modules = [
11
26
  {
12
- name: 'peach-swift-entry.js',
27
+ name: 'peach-guest-entry.js',
13
28
  code: `const { AppRegistry } = require(1)
14
- const { createSwiftPreviewEntry } = require(2)
15
- AppRegistry.registerComponent('PeachSwift', () => createSwiftPreviewEntry(${JSON.stringify(packageName)}))`,
29
+ const host = require(2)
30
+ ${registration}`,
16
31
  deps: [1, 2],
17
32
  },
18
33
  {
@@ -21,8 +36,8 @@ AppRegistry.registerComponent('PeachSwift', () => createSwiftPreviewEntry(${JSON
21
36
  deps: [],
22
37
  },
23
38
  {
24
- name: 'peachsim/swift',
25
- code: `module.exports = global.__contrastHostModules['peachsim/swift']`,
39
+ name: hostModule,
40
+ code: `module.exports = global.__contrastHostModules[${JSON.stringify(hostModule)}]`,
26
41
  deps: [],
27
42
  },
28
43
  {
package/src/home-paths.ts CHANGED
@@ -264,13 +264,15 @@ export interface PrivacyPreferences {
264
264
  configured: boolean
265
265
  }
266
266
 
267
+ // bounded diagnostics and automatic crash reports are on until the person turns
268
+ // them off (`peach config telemetry off`, or DO_NOT_TRACK for one run).
267
269
  export function readPrivacyPreferences(): PrivacyPreferences {
268
270
  const settings = readSharedConfig().settings
269
271
  const productAnalytics = settings?.productAnalytics
270
272
  const crashReports = settings?.crashReports
271
273
  return {
272
- productAnalytics: typeof productAnalytics === 'boolean' ? productAnalytics : false,
273
- crashReports: typeof crashReports === 'boolean' ? crashReports : false,
274
+ productAnalytics: typeof productAnalytics === 'boolean' ? productAnalytics : true,
275
+ crashReports: typeof crashReports === 'boolean' ? crashReports : true,
274
276
  configured: typeof productAnalytics === 'boolean',
275
277
  }
276
278
  }
@@ -286,7 +288,7 @@ export function writePrivacyPreferences(
286
288
  })
287
289
  }
288
290
 
289
- // created only after explicit consent, alongside the privacy preferences.
291
+ // created on the first diagnostic sent, alongside the privacy preferences.
290
292
  export function readOrCreateAnonymousId(): string {
291
293
  const existing = readSharedConfig().settings?.anonymousId
292
294
  if (
@@ -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
+ }