catalyst-core 1.1.0 → 1.2.0-canary.2

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 (69) hide show
  1. package/bin/catalyst.js +54 -6
  2. package/changelog.md +27 -1
  3. package/dist/errors/registry.js +306 -18
  4. package/dist/errors-index.json +273 -70
  5. package/dist/native/androidProject/app/build.gradle.kts +53 -79
  6. package/dist/native/androidProject/app/src/main/AndroidManifest.xml +1 -1
  7. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CatalystConstants.kt +3 -1
  8. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +32 -4
  9. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +5 -1
  10. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/ConfigMappingTest.kt +25 -0
  11. package/dist/native/androidSetup.js +76 -170
  12. package/dist/native/androidToolchain.js +642 -0
  13. package/dist/native/appIcon.js +100 -0
  14. package/dist/native/buildAndroid/assets.js +29 -5
  15. package/dist/native/buildAndroid/build.js +110 -223
  16. package/dist/native/buildAndroid/config.js +57 -65
  17. package/dist/native/buildAndroid/devices.js +176 -0
  18. package/dist/native/buildAndroid/index.js +271 -46
  19. package/dist/native/buildAndroid/output.js +43 -0
  20. package/dist/native/buildAndroid/signing.js +456 -0
  21. package/dist/native/buildAppAndroid.js +27 -7
  22. package/dist/native/buildAppIos.js +139 -20
  23. package/dist/native/buildIos/assets.js +76 -4
  24. package/dist/native/buildIos/build.js +31 -181
  25. package/dist/native/buildIos/config.js +126 -11
  26. package/dist/native/buildIos/index.js +354 -34
  27. package/dist/native/buildIos/localhostCert.js +87 -0
  28. package/dist/native/buildIos/output.js +28 -0
  29. package/dist/native/buildIos/signing.js +596 -0
  30. package/dist/native/credentials.js +309 -0
  31. package/dist/native/exportCredentials.js +51 -0
  32. package/dist/native/iosToolchain.js +583 -0
  33. package/dist/native/iosnativeWebView/Configurations/Shared.xcconfig +1 -3
  34. package/dist/native/iosnativeWebView/iosnativeWebView/Info-Release.plist +2 -0
  35. package/dist/native/iosnativeWebView/iosnativeWebView/PrivacyInfo.xcprivacy +40 -0
  36. package/dist/native/iosnativeWebView/iosnativeWebView/iosnativeWebView.entitlements +0 -2
  37. package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +4 -4
  38. package/dist/native/localState.js +148 -0
  39. package/dist/native/nativeConfig.js +621 -0
  40. package/dist/native/releaseOutput.js +53 -0
  41. package/dist/native/renameAndroidProject.js +154 -369
  42. package/dist/native/setupEmulatorIos.js +58 -275
  43. package/dist/native/setupServer.js +520 -124
  44. package/dist/native/utils.js +418 -115
  45. package/dist/vite/resolveDevServerConfig.js +21 -0
  46. package/mcp_v2/knowledge-base.json +13 -11
  47. package/mcp_v2/test/config.test.ts +40 -0
  48. package/mcp_v2/tools/build.js +80 -104
  49. package/mcp_v2/tools/config.js +181 -174
  50. package/package.json +1 -1
  51. package/test/cli/nativeFlags.test.ts +97 -0
  52. package/test/native/androidDevices.test.ts +230 -0
  53. package/test/native/androidElfAlignment.test.ts +148 -0
  54. package/test/native/androidSigning.test.ts +646 -0
  55. package/test/native/androidToolchain.test.ts +766 -0
  56. package/test/native/appIcon.test.ts +124 -0
  57. package/test/native/credentials.test.ts +241 -0
  58. package/test/native/interaction.test.ts +179 -0
  59. package/test/native/iosConfigPhase.test.ts +611 -0
  60. package/test/native/iosSigning.test.ts +1060 -0
  61. package/test/native/iosToolchain.test.ts +821 -0
  62. package/test/native/localState.test.ts +80 -0
  63. package/test/native/nativeConfig.test.ts +602 -0
  64. package/test/native/setupServer.test.ts +529 -0
  65. package/test/native/utils.test.ts +102 -127
  66. package/test/vite/resolveDevServerConfig.test.js +25 -0
  67. package/dist/native/androidProject/keystore/Untitled +0 -0
  68. package/dist/native/androidProject/keystore/release-key.jks +0 -0
  69. package/dist/native/iosnativeWebView/iosnativeWebView/localhost.p12 +0 -0
@@ -0,0 +1,230 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import {
4
+ chooseAndroidTarget,
5
+ devServerReachPlan,
6
+ launchArgs,
7
+ parseAdbDevices,
8
+ reachDevServer,
9
+ } from "../../src/native/buildAndroid/devices.js"
10
+ import { devServerPorts } from "../../src/native/setupServer.js"
11
+
12
+ const ADB_OUTPUT = [
13
+ "List of devices attached",
14
+ "emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1",
15
+ "R5CT1234ABC device usb:1-1 product:o1sxeea model:SM_G991B device:o1s transport_id:2",
16
+ "1A2B3C4D unauthorized usb:1-2 transport_id:3",
17
+ "",
18
+ ].join("\n")
19
+
20
+ const devices = parseAdbDevices(ADB_OUTPUT)
21
+ const phone = { serial: "R5CT1234ABC", state: "device", emulator: false, model: "SM G991B" }
22
+ const secondPhone = { serial: "HT7A1B2C3", state: "device", emulator: false, model: "Pixel 7" }
23
+ const emulatorOnly = devices.filter((device) => device.emulator)
24
+
25
+ async function rejection(promise: Promise<unknown>): Promise<any> {
26
+ try {
27
+ await promise
28
+ } catch (error) {
29
+ return error
30
+ }
31
+ throw new Error("expected a rejection")
32
+ }
33
+
34
+ /** A select() stand-in that records the question and answers `pick`, or the default. */
35
+ function fakeSelect(pick?: string) {
36
+ const asked: Array<{ question: string; values: string[]; defaultValue: unknown }> = []
37
+ const select = async (question: string, choices: Array<{ value: string }>, options: any = {}) => {
38
+ asked.push({ question, values: choices.map((c) => c.value), defaultValue: options.defaultValue })
39
+ return pick ?? options.defaultValue
40
+ }
41
+ return { asked, select: select as any }
42
+ }
43
+
44
+ describe("parseAdbDevices", () => {
45
+ it("reads serial, state, emulator and model", () => {
46
+ expect(devices).toEqual([
47
+ { serial: "emulator-5554", state: "device", emulator: true, model: "sdk gphone64 arm64" },
48
+ phone,
49
+ { serial: "1A2B3C4D", state: "unauthorized", emulator: false, model: null },
50
+ ])
51
+ expect(parseAdbDevices("List of devices attached\n\n")).toEqual([])
52
+ })
53
+ })
54
+
55
+ describe("chooseAndroidTarget", () => {
56
+ it("uses the emulator by default, even with a phone plugged in, when there is no terminal", async () => {
57
+ const { asked, select } = fakeSelect()
58
+ expect(await chooseAndroidTarget({ devices, isTTY: false, select })).toEqual({ target: "emulator" })
59
+ expect(asked).toEqual([])
60
+ })
61
+
62
+ it("uses the emulator without asking when no phone is connected", async () => {
63
+ const { asked, select } = fakeSelect()
64
+ expect(await chooseAndroidTarget({ devices: emulatorOnly, isTTY: true, select })).toEqual({
65
+ target: "emulator",
66
+ })
67
+ expect(asked).toEqual([])
68
+ })
69
+
70
+ it("asks in a terminal when both are available, defaulting to the emulator", async () => {
71
+ const { asked, select } = fakeSelect()
72
+ expect(await chooseAndroidTarget({ devices, isTTY: true, select })).toEqual({ target: "emulator" })
73
+ expect(asked).toEqual([
74
+ { question: "Run the app on", values: ["emulator", "R5CT1234ABC"], defaultValue: "emulator" },
75
+ ])
76
+ })
77
+
78
+ it("defaults the question to the remembered phone, and returns the pick", async () => {
79
+ const { asked, select } = fakeSelect()
80
+ const target = await chooseAndroidTarget({
81
+ devices,
82
+ isTTY: true,
83
+ select,
84
+ last: { target: "device", serial: "R5CT1234ABC" },
85
+ })
86
+ expect(asked[0].defaultValue).toBe("R5CT1234ABC")
87
+ expect(target).toEqual({ target: "device", serial: "R5CT1234ABC", model: "SM G991B" })
88
+ })
89
+
90
+ it("ignores a remembered phone that is not connected", async () => {
91
+ const { asked, select } = fakeSelect()
92
+ await chooseAndroidTarget({ devices, isTTY: true, select, last: { target: "device", serial: "gone" } })
93
+ expect(asked[0].defaultValue).toBe("emulator")
94
+ })
95
+
96
+ it("--device uses the one connected phone without asking, terminal or not", async () => {
97
+ for (const isTTY of [true, false]) {
98
+ const { asked, select } = fakeSelect()
99
+ expect(await chooseAndroidTarget({ wantDevice: true, devices, isTTY, select, env: {} })).toEqual({
100
+ target: "device",
101
+ serial: "R5CT1234ABC",
102
+ model: "SM G991B",
103
+ })
104
+ expect(asked).toEqual([])
105
+ }
106
+ })
107
+
108
+ it("--device with no ready phone is ANDROID-011, naming an unauthorized one", async () => {
109
+ const none = await rejection(
110
+ chooseAndroidTarget({ wantDevice: true, devices: emulatorOnly, isTTY: true, env: {} })
111
+ )
112
+ expect(none.code).toBe("ANDROID-011")
113
+ const unauthorized = await rejection(
114
+ chooseAndroidTarget({
115
+ wantDevice: true,
116
+ devices: devices.filter((device) => device.state === "unauthorized"),
117
+ isTTY: false,
118
+ env: {},
119
+ })
120
+ )
121
+ expect(unauthorized.code).toBe("ANDROID-011")
122
+ expect(unauthorized.message).toContain("1A2B3C4D")
123
+ })
124
+
125
+ it("--device with several phones asks in a terminal, with the remembered one first", async () => {
126
+ const { asked, select } = fakeSelect("HT7A1B2C3")
127
+ const target = await chooseAndroidTarget({
128
+ wantDevice: true,
129
+ devices: [phone, secondPhone],
130
+ isTTY: true,
131
+ select,
132
+ env: {},
133
+ last: { target: "device", serial: "HT7A1B2C3" },
134
+ })
135
+ expect(asked[0].values).toEqual(["R5CT1234ABC", "HT7A1B2C3"])
136
+ expect(asked[0].defaultValue).toBe("HT7A1B2C3")
137
+ expect(target).toMatchObject({ target: "device", serial: "HT7A1B2C3" })
138
+ })
139
+
140
+ it("--device with several phones and no terminal fails with NATIVE-005 naming ANDROID_SERIAL", async () => {
141
+ const error = await rejection(
142
+ chooseAndroidTarget({ wantDevice: true, devices: [phone, secondPhone], isTTY: false, env: {} })
143
+ )
144
+ expect(error.code).toBe("NATIVE-005")
145
+ expect(error.hint).toContain("ANDROID_SERIAL")
146
+ })
147
+
148
+ it("--device honours ANDROID_SERIAL, and fails when it names no connected phone", async () => {
149
+ const env = { ANDROID_SERIAL: "HT7A1B2C3" }
150
+ expect(
151
+ await chooseAndroidTarget({ wantDevice: true, devices: [phone, secondPhone], isTTY: false, env })
152
+ ).toMatchObject({ serial: "HT7A1B2C3" })
153
+ const error = await rejection(
154
+ chooseAndroidTarget({ wantDevice: true, devices: [phone], isTTY: false, env })
155
+ )
156
+ expect(error.code).toBe("ANDROID-011")
157
+ })
158
+ })
159
+
160
+ describe("dev server reach (adb reverse)", () => {
161
+ const config = {
162
+ NODE_SERVER_PORT: 3005,
163
+ API_URL: "http://localhost:4000/api",
164
+ WEBVIEW_CONFIG: { port: "3005", apiBaseUrl: "http://127.0.0.1:4000" },
165
+ }
166
+
167
+ it("stops the app, then reverses the server, HMR and every localhost port in config", () => {
168
+ const ports = devServerPorts(config, { hmrPort: 24678 })
169
+ expect(ports).toEqual([3005, 4000, 24678])
170
+ expect(devServerReachPlan("emulator-5554", "com.acme.shop.debug", ports)).toEqual([
171
+ ["-s", "emulator-5554", "shell", "am", "force-stop", "com.acme.shop.debug"],
172
+ ["-s", "emulator-5554", "reverse", "tcp:3005", "tcp:3005"],
173
+ ["-s", "emulator-5554", "reverse", "tcp:4000", "tcp:4000"],
174
+ ["-s", "emulator-5554", "reverse", "tcp:24678", "tcp:24678"],
175
+ ])
176
+ })
177
+
178
+ it("runs the plan in order and tolerates an app that is not running", () => {
179
+ const calls: string[][] = []
180
+ reachDevServer(
181
+ (args) => {
182
+ calls.push(args)
183
+ if (args.includes("force-stop")) throw new Error("not running")
184
+ return ""
185
+ },
186
+ "R5CT1234ABC",
187
+ "com.acme.shop.debug",
188
+ [3005]
189
+ )
190
+ expect(calls.map((args) => args[2])).toEqual(["shell", "reverse"])
191
+ })
192
+
193
+ it("fails with the stage and a hint when a reverse fails", () => {
194
+ let error: any
195
+ try {
196
+ reachDevServer(
197
+ (args) => {
198
+ if (args.includes("reverse")) {
199
+ throw Object.assign(new Error("Command failed"), {
200
+ stderr: "adb: error: cannot bind listener: Address already in use",
201
+ })
202
+ }
203
+ return ""
204
+ },
205
+ "emulator-5554",
206
+ "com.acme.shop.debug",
207
+ [3005]
208
+ )
209
+ } catch (caught) {
210
+ error = caught
211
+ }
212
+ expect(error.message).toContain("Address already in use")
213
+ expect(error.stage).toBe("Connecting the device to the dev server")
214
+ expect(error.hint).toContain("reverse --list")
215
+ })
216
+
217
+ it("launches the app's launcher activity on that device only", () => {
218
+ expect(launchArgs("R5CT1234ABC", "com.acme.shop.debug")).toEqual([
219
+ "-s",
220
+ "R5CT1234ABC",
221
+ "shell",
222
+ "monkey",
223
+ "-p",
224
+ "com.acme.shop.debug",
225
+ "-c",
226
+ "android.intent.category.LAUNCHER",
227
+ "1",
228
+ ])
229
+ })
230
+ })
@@ -0,0 +1,148 @@
1
+ import zlib from "zlib"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ // The repo script that checks a built AAB/APK for 16 KB page alignment (#302).
5
+ import {
6
+ checkNativeAlignment,
7
+ elfLoadAlignments,
8
+ zipEntries,
9
+ } from "../../../../scripts/native-elf-alignment.mjs"
10
+
11
+ const PT_LOAD = 1
12
+ const PT_DYNAMIC = 2
13
+
14
+ /** A tiny ELF file: just the header and program headers of the given types and alignments. */
15
+ function elf(bits: 32 | 64, segments: Array<[type: number, align: number]>) {
16
+ const ehsize = bits === 64 ? 64 : 52
17
+ const phentsize = bits === 64 ? 56 : 32
18
+ const buf = Buffer.alloc(ehsize + phentsize * segments.length)
19
+ buf.writeUInt32BE(0x7f454c46, 0)
20
+ buf[4] = bits === 64 ? 2 : 1
21
+ buf[5] = 1 // little-endian
22
+ buf[6] = 1
23
+ if (bits === 64) {
24
+ buf.writeBigUInt64LE(BigInt(ehsize), 0x20)
25
+ buf.writeUInt16LE(phentsize, 0x36)
26
+ buf.writeUInt16LE(segments.length, 0x38)
27
+ } else {
28
+ buf.writeUInt32LE(ehsize, 0x1c)
29
+ buf.writeUInt16LE(phentsize, 0x2a)
30
+ buf.writeUInt16LE(segments.length, 0x2c)
31
+ }
32
+ segments.forEach(([type, align], i) => {
33
+ const at = ehsize + i * phentsize
34
+ buf.writeUInt32LE(type, at)
35
+ if (bits === 64) buf.writeBigUInt64LE(BigInt(align), at + 48)
36
+ else buf.writeUInt32LE(align, at + 28)
37
+ })
38
+ return buf
39
+ }
40
+
41
+ /** A minimal zip. `stored` entries are written uncompressed, padded so their data starts at `alignTo`. */
42
+ function zip(files: Array<{ name: string; data: Buffer; stored?: boolean; alignTo?: number }>) {
43
+ const parts: Buffer[] = []
44
+ const central: Buffer[] = []
45
+ let offset = 0
46
+ for (const file of files) {
47
+ const name = Buffer.from(file.name)
48
+ const body = file.stored ? file.data : zlib.deflateRawSync(file.data)
49
+ const headerEnd = offset + 30 + name.length
50
+ const pad = file.alignTo ? (file.alignTo - (headerEnd % file.alignTo)) % file.alignTo : 0
51
+ const local = Buffer.alloc(30)
52
+ local.writeUInt32LE(0x04034b50, 0)
53
+ local.writeUInt16LE(file.stored ? 0 : 8, 8)
54
+ local.writeUInt32LE(body.length, 18)
55
+ local.writeUInt32LE(file.data.length, 22)
56
+ local.writeUInt16LE(name.length, 26)
57
+ local.writeUInt16LE(pad, 28)
58
+ const entry = Buffer.alloc(46)
59
+ entry.writeUInt32LE(0x02014b50, 0)
60
+ entry.writeUInt16LE(file.stored ? 0 : 8, 10)
61
+ entry.writeUInt32LE(body.length, 20)
62
+ entry.writeUInt32LE(file.data.length, 24)
63
+ entry.writeUInt16LE(name.length, 28)
64
+ entry.writeUInt32LE(offset, 42)
65
+ central.push(entry, name)
66
+ parts.push(local, name, Buffer.alloc(pad), body)
67
+ offset += 30 + name.length + pad + body.length
68
+ }
69
+ const cd = Buffer.concat(central)
70
+ const end = Buffer.alloc(22)
71
+ end.writeUInt32LE(0x06054b50, 0)
72
+ end.writeUInt16LE(files.length, 8)
73
+ end.writeUInt16LE(files.length, 10)
74
+ end.writeUInt32LE(cd.length, 12)
75
+ end.writeUInt32LE(offset, 16)
76
+ return Buffer.concat([...parts, cd, end])
77
+ }
78
+
79
+ const aligned64 = elf(64, [
80
+ [PT_LOAD, 0x4000],
81
+ [PT_DYNAMIC, 8],
82
+ [PT_LOAD, 0x4000],
83
+ ])
84
+ const unaligned64 = elf(64, [
85
+ [PT_LOAD, 0x4000],
86
+ [PT_LOAD, 0x1000],
87
+ ])
88
+ const unaligned32 = elf(32, [[PT_LOAD, 0x1000]])
89
+
90
+ describe("ELF LOAD alignment", () => {
91
+ it("reads p_align of LOAD segments only, for 64- and 32-bit ELF", () => {
92
+ expect(elfLoadAlignments(aligned64)).toEqual({ bits: 64, aligns: [0x4000, 0x4000] })
93
+ expect(elfLoadAlignments(unaligned32)).toEqual({ bits: 32, aligns: [0x1000] })
94
+ expect(() => elfLoadAlignments(Buffer.from("not an elf file, just some text here......................"))).toThrow(
95
+ /not an ELF/
96
+ )
97
+ })
98
+
99
+ it("reads stored and deflated zip entries", () => {
100
+ const entries = zipEntries(
101
+ zip([
102
+ { name: "a.txt", data: Buffer.from("hello") },
103
+ { name: "b.bin", data: Buffer.from("world"), stored: true },
104
+ ])
105
+ )
106
+ expect(entries.map((entry) => [entry.name, entry.method, entry.read().toString()])).toEqual([
107
+ ["a.txt", 8, "hello"],
108
+ ["b.bin", 0, "world"],
109
+ ])
110
+ })
111
+ })
112
+
113
+ describe("checkNativeAlignment", () => {
114
+ it("passes an AAB whose 64-bit libs are 16 KB aligned, reporting but not failing 4 KB 32-bit libs", () => {
115
+ const aab = zip([
116
+ { name: "base/lib/arm64-v8a/libgood.so", data: aligned64 },
117
+ { name: "base/lib/armeabi-v7a/libold.so", data: unaligned32 },
118
+ { name: "base/assets/notalib.so.txt", data: Buffer.from("x") },
119
+ ])
120
+ const result = checkNativeAlignment(aab)
121
+ expect(result.failures).toEqual([])
122
+ expect(result.libs.map((lib) => [lib.name, lib.ok, lib.required])).toEqual([
123
+ ["base/lib/arm64-v8a/libgood.so", true, true],
124
+ ["base/lib/armeabi-v7a/libold.so", false, false],
125
+ ])
126
+ })
127
+
128
+ it("fails a 64-bit lib with any LOAD segment below 0x4000", () => {
129
+ const result = checkNativeAlignment(
130
+ zip([{ name: "base/lib/x86_64/libimage_processing_util_jni.so", data: unaligned64 }])
131
+ )
132
+ expect(result.failures).toEqual([
133
+ "base/lib/x86_64/libimage_processing_util_jni.so: LOAD segment aligned to 0x1000 (needs 0x4000)",
134
+ ])
135
+ })
136
+
137
+ it("in an APK, fails an uncompressed 64-bit lib that is not on a 16 KB zip boundary", () => {
138
+ const misplaced = zip([{ name: "lib/arm64-v8a/libgood.so", data: aligned64, stored: true, alignTo: 4096 }])
139
+ const placed = zip([{ name: "lib/arm64-v8a/libgood.so", data: aligned64, stored: true, alignTo: 0x4000 }])
140
+ expect(zipEntries(misplaced)[0].dataOffset).toBe(4096)
141
+ expect(checkNativeAlignment(misplaced, { apk: true }).failures).toEqual([
142
+ "lib/arm64-v8a/libgood.so: stored uncompressed at zip offset 4096, not on a 16 KB boundary",
143
+ ])
144
+ expect(checkNativeAlignment(placed, { apk: true }).failures).toEqual([])
145
+ // An AAB is aligned by bundletool later, so its zip offsets are not checked.
146
+ expect(checkNativeAlignment(misplaced).failures).toEqual([])
147
+ })
148
+ })