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,1060 @@
1
+ import { execFileSync } from "child_process"
2
+ import fs from "fs"
3
+ import { createRequire } from "module"
4
+ import os from "os"
5
+ import path from "path"
6
+ import { fileURLToPath } from "url"
7
+ import { afterEach, describe, expect, it } from "vitest"
8
+
9
+ import {
10
+ buildExportOptions,
11
+ isReleaseBuildType,
12
+ isSigningFailure,
13
+ parseIdentity,
14
+ parseKeychainList,
15
+ parseProperties,
16
+ planIosSigning,
17
+ profileMatchesBundleId,
18
+ readIosCredentials,
19
+ readProvisioningProfile,
20
+ resolveExportMethod,
21
+ setupP12Signing,
22
+ signingSetupError,
23
+ writeExportOptionsPlist,
24
+ xcodeAuthArgs,
25
+ } from "../../src/native/buildIos/signing.js"
26
+ import { releaseOutputDir } from "../../src/native/buildIos/output.js"
27
+ import { packCredentials, resolveCredentials } from "../../src/native/credentials.js"
28
+
29
+ // The CJS build phases load with require, as in releaseConfig.test.ts.
30
+ const require = createRequire(import.meta.url)
31
+ const createIosConfigPhase = require("../../src/native/buildIos/config.js")
32
+ const { generateLocalhostP12 } = require("../../src/native/buildIos/localhostCert.js")
33
+
34
+ const here = path.dirname(fileURLToPath(import.meta.url))
35
+ const shellDir = path.resolve(here, "../../src/native/iosnativeWebView")
36
+ const isMac = process.platform === "darwin"
37
+
38
+ const temps: string[] = []
39
+ function tempDir() {
40
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "catalyst-ios-signing-test-"))
41
+ temps.push(dir)
42
+ return dir
43
+ }
44
+
45
+ afterEach(() => {
46
+ for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true })
47
+ })
48
+
49
+ /** Run fn and return what it threw. */
50
+ function thrown(fn: () => unknown): any {
51
+ try {
52
+ fn()
53
+ } catch (error) {
54
+ return error
55
+ }
56
+ throw new Error("expected a throw")
57
+ }
58
+
59
+ describe("isReleaseBuildType", () => {
60
+ it("accepts release in any case", () => {
61
+ expect(isReleaseBuildType("release")).toBe(true)
62
+ expect(isReleaseBuildType("Release")).toBe(true)
63
+ expect(isReleaseBuildType(" RELEASE ")).toBe(true)
64
+ expect(isReleaseBuildType("Debug")).toBe(false)
65
+ expect(isReleaseBuildType(undefined)).toBe(false)
66
+ })
67
+ })
68
+
69
+ describe("resolveExportMethod", () => {
70
+ it("defaults to app-store-connect and accepts Xcode 26 names", () => {
71
+ expect(resolveExportMethod({})).toBe("app-store-connect")
72
+ expect(resolveExportMethod({ exportMethod: "release-testing" })).toBe("release-testing")
73
+ expect(resolveExportMethod({ exportMethod: "Debugging" })).toBe("debugging")
74
+ })
75
+
76
+ it("maps the old names with a warning", () => {
77
+ const logs: string[] = []
78
+ expect(resolveExportMethod({ exportMethod: "ad-hoc" }, { log: (m: string) => logs.push(m) })).toBe(
79
+ "release-testing"
80
+ )
81
+ expect(resolveExportMethod({ exportMethod: "app-store" })).toBe("app-store-connect")
82
+ expect(logs[0]).toMatch(/old name/)
83
+ })
84
+
85
+ it("fails an unknown method with IOS-013", () => {
86
+ expect(thrown(() => resolveExportMethod({ exportMethod: "testflight" })).code).toBe("IOS-013")
87
+ })
88
+ })
89
+
90
+ /** A credentials/ios/ folder with these files (name -> contents). */
91
+ function credentialsFolder(files: Record<string, string>) {
92
+ const dir = path.join(tempDir(), "credentials", "ios")
93
+ fs.mkdirSync(dir, { recursive: true })
94
+ for (const [name, contents] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), contents)
95
+ return dir
96
+ }
97
+
98
+ describe("readIosCredentials", () => {
99
+ const PEM = "-----BEGIN PRIVATE KEY-----\nMIGT\n-----END PRIVATE KEY-----\n"
100
+
101
+ it("finds nothing in a missing or empty folder", () => {
102
+ expect(readIosCredentials(path.join(tempDir(), "nope"))).toMatchObject({ apiKey: null, p12: null })
103
+ expect(readIosCredentials(credentialsFolder({}))).toMatchObject({ apiKey: null, p12: null })
104
+ })
105
+
106
+ it("reads the API key, taking the key ID from the file name", () => {
107
+ const dir = credentialsFolder({
108
+ "AuthKey_ABC123XYZ.p8": PEM,
109
+ "signing.properties": "# cloud signing\nascIssuerId = 11111111-2222-3333-4444-555555555555\n",
110
+ })
111
+ expect(readIosCredentials(dir).apiKey).toEqual({
112
+ keyPath: path.join(dir, "AuthKey_ABC123XYZ.p8"),
113
+ keyId: "ABC123XYZ",
114
+ issuerId: "11111111-2222-3333-4444-555555555555",
115
+ })
116
+ })
117
+
118
+ it("takes ascKeyId over the file name", () => {
119
+ const dir = credentialsFolder({
120
+ "store.p8": PEM,
121
+ "signing.properties": "ascKeyId=KEY42\nascIssuerId=issuer\n",
122
+ })
123
+ expect(readIosCredentials(dir).apiKey).toMatchObject({ keyId: "KEY42", issuerId: "issuer" })
124
+ })
125
+
126
+ it("fails a partial API key with IOS-011 naming what is missing", () => {
127
+ const noIssuer = thrown(() => readIosCredentials(credentialsFolder({ "AuthKey_ABC.p8": PEM })))
128
+ expect(noIssuer.code).toBe("IOS-011")
129
+ expect(noIssuer.message).toMatch(/issuer ID/)
130
+ expect(noIssuer.message).not.toMatch(/key file/)
131
+ const noKey = thrown(() =>
132
+ readIosCredentials(credentialsFolder({ "signing.properties": "ascIssuerId=issuer\n" }))
133
+ )
134
+ expect(noKey.code).toBe("IOS-011")
135
+ expect(noKey.message).toMatch(/key file/)
136
+ expect(
137
+ thrown(() =>
138
+ readIosCredentials(
139
+ credentialsFolder({
140
+ "AuthKey_A.p8": PEM,
141
+ "AuthKey_B.p8": PEM,
142
+ "signing.properties": "ascIssuerId=i",
143
+ })
144
+ )
145
+ ).message
146
+ ).toMatch(/keep one/)
147
+ expect(thrown(() => readIosCredentials(credentialsFolder({ "AuthKey_A.p8": PEM }))).hint).toMatch(
148
+ /CATALYST_IOS_CREDENTIALS/
149
+ )
150
+ })
151
+
152
+ it("fails a key ID that is not letters and digits with IOS-011", () => {
153
+ const error = thrown(() =>
154
+ readIosCredentials(
155
+ credentialsFolder({
156
+ "store.p8": PEM,
157
+ "signing.properties": "ascKeyId=KEY-42 x\nascIssuerId=issuer\n",
158
+ })
159
+ )
160
+ )
161
+ expect(error.code).toBe("IOS-011")
162
+ expect(error.message).toContain('the key ID "KEY-42 x" should be letters and digits only')
163
+ })
164
+
165
+ it("pairs the .p12 with its profile and masks the password", () => {
166
+ const masked: string[] = []
167
+ const dir = credentialsFolder({
168
+ "dist.p12": "p12",
169
+ "Acme_App_Store.mobileprovision": "profile",
170
+ "signing.properties": "p12Password=hunter2-s3cret\n",
171
+ })
172
+ const found = readIosCredentials(dir, { mask: (value: string) => void masked.push(value) })
173
+ expect(found.p12).toEqual({
174
+ file: path.join(dir, "dist.p12"),
175
+ password: "hunter2-s3cret",
176
+ profileFile: path.join(dir, "Acme_App_Store.mobileprovision"),
177
+ })
178
+ expect(masked).toEqual(["hunter2-s3cret"])
179
+ })
180
+
181
+ it("fails a .p12 without a profile, and a profile without a .p12, with IOS-012", () => {
182
+ expect(thrown(() => readIosCredentials(credentialsFolder({ "dist.p12": "x" }))).code).toBe("IOS-012")
183
+ expect(thrown(() => readIosCredentials(credentialsFolder({ "a.mobileprovision": "x" }))).code).toBe(
184
+ "IOS-012"
185
+ )
186
+ })
187
+
188
+ it("reads the same files from CATALYST_IOS_CREDENTIALS when the folder is missing", () => {
189
+ const source = credentialsFolder({
190
+ "AuthKey_ENVKEY1.p8": PEM,
191
+ "signing.properties": "ascIssuerId=env-issuer\n",
192
+ })
193
+ const value = packCredentials("ios", { cwd: path.dirname(path.dirname(source)) })
194
+ const found = resolveCredentials("ios", {
195
+ cwd: tempDir(),
196
+ env: { CATALYST_IOS_CREDENTIALS: value },
197
+ })
198
+ try {
199
+ expect(found.source).toBe("env")
200
+ const key = readIosCredentials(found.dir).apiKey!
201
+ expect(key).toMatchObject({ keyId: "ENVKEY1", issuerId: "env-issuer" })
202
+ expect(fs.readFileSync(key.keyPath, "utf8")).toBe(PEM)
203
+ } finally {
204
+ found.cleanup()
205
+ }
206
+ expect(fs.existsSync(found.dir)).toBe(false)
207
+ })
208
+
209
+ it("parses the properties subset people write", () => {
210
+ expect(parseProperties("# c\n! c\na=1\nb : two words \n\nbroken line\n")).toEqual({
211
+ a: "1",
212
+ b: "two words",
213
+ })
214
+ })
215
+ })
216
+
217
+ describe("xcodeAuthArgs", () => {
218
+ it("lets Xcode provision with the signed-in account when there is no key", () => {
219
+ expect(xcodeAuthArgs(null)).toEqual([
220
+ "-allowProvisioningUpdates",
221
+ "-allowProvisioningDeviceRegistration",
222
+ ])
223
+ })
224
+
225
+ it("adds the API key flags", () => {
226
+ expect(xcodeAuthArgs({ keyPath: "/k/AuthKey_A.p8", keyId: "A", issuerId: "I" })).toEqual([
227
+ "-allowProvisioningUpdates",
228
+ "-allowProvisioningDeviceRegistration",
229
+ "-authenticationKeyPath",
230
+ "/k/AuthKey_A.p8",
231
+ "-authenticationKeyID",
232
+ "A",
233
+ "-authenticationKeyIssuerID",
234
+ "I",
235
+ ])
236
+ })
237
+ })
238
+
239
+ describe("planIosSigning", () => {
240
+ const p12 = { file: "/c/dist.p12", password: "", profileFile: "/c/a.mobileprovision" }
241
+ const apiKey = { keyPath: "/c/AuthKey_A.p8", keyId: "A", issuerId: "I" }
242
+
243
+ it("signs a release locally with the .p12 from credentials/ios, with a production push environment", () => {
244
+ expect(planIosSigning({ release: true, credentials: { dir: "/c", apiKey, p12 } })).toMatchObject({
245
+ style: "manual",
246
+ p12: true,
247
+ apsEnvironment: "production",
248
+ })
249
+ })
250
+
251
+ it("leaves the .p12 to releases: a debug build signs automatically", () => {
252
+ expect(planIosSigning({ credentials: { dir: "/c", apiKey: null, p12 } })).toMatchObject({
253
+ style: "automatic",
254
+ p12: false,
255
+ })
256
+ })
257
+
258
+ it("keeps ios.provisioningProfile users on manual signing", () => {
259
+ const iosConfig = { provisioningProfile: "My Profile", developmentTeam: "TEAM123456" }
260
+ expect(planIosSigning({ iosConfig })).toMatchObject({
261
+ style: "manual",
262
+ provisioningProfile: "My Profile",
263
+ teamId: "TEAM123456",
264
+ apsEnvironment: "development",
265
+ })
266
+ expect(planIosSigning({ iosConfig, release: true }).apsEnvironment).toBe("production")
267
+ expect(planIosSigning({ iosConfig, release: true, exportMethod: "debugging" }).apsEnvironment).toBe(
268
+ "development"
269
+ )
270
+ })
271
+
272
+ it("is cloud signing with the API key otherwise, archived for development (export re-signs)", () => {
273
+ expect(
274
+ planIosSigning({
275
+ iosConfig: { developmentTeam: "T" },
276
+ release: true,
277
+ credentials: { dir: "/c", apiKey, p12: null },
278
+ })
279
+ ).toMatchObject({
280
+ style: "automatic",
281
+ source: "App Store Connect API key",
282
+ teamId: "T",
283
+ apsEnvironment: "development",
284
+ })
285
+ expect(planIosSigning({ release: true }).source).toBe("Xcode account")
286
+ })
287
+
288
+ it("has no unsigned mode", () => {
289
+ expect(planIosSigning({ release: true, noSign: true } as any).style).not.toBe("none")
290
+ })
291
+ })
292
+
293
+ describe("security output parsing", () => {
294
+ it("reads the keychain search list", () => {
295
+ expect(
296
+ parseKeychainList(
297
+ ' "/Users/a/Library/Keychains/login.keychain-db"\n "/Library/Keychains/System.keychain"\n'
298
+ )
299
+ ).toEqual(["/Users/a/Library/Keychains/login.keychain-db", "/Library/Keychains/System.keychain"])
300
+ })
301
+
302
+ it("reads the first valid identity", () => {
303
+ const sha = "A".repeat(40)
304
+ expect(
305
+ parseIdentity(
306
+ ` 1) ${sha} "Apple Distribution: Acme (TEAM123456)"\n 1 valid identities found\n`
307
+ )
308
+ ).toEqual({ sha1: sha, name: "Apple Distribution: Acme (TEAM123456)" })
309
+ expect(parseIdentity(" 0 valid identities found\n")).toBeNull()
310
+ })
311
+
312
+ it("matches profiles to bundle ids, including wildcards", () => {
313
+ expect(profileMatchesBundleId("TEAM.com.acme.app", "com.acme.app")).toBe(true)
314
+ expect(profileMatchesBundleId("TEAM.com.acme.*", "com.acme.app")).toBe(true)
315
+ expect(profileMatchesBundleId("TEAM.*", "com.acme.app")).toBe(true)
316
+ expect(profileMatchesBundleId("TEAM.com.other.app", "com.acme.app")).toBe(false)
317
+ })
318
+ })
319
+
320
+ describe("readProvisioningProfile", () => {
321
+ it("fails with IOS-012 for a file security cannot decode", () => {
322
+ const error = thrown(() =>
323
+ readProvisioningProfile("/creds/store.mobileprovision", {
324
+ workDir: tempDir(),
325
+ run: () => {
326
+ throw new Error("security: SecCmsDecoder failed")
327
+ },
328
+ })
329
+ )
330
+ expect(error.code).toBe("IOS-012")
331
+ expect(error.message).toContain("store.mobileprovision is not a provisioning profile")
332
+ })
333
+
334
+ it("fails with IOS-012 when the profile has no usable UUID", () => {
335
+ const noFields = (command: string) => {
336
+ if (command === "plutil") throw new Error("No value at that key path")
337
+ return ""
338
+ }
339
+ const missing = thrown(() =>
340
+ readProvisioningProfile("/creds/a.mobileprovision", { workDir: tempDir(), run: noFields })
341
+ )
342
+ expect(missing.code).toBe("IOS-012")
343
+ expect(missing.message).toContain("no UUID")
344
+
345
+ const odd = (command: string, args: string[]) =>
346
+ command === "plutil" && args[1] === "UUID" ? "not-a-uuid!\n" : ""
347
+ expect(
348
+ thrown(() =>
349
+ readProvisioningProfile("/creds/a.mobileprovision", { workDir: tempDir(), run: odd })
350
+ ).message
351
+ ).toContain("no UUID")
352
+ })
353
+
354
+ it("reads the fields it can and leaves the rest undefined", () => {
355
+ const workDir = tempDir()
356
+ const calls: string[][] = []
357
+ const run = (command: string, args: string[]) => {
358
+ calls.push([command, ...args])
359
+ if (command !== "plutil") return ""
360
+ if (args[1] === "UUID") return "AAAA-BBBB\n"
361
+ if (args[1] === "Name") return "Acme Dev\n"
362
+ throw new Error("No value at that key path")
363
+ }
364
+ const profile = readProvisioningProfile("/creds/a.mobileprovision", { workDir, run })
365
+ expect(profile).toMatchObject({ uuid: "AAAA-BBBB", name: "Acme Dev", teamId: undefined })
366
+ expect(calls[0]).toEqual([
367
+ "security",
368
+ "cms",
369
+ "-D",
370
+ "-i",
371
+ "/creds/a.mobileprovision",
372
+ "-o",
373
+ path.join(workDir, "profile.plist"),
374
+ ])
375
+ })
376
+ })
377
+
378
+ describe("setupP12Signing", () => {
379
+ const P12_PASSWORD = "p12-s3cret-value"
380
+ const SHA = "B".repeat(40)
381
+ const LOGIN = "/Users/ci/Library/Keychains/login.keychain-db"
382
+ const SYSTEM = "/Library/Keychains/System.keychain"
383
+
384
+ type Call = { command: string; args: string[] }
385
+
386
+ function fakeSigning({
387
+ failOn,
388
+ profile = {},
389
+ identities = ` 1) ${SHA} "Apple Distribution: Acme (TEAM123456)"\n 1 valid identities found\n`,
390
+ }: { failOn?: string; profile?: Record<string, string>; identities?: string } = {}) {
391
+ const calls: Call[] = []
392
+ const profileFields: Record<string, string> = {
393
+ UUID: "11111111-2222-3333-4444-555555555555",
394
+ Name: "Acme App Store",
395
+ "TeamIdentifier.0": "TEAM123456",
396
+ "Entitlements.application-identifier": "TEAM123456.com.acme.app",
397
+ "Entitlements.aps-environment": "production",
398
+ ExpirationDate: "2099-01-01T00:00:00Z",
399
+ ...profile,
400
+ }
401
+ const run = (command: string, args: string[]) => {
402
+ calls.push({ command, args })
403
+ const key = `${command} ${args[0]}`
404
+ if (failOn && key === failOn) {
405
+ const error: any = new Error(`Command failed: ${command} ${args.join(" ")}`)
406
+ error.stderr = `security: ${args.join(" ")}: The specified item could not be found.`
407
+ throw error
408
+ }
409
+ if (command === "security" && args[0] === "list-keychains" && !args.includes("-s")) {
410
+ return ` "${LOGIN}"\n "${SYSTEM}"\n`
411
+ }
412
+ if (command === "security" && args[0] === "find-identity") return identities
413
+ if (command === "plutil" && args[0] === "-extract") {
414
+ if (!(args[1] in profileFields)) throw new Error("No value at that key path")
415
+ return `${profileFields[args[1]]}\n`
416
+ }
417
+ return ""
418
+ }
419
+ const handlers: Record<string, (signal: string) => void> = {}
420
+ const signals = {
421
+ once: (name: string, fn: (signal: string) => void) => {
422
+ handlers[name] = fn
423
+ },
424
+ removeListener: (name: string, fn: (signal: string) => void) => {
425
+ if (handlers[name] === fn) delete handlers[name]
426
+ },
427
+ }
428
+ const logs: string[] = []
429
+ const exits: number[] = []
430
+ const masked: string[] = []
431
+ const homeDir = tempDir()
432
+ const tmpRoot = tempDir()
433
+ const credentials = credentialsFolder({ "dist.p12": "p12-bytes", "store.mobileprovision": "profile" })
434
+ const options = {
435
+ p12: {
436
+ file: path.join(credentials, "dist.p12"),
437
+ password: P12_PASSWORD,
438
+ profileFile: path.join(credentials, "store.mobileprovision"),
439
+ },
440
+ bundleId: "com.acme.app",
441
+ env: {},
442
+ mask: (value: string) => void masked.push(value),
443
+ run,
444
+ homeDir,
445
+ tmpRoot,
446
+ signals,
447
+ exit: (code: number) => exits.push(code),
448
+ log: (message: string) => logs.push(message),
449
+ now: () => new Date("2026-10-26T00:00:00Z"),
450
+ }
451
+ return { calls, handlers, logs, exits, masked, credentials, homeDir, tmpRoot, options }
452
+ }
453
+
454
+ const securityCalls = (calls: Call[]) =>
455
+ calls.filter((call) => call.command === "security").map((call) => call.args[0])
456
+
457
+ const profilePaths = (homeDir: string) => [
458
+ path.join(
459
+ homeDir,
460
+ "Library/Developer/Xcode/UserData/Provisioning Profiles/11111111-2222-3333-4444-555555555555.mobileprovision"
461
+ ),
462
+ path.join(
463
+ homeDir,
464
+ "Library/MobileDevice/Provisioning Profiles/11111111-2222-3333-4444-555555555555.mobileprovision"
465
+ ),
466
+ ]
467
+
468
+ it("runs the keychain commands in order and restores the exact search list on cleanup", () => {
469
+ const fake = fakeSigning()
470
+ const result = setupP12Signing(fake.options)
471
+
472
+ expect(securityCalls(fake.calls)).toEqual([
473
+ "list-keychains",
474
+ "create-keychain",
475
+ "set-keychain-settings",
476
+ "unlock-keychain",
477
+ "import",
478
+ "set-key-partition-list",
479
+ "list-keychains",
480
+ "find-identity",
481
+ "cms",
482
+ ])
483
+ const keychain = result.keychainPath
484
+ const set = fake.calls.filter((c) => c.args[0] === "list-keychains" && c.args.includes("-s"))
485
+ expect(set[0].args).toEqual(["list-keychains", "-d", "user", "-s", keychain, LOGIN, SYSTEM])
486
+ expect(result.identity.sha1).toBe(SHA)
487
+ expect(result.profile).toMatchObject({ name: "Acme App Store", teamId: "TEAM123456" })
488
+ for (const file of profilePaths(fake.homeDir)) {
489
+ expect(fs.existsSync(file)).toBe(true)
490
+ expect(fs.statSync(file).mode & 0o777).toBe(0o600)
491
+ }
492
+ expect(Object.keys(fake.handlers).sort()).toEqual(["SIGINT", "SIGTERM"])
493
+
494
+ const before = fake.calls.length
495
+ result.cleanup()
496
+ const after = fake.calls.slice(before)
497
+ expect(after.map((c) => c.args)).toEqual([
498
+ ["list-keychains", "-d", "user", "-s", LOGIN, SYSTEM],
499
+ ["delete-keychain", keychain],
500
+ ])
501
+ for (const file of profilePaths(fake.homeDir)) expect(fs.existsSync(file)).toBe(false)
502
+ // The folders this created are gone too; nothing else in the fake home.
503
+ expect(fs.readdirSync(fake.homeDir)).toEqual([])
504
+ expect(fs.readdirSync(fake.tmpRoot)).toEqual([])
505
+ expect(fake.handlers).toEqual({})
506
+ // The credentials folder is read, never changed.
507
+ expect(fs.readdirSync(fake.credentials).sort()).toEqual(["dist.p12", "store.mobileprovision"])
508
+ // Idempotent.
509
+ result.cleanup()
510
+ expect(fake.calls.length).toBe(before + 2)
511
+ })
512
+
513
+ it("restores the search list and deletes the keychain when a step fails, without leaking the password", () => {
514
+ const fake = fakeSigning({ failOn: "security import" })
515
+ const error = thrown(() => setupP12Signing(fake.options))
516
+ expect(error.code).toBe("IOS-012")
517
+ expect(error.message).not.toContain(P12_PASSWORD)
518
+ const tail = fake.calls.slice(-2).map((c) => c.args.slice(0, 5))
519
+ expect(tail[0]).toEqual(["list-keychains", "-d", "user", "-s", LOGIN])
520
+ expect(fake.calls.at(-2)!.args).toEqual(["list-keychains", "-d", "user", "-s", LOGIN, SYSTEM])
521
+ expect(tail[1][0]).toBe("delete-keychain")
522
+ expect(fs.readdirSync(fake.tmpRoot)).toEqual([])
523
+ expect(fake.handlers).toEqual({})
524
+ })
525
+
526
+ it("warns, without the passwords, when a cleanup step fails, and still runs the others", () => {
527
+ const fake = fakeSigning()
528
+ const options = {
529
+ ...fake.options,
530
+ run: (command: string, args: string[]) => {
531
+ if (args[0] === "delete-keychain") {
532
+ throw Object.assign(
533
+ new Error(`Command failed: security delete-keychain ${P12_PASSWORD}`),
534
+ {
535
+ stderr: "security: SecKeychainDelete: The specified keychain could not be found.",
536
+ }
537
+ )
538
+ }
539
+ return fake.options.run(command, args)
540
+ },
541
+ }
542
+ const result = setupP12Signing(options)
543
+ result.cleanup()
544
+ const warning = fake.logs.find((line) =>
545
+ line.includes("Signing cleanup could not delete the temporary keychain")
546
+ )
547
+ expect(warning).toBeDefined()
548
+ expect(warning).not.toContain(P12_PASSWORD)
549
+ // The steps after the failed one still ran.
550
+ for (const file of profilePaths(fake.homeDir)) expect(fs.existsSync(file)).toBe(false)
551
+ expect(fs.readdirSync(fake.tmpRoot)).toEqual([])
552
+ })
553
+
554
+ it("cleans up and exits on SIGINT", () => {
555
+ const fake = fakeSigning()
556
+ setupP12Signing(fake.options)
557
+ const before = fake.calls.length
558
+ fake.handlers.SIGINT("SIGINT")
559
+ expect(fake.calls.slice(before).map((c) => c.args[0])).toEqual(["list-keychains", "delete-keychain"])
560
+ expect(fake.calls.at(-2)!.args).toEqual(["list-keychains", "-d", "user", "-s", LOGIN, SYSTEM])
561
+ expect(fake.exits).toEqual([130])
562
+ for (const file of profilePaths(fake.homeDir)) expect(fs.existsSync(file)).toBe(false)
563
+ })
564
+
565
+ it("keeps secrets out of logs and passes them only to the security commands that require them", () => {
566
+ const fake = fakeSigning()
567
+ const result = setupP12Signing(fake.options)
568
+ result.cleanup()
569
+ const keychainPassword = fake.calls.find((c) => c.args[0] === "create-keychain")!.args[2]
570
+ expect(keychainPassword.length).toBeGreaterThan(20)
571
+ const carrying = (secret: string) =>
572
+ fake.calls.filter((c) => c.args.includes(secret)).map((c) => c.args[0])
573
+ // security has no environment or stdin form for these; see signing.js.
574
+ expect(carrying(P12_PASSWORD)).toEqual(["import"])
575
+ expect(carrying(keychainPassword)).toEqual([
576
+ "create-keychain",
577
+ "unlock-keychain",
578
+ "set-key-partition-list",
579
+ ])
580
+ // Under GitHub Actions both are masked before any command runs.
581
+ expect(fake.masked).toEqual([P12_PASSWORD, keychainPassword])
582
+ const logged = fake.logs.join("\n")
583
+ expect(logged).not.toContain(P12_PASSWORD)
584
+ expect(logged).not.toContain(keychainPassword)
585
+ expect(logged).toMatch(/Acme App Store/)
586
+ })
587
+
588
+ it("leaves a profile the developer already installed in place", () => {
589
+ const fake = fakeSigning()
590
+ const [userData] = profilePaths(fake.homeDir)
591
+ fs.mkdirSync(path.dirname(userData), { recursive: true })
592
+ fs.writeFileSync(userData, "theirs")
593
+ setupP12Signing(fake.options).cleanup()
594
+ expect(fs.readFileSync(userData, "utf8")).toBe("theirs")
595
+ })
596
+
597
+ it("rejects a profile for another bundle id, an expired profile, and a p12 with no identity", () => {
598
+ expect(
599
+ thrown(() =>
600
+ setupP12Signing(
601
+ fakeSigning({
602
+ profile: { "Entitlements.application-identifier": "TEAM123456.com.other" },
603
+ }).options
604
+ )
605
+ ).code
606
+ ).toBe("IOS-012")
607
+ expect(
608
+ thrown(() =>
609
+ setupP12Signing(fakeSigning({ profile: { ExpirationDate: "2020-01-01T00:00:00Z" } }).options)
610
+ ).message
611
+ ).toMatch(/expired/)
612
+ const fake = fakeSigning({ identities: " 0 valid identities found\n" })
613
+ expect(thrown(() => setupP12Signing(fake.options)).code).toBe("IOS-012")
614
+ expect(fake.calls.at(-1)!.args[0]).toBe("delete-keychain")
615
+ })
616
+ })
617
+
618
+ describe("export options", () => {
619
+ it("sets the method, team and style, and pins the profile for manual signing", () => {
620
+ expect(
621
+ buildExportOptions({ method: "app-store-connect", teamId: "T", signingStyle: "automatic" })
622
+ ).toEqual({
623
+ method: "app-store-connect",
624
+ signingStyle: "automatic",
625
+ destination: "export",
626
+ stripSwiftSymbols: true,
627
+ teamID: "T",
628
+ uploadSymbols: true,
629
+ manageAppVersionAndBuildNumber: false,
630
+ })
631
+ expect(
632
+ buildExportOptions({
633
+ method: "release-testing",
634
+ teamId: "T",
635
+ signingStyle: "manual",
636
+ bundleId: "com.acme.app",
637
+ profileName: "Acme Ad Hoc",
638
+ signingCertificate: "ABC",
639
+ })
640
+ ).toMatchObject({
641
+ provisioningProfiles: { "com.acme.app": "Acme Ad Hoc" },
642
+ signingCertificate: "ABC",
643
+ })
644
+ })
645
+
646
+ it.skipIf(!isMac)("writes a valid plist with special characters escaped", () => {
647
+ const dir = tempDir()
648
+ const file = writeExportOptionsPlist(
649
+ buildExportOptions({
650
+ method: "app-store-connect",
651
+ teamId: "T",
652
+ signingStyle: "manual",
653
+ bundleId: "com.acme.app",
654
+ profileName: 'Acme & <Co> "Store"',
655
+ }),
656
+ path.join(dir, "ExportOptions.plist")
657
+ )
658
+ expect(execFileSync("plutil", ["-lint", file], { encoding: "utf8" })).toMatch(/OK/)
659
+ const xml = fs.readFileSync(file, "utf8")
660
+ expect(xml).toContain("Acme &amp; &lt;Co&gt;")
661
+ expect(
662
+ execFileSync(
663
+ "plutil",
664
+ ["-extract", "provisioningProfiles.com\\.acme\\.app", "raw", "-o", "-", file],
665
+ {
666
+ encoding: "utf8",
667
+ }
668
+ ).trim()
669
+ ).toBe('Acme & <Co> "Store"')
670
+ expect(fs.readdirSync(dir)).toEqual(["ExportOptions.plist"])
671
+ })
672
+ })
673
+
674
+ describe("signing failures", () => {
675
+ it("recognises xcodebuild's signing errors", () => {
676
+ expect(isSigningFailure('error: Signing for "iosnativeWebView" requires a development team.')).toBe(
677
+ true
678
+ )
679
+ expect(isSigningFailure("error: No profiles for 'com.acme.app' were found")).toBe(true)
680
+ expect(isSigningFailure("error: No Accounts: Add a new account in Accounts settings.")).toBe(true)
681
+ expect(isSigningFailure("error: cannot find 'Foo' in scope")).toBe(false)
682
+ })
683
+
684
+ it("wraps one as IOS-010 and keeps the transcript", () => {
685
+ const cause: any = Object.assign(new Error("exit 65"), { output: "requires a development team" })
686
+ const error = signingSetupError("Xcode could not sign the device build", cause)
687
+ expect(error.code).toBe("IOS-010")
688
+ expect(error.output).toBe("requires a development team")
689
+ expect(error.hint).toMatch(/CATALYST_IOS_CREDENTIALS/)
690
+ expect(error.hint).toMatch(/catalyst credentials:export ios/)
691
+ expect(error.hint).not.toMatch(/CATALYST_ASC_|CATALYST_IOS_P12/)
692
+ })
693
+ })
694
+
695
+ describe("generateXCConfig signing and version", () => {
696
+ function phase(iosConfig: Record<string, any>, extra: Record<string, any> = {}) {
697
+ const projectDir = tempDir()
698
+ const statuses: string[] = []
699
+ const { generateXCConfig } = createIosConfigPhase({
700
+ WEBVIEW_CONFIG: { ios: iosConfig },
701
+ iosConfig,
702
+ PROJECT_DIR: projectDir,
703
+ progress: { status: (s: string) => statuses.push(s) },
704
+ ...extra,
705
+ })
706
+ const read = () => fs.readFileSync(path.join(projectDir, "Configurations", "Shared.xcconfig"), "utf8")
707
+ return { generateXCConfig, read }
708
+ }
709
+
710
+ it("is Automatic for the configured team with no identity or profile pinned", async () => {
711
+ const { generateXCConfig, read } = phase({
712
+ developmentTeam: "TEAM123456",
713
+ appBundleId: "com.acme.app",
714
+ })
715
+ await generateXCConfig()
716
+ const xcconfig = read()
717
+ expect(xcconfig).toMatch(/^CODE_SIGN_STYLE = Automatic$/m)
718
+ expect(xcconfig).toMatch(/^DEVELOPMENT_TEAM = TEAM123456$/m)
719
+ expect(xcconfig).not.toMatch(/CODE_SIGN_IDENTITY|PROVISIONING_PROFILE_SPECIFIER/)
720
+ })
721
+
722
+ it("keeps the manual settings for ios.provisioningProfile", async () => {
723
+ const { generateXCConfig, read } = phase({ provisioningProfile: "Acme Dev", developmentTeam: "T" })
724
+ await generateXCConfig()
725
+ const xcconfig = read()
726
+ expect(xcconfig).toMatch(/^CODE_SIGN_STYLE = Manual$/m)
727
+ expect(xcconfig).toMatch(/^CODE_SIGN_IDENTITY\[sdk=iphoneos\*\] = iPhone Developer$/m)
728
+ expect(xcconfig).toMatch(/^PROVISIONING_PROFILE_SPECIFIER\[sdk=iphoneos\*\] = Acme Dev$/m)
729
+ })
730
+
731
+ it("signs a CI release with the imported identity and the profile's team", async () => {
732
+ const { generateXCConfig, read } = phase({ developmentTeam: "" })
733
+ await generateXCConfig({
734
+ signing: {
735
+ style: "manual",
736
+ provisioningProfile: "Acme Store",
737
+ identity: "A".repeat(40),
738
+ teamId: "TEAMCI",
739
+ },
740
+ version: { versionName: "2.5.0-beta.1", buildNumber: 42 },
741
+ })
742
+ const xcconfig = read()
743
+ expect(xcconfig).toMatch(/^DEVELOPMENT_TEAM = TEAMCI$/m)
744
+ expect(xcconfig).toMatch(
745
+ new RegExp(`^CODE_SIGN_IDENTITY\\[sdk=iphoneos\\*\\] = ${"A".repeat(40)}$`, "m")
746
+ )
747
+ expect(xcconfig).toMatch(/^MARKETING_VERSION = 2\.5\.0$/m)
748
+ expect(xcconfig).toMatch(/^CURRENT_PROJECT_VERSION = 42$/m)
749
+ })
750
+
751
+ it("takes the resolved version from the build context", async () => {
752
+ const { generateXCConfig, read } = phase({}, { version: { versionName: "3.1", buildNumber: 9 } })
753
+ await generateXCConfig()
754
+ expect(read()).toMatch(/^MARKETING_VERSION = 3\.1$/m)
755
+ expect(read()).toMatch(/^CURRENT_PROJECT_VERSION = 9$/m)
756
+ })
757
+ })
758
+
759
+ describe("updateInfoPlist", () => {
760
+ function infoPlistPhase(extra: Record<string, any> = {}, pluginInfoPlist: Record<string, any> = {}) {
761
+ const projectDir = tempDir()
762
+ const dir = path.join(projectDir, "iosnativeWebView")
763
+ fs.mkdirSync(dir, { recursive: true })
764
+ const debugFile = path.join(dir, "Info.plist")
765
+ const releaseFile = path.join(dir, "Info-Release.plist")
766
+ fs.writeFileSync(
767
+ debugFile,
768
+ JSON.stringify({ NSAppTransportSecurity: { NSAllowsArbitraryLoads: true } })
769
+ )
770
+ fs.writeFileSync(
771
+ releaseFile,
772
+ JSON.stringify({ NSAppTransportSecurity: { NSAllowsArbitraryLoads: false } })
773
+ )
774
+ const { updateInfoPlist } = createIosConfigPhase({
775
+ WEBVIEW_CONFIG: {},
776
+ iosConfig: { appName: "Acme" },
777
+ PROJECT_DIR: projectDir,
778
+ PROJECT_NAME: "iosnativeWebView",
779
+ version: { versionName: "2.5.0-beta.1", buildNumber: 1501234 },
780
+ restoreManagedFileFromBaseline: () => {},
781
+ readPlistObject: (f: string) => JSON.parse(fs.readFileSync(f, "utf8")),
782
+ writePlistObject: (f: string, v: unknown) => fs.writeFileSync(f, JSON.stringify(v)),
783
+ ...extra,
784
+ })
785
+ const read = (f: string) => JSON.parse(fs.readFileSync(f, "utf8"))
786
+ return {
787
+ run: () => updateInfoPlist({ infoPlist: pluginInfoPlist }),
788
+ debug: () => read(debugFile),
789
+ release: () => read(releaseFile),
790
+ }
791
+ }
792
+
793
+ it("writes the version and build number into both plists", async () => {
794
+ const phase = infoPlistPhase()
795
+ await phase.run()
796
+ for (const plist of [phase.debug(), phase.release()]) {
797
+ expect(plist.CFBundleShortVersionString).toBe("2.5.0")
798
+ expect(plist.CFBundleVersion).toBe("1501234")
799
+ }
800
+ })
801
+
802
+ it("allows local networking in the debug plist only, when the app loads the dev server", async () => {
803
+ // The template's debug plist allows arbitrary loads; NSAllowsLocalNetworking
804
+ // there would make iOS ignore it, so it is not added.
805
+ const dev = infoPlistPhase({ localNetworking: true })
806
+ await dev.run()
807
+ expect(dev.debug().NSAppTransportSecurity).toEqual({ NSAllowsArbitraryLoads: true })
808
+ expect(dev.release().NSAppTransportSecurity).toEqual({ NSAllowsArbitraryLoads: false })
809
+
810
+ // A debug plist without arbitrary loads gets local networking.
811
+ const strict = infoPlistPhase({
812
+ localNetworking: true,
813
+ restoreManagedFileFromBaseline: (file: string) => {
814
+ if (file.endsWith("/Info.plist")) {
815
+ fs.writeFileSync(
816
+ file,
817
+ JSON.stringify({ NSAppTransportSecurity: { NSAllowsArbitraryLoads: false } })
818
+ )
819
+ }
820
+ },
821
+ })
822
+ await strict.run()
823
+ expect(strict.debug().NSAppTransportSecurity).toEqual({
824
+ NSAllowsArbitraryLoads: false,
825
+ NSAllowsLocalNetworking: true,
826
+ })
827
+
828
+ const legacy = infoPlistPhase({ localNetworking: false })
829
+ await legacy.run()
830
+ expect(legacy.debug().NSAppTransportSecurity).not.toHaveProperty("NSAllowsLocalNetworking")
831
+ })
832
+
833
+ it("declares no non-exempt encryption in the release plist, unless a plugin says otherwise", async () => {
834
+ const phase = infoPlistPhase()
835
+ await phase.run()
836
+ expect(phase.release().ITSAppUsesNonExemptEncryption).toBe(false)
837
+ expect(phase.debug()).not.toHaveProperty("ITSAppUsesNonExemptEncryption")
838
+
839
+ const crypto = infoPlistPhase({}, { ITSAppUsesNonExemptEncryption: true })
840
+ await crypto.run()
841
+ expect(crypto.release().ITSAppUsesNonExemptEncryption).toBe(true)
842
+ })
843
+ })
844
+
845
+ describe("release output", () => {
846
+ it("goes to build-output/ios/<version>-<build> under the app root", () => {
847
+ expect(releaseOutputDir("/apps/shop", { versionName: "1.4.0", buildNumber: 1501234 })).toBe(
848
+ path.resolve("/apps/shop/build-output/ios/1.4.0-1501234")
849
+ )
850
+ expect(releaseOutputDir("/apps/shop", { buildNumber: 7 } as any)).toBe(
851
+ path.resolve("/apps/shop/build-output/ios/1.0.0-7")
852
+ )
853
+ })
854
+ })
855
+
856
+ describe("updateEntitlements", () => {
857
+ function entitlementsPhase(WEBVIEW_CONFIG: Record<string, any>, apsEnvironment?: string) {
858
+ const projectDir = tempDir()
859
+ const file = path.join(projectDir, "iosnativeWebView", "iosnativeWebView.entitlements")
860
+ fs.mkdirSync(path.dirname(file), { recursive: true })
861
+ // A baseline from an older Catalyst, which still carried the key.
862
+ fs.writeFileSync(
863
+ file,
864
+ JSON.stringify({ "aps-environment": "development", "keychain-access-groups": ["x"] })
865
+ )
866
+ const { updateEntitlements } = createIosConfigPhase({
867
+ WEBVIEW_CONFIG,
868
+ iosConfig: {},
869
+ PROJECT_DIR: projectDir,
870
+ PROJECT_NAME: "iosnativeWebView",
871
+ apsEnvironment,
872
+ restoreManagedFileFromBaseline: () => {},
873
+ readPlistObject: (f: string) => JSON.parse(fs.readFileSync(f, "utf8")),
874
+ writePlistObject: (f: string, v: unknown) => fs.writeFileSync(f, JSON.stringify(v)),
875
+ })
876
+ return { updateEntitlements, read: () => JSON.parse(fs.readFileSync(file, "utf8")) }
877
+ }
878
+
879
+ it("leaves aps-environment out when notifications are off", async () => {
880
+ const { updateEntitlements, read } = entitlementsPhase({})
881
+ await updateEntitlements()
882
+ expect(read()).toEqual({ "keychain-access-groups": ["x"] })
883
+ })
884
+
885
+ it("writes the planned aps-environment when notifications are on", async () => {
886
+ const dev = entitlementsPhase({ notifications: { enabled: true } })
887
+ await dev.updateEntitlements()
888
+ expect(dev.read()["aps-environment"]).toBe("development")
889
+ const prod = entitlementsPhase({ notifications: { enabled: true } }, "production")
890
+ await prod.updateEntitlements()
891
+ expect(prod.read()["aps-environment"]).toBe("production")
892
+ })
893
+ })
894
+
895
+ describe("ConfigConstants", () => {
896
+ it("never emits the App Store Connect key or the export method", async () => {
897
+ const projectDir = tempDir()
898
+ const progress = { start() {}, complete() {}, fail() {}, log() {} }
899
+ const { generateConfigConstants } = createIosConfigPhase({
900
+ WEBVIEW_CONFIG: { port: 3005 },
901
+ iosConfig: {
902
+ appBundleId: "com.acme.app",
903
+ exportMethod: "app-store-connect",
904
+ ascApiKey: { path: "/secret/AuthKey.p8", keyId: "KEY1", issuerId: "issuer" },
905
+ },
906
+ PROJECT_DIR: projectDir,
907
+ PUBLIC_PATH: tempDir(),
908
+ url: "https://www.example.com:443",
909
+ progress,
910
+ })
911
+ await generateConfigConstants()
912
+ const swift = fs.readFileSync(
913
+ path.join(projectDir, "CoreLogic/Sources/CatalystCoreLogic/Constants/ConfigConstants.swift"),
914
+ "utf8"
915
+ )
916
+ expect(swift).toContain('public static let url = "https://www.example.com:443"')
917
+ expect(swift).toContain("appBundleId")
918
+ expect(swift).not.toMatch(/AuthKey|ascApiKey|AscApiKey|exportMethod/)
919
+ })
920
+ })
921
+
922
+ describe("ConfigConstants resolver-only keys", () => {
923
+ it("skips url, appId, appName and allowPrivateHost even when they reach the emitter", async () => {
924
+ const projectDir = tempDir()
925
+ const progress = { start() {}, complete() {}, fail() {}, log() {} }
926
+ const { generateConfigConstants } = createIosConfigPhase({
927
+ WEBVIEW_CONFIG: {
928
+ url: "https://shop.acme.com",
929
+ appId: "com.acme.shop",
930
+ appName: "Acme Shop",
931
+ allowPrivateHost: true,
932
+ LOCAL_IP: "shop.acme.com",
933
+ },
934
+ iosConfig: {},
935
+ PROJECT_DIR: projectDir,
936
+ PUBLIC_PATH: tempDir(),
937
+ url: "https://shop.acme.com",
938
+ progress,
939
+ })
940
+ await generateConfigConstants()
941
+ const swift = fs.readFileSync(
942
+ path.join(projectDir, "CoreLogic/Sources/CatalystCoreLogic/Constants/ConfigConstants.swift"),
943
+ "utf8"
944
+ )
945
+ expect(swift.match(/static let url\b/g)).toHaveLength(1)
946
+ expect(swift).not.toMatch(/static let (appId|appName|allowPrivateHost)\b/)
947
+ expect(swift).toContain('public static let LOCAL_IP = "shop.acme.com"')
948
+ })
949
+ })
950
+
951
+ describe("localhost.p12", () => {
952
+ it("falls back to /usr/bin/openssl when openssl on PATH fails", () => {
953
+ const dest = path.join(tempDir(), "out", "localhost.p12")
954
+ const used: string[] = []
955
+ generateLocalhostP12(dest, {
956
+ run: (command: string, args: string[]) => {
957
+ used.push(`${command} ${args[0]}`)
958
+ if (command === "openssl")
959
+ throw Object.assign(new Error("boom"), { stderr: "unknown option" })
960
+ const out = args[args.indexOf("-out") + 1]
961
+ fs.writeFileSync(out, "p12")
962
+ return ""
963
+ },
964
+ })
965
+ expect(used).toEqual(["openssl req", "/usr/bin/openssl req", "/usr/bin/openssl pkcs12"])
966
+ expect(fs.readFileSync(dest, "utf8")).toBe("p12")
967
+ })
968
+
969
+ it("names openssl's last error line when every openssl fails, and writes nothing", () => {
970
+ const dest = path.join(tempDir(), "out", "localhost.p12")
971
+ const tmpBefore = fs
972
+ .readdirSync(os.tmpdir())
973
+ .filter((n) => n.startsWith("catalyst-localhost-cert-")).length
974
+ expect(() =>
975
+ generateLocalhostP12(dest, {
976
+ run: () => {
977
+ throw Object.assign(new Error("Command failed"), {
978
+ stderr: "req: Use -help for summary.\nreq: Unrecognized flag addext\n",
979
+ })
980
+ },
981
+ })
982
+ ).toThrow("Could not create the localhost certificate with openssl: req: Unrecognized flag addext")
983
+ expect(fs.existsSync(dest)).toBe(false)
984
+ expect(
985
+ fs.readdirSync(os.tmpdir()).filter((n) => n.startsWith("catalyst-localhost-cert-")).length
986
+ ).toBe(tmpBefore)
987
+ })
988
+
989
+ it.skipIf(!isMac)("writes a localhost identity openssl reads with an empty passphrase", () => {
990
+ const dest = path.join(tempDir(), "localhost.p12")
991
+ generateLocalhostP12(dest)
992
+ const info = execFileSync("openssl", ["pkcs12", "-in", dest, "-passin", "pass:", "-info", "-noout"], {
993
+ encoding: "utf8",
994
+ stdio: ["ignore", "pipe", "pipe"],
995
+ })
996
+ expect(info).toBe("")
997
+ const cert = execFileSync("openssl", ["pkcs12", "-in", dest, "-passin", "pass:", "-nokeys"], {
998
+ encoding: "utf8",
999
+ })
1000
+ const text = execFileSync("openssl", ["x509", "-noout", "-subject", "-ext", "subjectAltName"], {
1001
+ input: cert,
1002
+ encoding: "utf8",
1003
+ })
1004
+ expect(text).toMatch(/CN\s*=\s*localhost/)
1005
+ expect(text).toMatch(/DNS:localhost/)
1006
+ expect(text).toMatch(/IP Address:127\.0\.0\.1/)
1007
+ })
1008
+ })
1009
+
1010
+ describe("the iOS shell project", () => {
1011
+ const pbxproj = fs.readFileSync(path.join(shellDir, "iosnativeWebView.xcodeproj/project.pbxproj"), "utf8")
1012
+
1013
+ it("carries no team, profile or bundle id overrides from the 1mg app", () => {
1014
+ expect(pbxproj).not.toMatch(/DEVELOPMENT_TEAM\[sdk=iphoneos\*\]/)
1015
+ expect(pbxproj).not.toMatch(/PROVISIONING_PROFILE_SPECIFIER/)
1016
+ expect(pbxproj).not.toMatch(/com\.1mg|BHQQRZ69D3|Tata1mgPos/)
1017
+ })
1018
+
1019
+ it("bundles PrivacyInfo.xcprivacy and still references the generated localhost.p12", () => {
1020
+ const resources = /\/\* Begin PBXResourcesBuildPhase section \*\/[\s\S]*?\);/.exec(pbxproj)![0]
1021
+ expect(resources).toContain("PrivacyInfo.xcprivacy in Resources")
1022
+ expect(resources).toContain("localhost.p12 in Resources")
1023
+ expect(fs.existsSync(path.join(shellDir, "iosnativeWebView/PrivacyInfo.xcprivacy"))).toBe(true)
1024
+ expect(fs.readFileSync(path.join(shellDir, ".gitignore"), "utf8")).toMatch(
1025
+ /^iosnativeWebView\/localhost\.p12$/m
1026
+ )
1027
+ })
1028
+
1029
+ it.skipIf(!isMac)("has a valid privacy manifest that declares no tracking", () => {
1030
+ const file = path.join(shellDir, "iosnativeWebView/PrivacyInfo.xcprivacy")
1031
+ expect(execFileSync("plutil", ["-lint", file], { encoding: "utf8" })).toMatch(/OK/)
1032
+ const json = JSON.parse(
1033
+ execFileSync("plutil", ["-convert", "json", "-o", "-", file], { encoding: "utf8" })
1034
+ )
1035
+ expect(json.NSPrivacyTracking).toBe(false)
1036
+ expect(json.NSPrivacyCollectedDataTypes).toEqual([])
1037
+ expect(
1038
+ json.NSPrivacyAccessedAPITypes.map((entry: any) => entry.NSPrivacyAccessedAPIType).sort()
1039
+ ).toEqual([
1040
+ "NSPrivacyAccessedAPICategoryFileTimestamp",
1041
+ "NSPrivacyAccessedAPICategorySystemBootTime",
1042
+ "NSPrivacyAccessedAPICategoryUserDefaults",
1043
+ ])
1044
+ })
1045
+
1046
+ it("declares no non-exempt encryption in the release plist and keeps local networking out of the template", () => {
1047
+ const release = fs.readFileSync(path.join(shellDir, "iosnativeWebView/Info-Release.plist"), "utf8")
1048
+ expect(release).toMatch(/<key>ITSAppUsesNonExemptEncryption<\/key>\s*<false\/>/)
1049
+ const debug = fs.readFileSync(path.join(shellDir, "iosnativeWebView/Info.plist"), "utf8")
1050
+ expect(debug + release).not.toContain("NSAllowsLocalNetworking")
1051
+ })
1052
+
1053
+ it("does not ask for push in the template entitlements", () => {
1054
+ const entitlements = fs.readFileSync(
1055
+ path.join(shellDir, "iosnativeWebView/iosnativeWebView.entitlements"),
1056
+ "utf8"
1057
+ )
1058
+ expect(entitlements).not.toContain("aps-environment")
1059
+ })
1060
+ })