peachsim 0.1.649 → 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.
- package/README.md +10 -9
- package/cli/command-registry.ts +2 -1
- package/cli/commands/guest-host.ts +67 -0
- package/cli/commands/kotlin.ts +231 -0
- package/cli/commands/project-report.ts +1 -1
- package/cli/commands/report-issue.ts +55 -32
- package/cli/commands/setup.ts +10 -14
- package/cli/commands/support.ts +399 -0
- package/cli/commands/swift.ts +6 -52
- package/cli/commands/telemetry.ts +10 -13
- package/cli/main.ts +22 -10
- package/cli/outbound-endpoints.ts +23 -2
- package/cli/privacy.ts +23 -25
- package/cli/runtime-notes.ts +3 -2
- package/cli/telemetry.ts +109 -14
- 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 +1 -1
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +3 -3
- package/dist-lib/host/bridge-host.cjs +1 -1
- 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 +1 -1
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/kotlin-host.cjs +93 -28
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.mjs +1 -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 +1 -1
- package/dist-lib/native-app-identity.cjs +1 -1
- package/dist-lib/native-app-identity.mjs +1 -1
- 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 +1 -1
- package/dist-lib/sdk.mjs +1 -1
- package/dist-lib/skills.cjs +15 -26
- package/dist-lib/swift-compile.cjs +6 -3
- package/dist-lib/swift-compile.mjs +6 -3
- 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 +1 -1
- package/dist-lib/vite-swift.cjs +6 -3
- package/dist-lib/vite.cjs +1 -1
- package/kotlin/AppSources/android/app/Activity.kt +85 -0
- package/kotlin/AppSources/android/app/Application.kt +7 -0
- package/kotlin/AppSources/android/content/Context.kt +5 -0
- package/kotlin/AppSources/android/content/Intent.kt +16 -0
- package/kotlin/AppSources/android/os/Bundle.kt +5 -0
- package/kotlin/AppSources/android/util/SuperNotCalledException.kt +3 -0
- package/kotlin/AppSources/android/view/View.kt +72 -0
- package/kotlin/AppSources/android/view/Window.kt +44 -0
- package/kotlin/AppSources/android/widget/FrameLayout.kt +6 -0
- package/kotlin/AppSources/androidx/activity/ComponentActivity.kt +57 -0
- package/kotlin/AppSources/androidx/activity/compose/ComponentActivity.kt +29 -0
- package/kotlin/AppSources/androidx/compose/ui/platform/ComposeView.kt +67 -0
- package/kotlin/AppSources/dev/peach/app/PeachApp.kt +136 -0
- package/kotlin/AppSources/dev/peach/app/PeachWindowHost.kt +13 -0
- package/kotlin/ComposeSources/PeachComposeHost.kt +58 -37
- package/package.json +1 -1
- package/skills/contrast/SKILL.md +2 -1
- package/skills/peach-setup/SKILL.md +2 -2
- package/src/{swift-guest.ts → guest-bundle.ts} +22 -7
- package/src/home-paths.ts +5 -3
- package/src/kotlin-app.ts +411 -0
- package/src/kotlin-host.ts +139 -32
- package/src/swift-compile.ts +7 -2
package/skills/contrast/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
2
|
-
// the author supplies Swift;
|
|
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-
|
|
27
|
+
name: 'peach-guest-entry.js',
|
|
13
28
|
code: `const { AppRegistry } = require(1)
|
|
14
|
-
const
|
|
15
|
-
|
|
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:
|
|
25
|
-
code: `module.exports = global.__contrastHostModules[
|
|
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 :
|
|
273
|
-
crashReports: typeof crashReports === 'boolean' ? crashReports :
|
|
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
|
|
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('"', '"').replaceAll('&', '&')
|
|
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
|
+
}
|