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,611 @@
1
+ import fs from "fs"
2
+ import { createRequire } from "module"
3
+ import os from "os"
4
+ import path from "path"
5
+ import { afterEach, describe, expect, it } from "vitest"
6
+
7
+ // The CJS build phase loads with require, as in iosSigning.test.ts.
8
+ const require = createRequire(import.meta.url)
9
+ const createIosConfigPhase = require("../../src/native/buildIos/config.js")
10
+
11
+ const temps: string[] = []
12
+ function tempDir() {
13
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "catalyst-ios-config-phase-"))
14
+ temps.push(dir)
15
+ return dir
16
+ }
17
+
18
+ afterEach(() => {
19
+ for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true })
20
+ })
21
+
22
+ async function rejection(promise: Promise<unknown>): Promise<any> {
23
+ try {
24
+ await promise
25
+ } catch (error) {
26
+ return error
27
+ }
28
+ throw new Error("expected a rejection")
29
+ }
30
+
31
+ type Log = { message: string; type?: string }
32
+
33
+ function recordingProgress() {
34
+ const logs: Log[] = []
35
+ const events: string[] = []
36
+ return {
37
+ logs,
38
+ events,
39
+ progress: {
40
+ start: (step: string) => events.push(`start:${step}`),
41
+ complete: (step: string) => events.push(`complete:${step}`),
42
+ fail: (step: string, message: string) => events.push(`fail:${step}:${message}`),
43
+ log: (message: string, type?: string) => logs.push({ message, type }),
44
+ status: (message: string) => events.push(`status:${message}`),
45
+ },
46
+ }
47
+ }
48
+
49
+ const CONSTANTS_PATH = ["CoreLogic", "Sources", "CatalystCoreLogic", "Constants", "ConfigConstants.swift"]
50
+
51
+ /** Run generateConfigConstants and return the Swift it wrote, plus the logs. */
52
+ async function constants(
53
+ WEBVIEW_CONFIG: Record<string, any>,
54
+ {
55
+ iosConfig = WEBVIEW_CONFIG.ios || {},
56
+ publicPath = tempDir(),
57
+ }: { iosConfig?: any; publicPath?: string } = {}
58
+ ) {
59
+ const projectDir = tempDir()
60
+ const recorder = recordingProgress()
61
+ const { generateConfigConstants } = createIosConfigPhase({
62
+ WEBVIEW_CONFIG,
63
+ iosConfig,
64
+ PROJECT_DIR: projectDir,
65
+ PUBLIC_PATH: publicPath,
66
+ url: "https://shop.acme.com/",
67
+ progress: recorder.progress,
68
+ })
69
+ await generateConfigConstants()
70
+ const swift = fs.readFileSync(path.join(projectDir, ...CONSTANTS_PATH), "utf8")
71
+ return { swift, ...recorder }
72
+ }
73
+
74
+ describe("generateConfigConstants: Swift properties", () => {
75
+ it("types each array by its first element and keeps empty arrays as [String]", async () => {
76
+ const { swift } = await constants({
77
+ tags: ["a", "b"],
78
+ ports: [80, 443],
79
+ ratios: [0.5, 1.5],
80
+ flags: [true, false],
81
+ objects: [{ id: 1 }],
82
+ none: [],
83
+ ios: {},
84
+ })
85
+ expect(swift).toContain(' public static let tags: [String] = ["a", "b"]')
86
+ expect(swift).toContain(" public static let ports: [Int] = [80, 443]")
87
+ expect(swift).toContain(" public static let ratios: [Double] = [0.5, 1.5]")
88
+ expect(swift).toContain(" public static let flags: [Bool] = [true, false]")
89
+ expect(swift).toContain(' public static let objects: [String] = ["[object Object]"]')
90
+ expect(swift).toContain(" public static let none: [String] = []")
91
+ })
92
+
93
+ it("emits scalars, nil for null, and nested objects as enums", async () => {
94
+ const { swift } = await constants({
95
+ title: "Shop",
96
+ port: 3005,
97
+ debug: true,
98
+ missing: null,
99
+ theme: { primary: "#000", spacing: { small: 4 } },
100
+ ios: {},
101
+ })
102
+ expect(swift).toContain(' public static let title = "Shop"')
103
+ expect(swift).toContain(" public static let port = 3005")
104
+ expect(swift).toContain(" public static let debug = true")
105
+ expect(swift).toContain(" public static let missing: String? = nil")
106
+ expect(swift).toContain(
107
+ [
108
+ " public enum Theme {",
109
+ ' public static let primary = "#000"',
110
+ " public enum Spacing {",
111
+ " public static let small = 4",
112
+ " }",
113
+ " }",
114
+ ].join("\n")
115
+ )
116
+ })
117
+
118
+ it("always types cachePattern as [String], from a string or an array", async () => {
119
+ const single = await constants({ ios: { cachePattern: "*.png" } })
120
+ expect(single.swift).toContain(' public static let cachePattern: [String] = ["*.png"]')
121
+ expect(single.logs.map((l) => l.message)).not.toContain(
122
+ "cachePattern not found in config, adding default (no patterns)"
123
+ )
124
+
125
+ const many = await constants({ ios: { cachePattern: ["*.png", "*.css"] } })
126
+ expect(many.swift).toContain(' public static let cachePattern: [String] = ["*.png", "*.css"]')
127
+ })
128
+
129
+ it("falls back to a quoted string for a value of another type", async () => {
130
+ const { swift } = await constants({ big: BigInt(7), ios: {} })
131
+ expect(swift).toContain(' public static let big = "7"')
132
+ })
133
+
134
+ it("adds the iOS keys after the shared ones, skipping build-only and duplicate keys", async () => {
135
+ const { swift } = await constants({
136
+ theme: "light",
137
+ ios: { theme: "dark", appName: "Acme", ascApiKey: "secret", exportMethod: "debugging" },
138
+ })
139
+ expect(swift).toContain(' public static let theme = "light"')
140
+ expect(swift).not.toContain('theme = "dark"')
141
+ expect(swift).toContain(" // iOS-specific configuration")
142
+ expect(swift).toContain(' public static let appName = "Acme"')
143
+ expect(swift).not.toMatch(/ascApiKey|exportMethod|secret/)
144
+ })
145
+ })
146
+
147
+ describe("generateConfigConstants: access control", () => {
148
+ it("emits the allowed URLs from an array", async () => {
149
+ const { swift } = await constants({
150
+ accessControl: { enabled: true, allowedUrls: ["https://a.com/*", "https://b.com/*"] },
151
+ ios: {},
152
+ })
153
+ expect(swift).toContain(" public static let accessControlEnabled = true")
154
+ expect(swift).toContain(
155
+ ' public static let allowedUrls: [String] = ["https://a.com/*", "https://b.com/*"]'
156
+ )
157
+ })
158
+
159
+ it("splits a comma-separated string and drops empty entries", async () => {
160
+ const { swift } = await constants({
161
+ accessControl: { allowedUrls: " https://a.com/* , ,https://b.com/* ," },
162
+ ios: {},
163
+ })
164
+ expect(swift).toContain(" public static let accessControlEnabled = false")
165
+ expect(swift).toContain(
166
+ ' public static let allowedUrls: [String] = ["https://a.com/*", "https://b.com/*"]'
167
+ )
168
+ })
169
+
170
+ it("is disabled with no URLs when not configured", async () => {
171
+ const { swift } = await constants({ ios: {} })
172
+ expect(swift).toContain(" public static let accessControlEnabled = false")
173
+ expect(swift).toContain(" public static let allowedUrls: [String] = []")
174
+ })
175
+ })
176
+
177
+ describe("generateConfigConstants: splash screen", () => {
178
+ it("uses the configured values and embeds public/splashscreen.html as base64", async () => {
179
+ const publicPath = tempDir()
180
+ const html = "<html><body>Loading</body></html>"
181
+ fs.writeFileSync(path.join(publicPath, "splashscreen.html"), html)
182
+ const { swift, logs } = await constants(
183
+ {
184
+ splashScreen: {
185
+ duration: 1500,
186
+ backgroundColor: "#112233",
187
+ imageWidth: 200,
188
+ imageHeight: 100,
189
+ cornerRadius: 8,
190
+ minDuration: 250,
191
+ },
192
+ ios: {},
193
+ },
194
+ { publicPath }
195
+ )
196
+ expect(swift).toContain(" public static let splashScreenEnabled = true")
197
+ expect(swift).toContain(" public static let splashScreenDuration: TimeInterval? = 1.5")
198
+ expect(swift).toContain(' public static let splashScreenBackgroundColor = "#112233"')
199
+ expect(swift).toContain(" public static let splashScreenImageWidth: CGFloat = 200")
200
+ expect(swift).toContain(" public static let splashScreenImageHeight: CGFloat = 100")
201
+ expect(swift).toContain(" public static let splashScreenCornerRadius: CGFloat = 8")
202
+ expect(swift).toContain(
203
+ ` public static let splashScreenHtml: String? = "${Buffer.from(html).toString("base64")}"`
204
+ )
205
+ expect(swift).toContain(" public static let splashScreenMinDuration: TimeInterval = 0.25")
206
+ expect(logs.filter((l) => l.type === "warning")).toEqual([])
207
+ })
208
+
209
+ it("defaults the values, with no HTML splash when the file is missing", async () => {
210
+ const { swift } = await constants({ splashScreen: {}, ios: {} })
211
+ expect(swift).toContain(" public static let splashScreenEnabled = true")
212
+ expect(swift).toContain(" public static let splashScreenDuration: TimeInterval? = nil")
213
+ expect(swift).toContain(' public static let splashScreenBackgroundColor = "#ffffff"')
214
+ expect(swift).toContain(" public static let splashScreenImageWidth: CGFloat = 120")
215
+ expect(swift).toContain(" public static let splashScreenImageHeight: CGFloat = 120")
216
+ expect(swift).toContain(" public static let splashScreenCornerRadius: CGFloat = 20")
217
+ expect(swift).toContain(" public static let splashScreenHtml: String? = nil")
218
+ expect(swift).toContain(" public static let splashScreenMinDuration: TimeInterval = 0")
219
+ })
220
+
221
+ it("warns about an HTML splash over 1 MB and an invalid minDuration", async () => {
222
+ const publicPath = tempDir()
223
+ fs.writeFileSync(path.join(publicPath, "splashscreen.html"), Buffer.alloc(1024 * 1024 + 1, "a"))
224
+ const { swift, logs } = await constants(
225
+ { splashScreen: { minDuration: -5 }, ios: {} },
226
+ { publicPath }
227
+ )
228
+ const warnings = logs.filter((l) => l.type === "warning").map((l) => l.message)
229
+ expect(warnings).toContain(
230
+ "splashscreen.html is 1.0 MB; it is embedded in the app binary, keep it small"
231
+ )
232
+ expect(warnings).toContain("Ignoring invalid splashScreen.minDuration: -5")
233
+ expect(swift).toContain(" public static let splashScreenMinDuration: TimeInterval = 0")
234
+ expect(swift).toMatch(/public static let splashScreenHtml: String\? = "YWFh/)
235
+ })
236
+
237
+ it("is disabled with the defaults when not configured", async () => {
238
+ const { swift } = await constants({ ios: {} })
239
+ expect(swift).toContain(" public static let splashScreenEnabled = false")
240
+ expect(swift).toContain(" public static let splashScreenHtml: String? = nil")
241
+ })
242
+ })
243
+
244
+ describe("generateConfigConstants: defaults and failures", () => {
245
+ it("adds disabled defaults for the features Swift always references", async () => {
246
+ const { swift, logs } = await constants({ ios: {} })
247
+ expect(swift).toContain(
248
+ " public enum Notifications {\n public static let enabled = false\n }"
249
+ )
250
+ expect(swift).toContain(" public enum GoogleSignIn {\n public static let enabled = false")
251
+ expect(swift).toContain(
252
+ " public enum Profiler {\n public static let enabled = false\n }"
253
+ )
254
+ expect(swift).toContain(
255
+ " public enum EdgeToEdge {\n public static let enabled = false\n }"
256
+ )
257
+ expect(swift).toContain(' public static let initial_url = ""')
258
+ expect(swift).toContain(' public static let appInfo = ""')
259
+ expect(swift).toContain(" public static let cachePattern: [String] = []")
260
+ expect(swift.trimEnd().endsWith("}")).toBe(true)
261
+ expect(logs.map((l) => l.message)).toContain("EdgeToEdge not found in config, adding default (false)")
262
+ })
263
+
264
+ it("uses the configured notifications instead of the default, and logs it", async () => {
265
+ const { swift, logs } = await constants({
266
+ notifications: { enabled: true },
267
+ googleSignIn: { enabled: true },
268
+ profiler: { enabled: true },
269
+ edgeToEdge: { enabled: true },
270
+ initial_url: "/home",
271
+ appInfo: "v1",
272
+ ios: {},
273
+ })
274
+ expect(swift).toContain(
275
+ " public enum Notifications {\n public static let enabled = true\n }"
276
+ )
277
+ expect(swift.match(/public enum Notifications/g)).toHaveLength(1)
278
+ expect(swift.match(/public enum GoogleSignIn/g)).toHaveLength(1)
279
+ expect(swift.match(/public enum Profiler/g)).toHaveLength(1)
280
+ expect(swift.match(/public enum EdgeToEdge/g)).toHaveLength(1)
281
+ expect(swift).toContain(' public static let initial_url = "/home"')
282
+ expect(swift).toContain(' public static let appInfo = "v1"')
283
+ const messages = logs.map((l) => l.message)
284
+ expect(messages).toContain('Processing notifications config: {"enabled":true}')
285
+ expect(messages).toContain("Notifications config was processed from WEBVIEW_CONFIG")
286
+ })
287
+
288
+ it("reports the failure on the progress step and rethrows", async () => {
289
+ const projectDir = tempDir()
290
+ // A file where the Constants folder should be makes the write fail.
291
+ const constantsDir = path.join(projectDir, ...CONSTANTS_PATH.slice(0, -1))
292
+ fs.mkdirSync(path.dirname(constantsDir), { recursive: true })
293
+ fs.writeFileSync(constantsDir, "")
294
+ const recorder = recordingProgress()
295
+ const { generateConfigConstants } = createIosConfigPhase({
296
+ WEBVIEW_CONFIG: { ios: {} },
297
+ iosConfig: {},
298
+ PROJECT_DIR: projectDir,
299
+ PUBLIC_PATH: tempDir(),
300
+ url: "https://shop.acme.com/",
301
+ progress: recorder.progress,
302
+ })
303
+ const error = await rejection(generateConfigConstants())
304
+ expect(error.code).toBe("ENOTDIR")
305
+ expect(recorder.events[0]).toBe("start:config")
306
+ expect(recorder.events).toContain(`fail:config:${error.message}`)
307
+ expect(recorder.events).not.toContain("complete:config")
308
+ })
309
+ })
310
+
311
+ describe("generateXCConfig", () => {
312
+ it("writes the defaults and reports the Swift and iOS versions", async () => {
313
+ const projectDir = tempDir()
314
+ const recorder = recordingProgress()
315
+ const { generateXCConfig } = createIosConfigPhase({
316
+ WEBVIEW_CONFIG: {},
317
+ iosConfig: { version: { major: 2, minor: 3, buildNumber: 11 } },
318
+ PROJECT_DIR: projectDir,
319
+ progress: recorder.progress,
320
+ })
321
+ await generateXCConfig()
322
+ const xcconfig = fs.readFileSync(path.join(projectDir, "Configurations", "Shared.xcconfig"), "utf8")
323
+ expect(xcconfig).toMatch(/^PRODUCT_BUNDLE_IDENTIFIER = com\.catalyst\.framework\.app$/m)
324
+ expect(xcconfig).toMatch(/^APP_DISPLAY_NAME = Catalyst App$/m)
325
+ expect(xcconfig).toMatch(/^MARKETING_VERSION = 2\.3\.0$/m)
326
+ expect(xcconfig).toMatch(/^CURRENT_PROJECT_VERSION = 11$/m)
327
+ expect(xcconfig).toMatch(/^SWIFT_VERSION = 5\.9$/m)
328
+ expect(xcconfig).toMatch(/^IPHONEOS_DEPLOYMENT_TARGET = 17\.0$/m)
329
+ expect(recorder.events).toContain("status:xcconfig · Swift 5.9 · iOS 17.0")
330
+ })
331
+
332
+ it("names the file in the error and keeps the original error", async () => {
333
+ const projectDir = tempDir()
334
+ // A file where Configurations/ should be.
335
+ fs.writeFileSync(path.join(projectDir, "Configurations"), "")
336
+ const { generateXCConfig } = createIosConfigPhase({
337
+ WEBVIEW_CONFIG: {},
338
+ iosConfig: {},
339
+ PROJECT_DIR: projectDir,
340
+ progress: recordingProgress().progress,
341
+ })
342
+ const error = await rejection(generateXCConfig())
343
+ expect(error.message).toMatch(/^Failed to generate Shared\.xcconfig: /)
344
+ expect(error.code).toBe("ENOTDIR")
345
+ })
346
+ })
347
+
348
+ const PROJECT_NAME = "iosnativeWebView"
349
+
350
+ function plistPhase({
351
+ WEBVIEW_CONFIG = {},
352
+ isGoogleSignInEnabled = false,
353
+ debugPlist = {} as Record<string, any> | null,
354
+ releasePlist = {} as Record<string, any> | null,
355
+ googleServices = null as string | null,
356
+ }: {
357
+ WEBVIEW_CONFIG?: Record<string, any>
358
+ isGoogleSignInEnabled?: boolean
359
+ debugPlist?: Record<string, any> | null
360
+ releasePlist?: Record<string, any> | null
361
+ googleServices?: string | null
362
+ } = {}) {
363
+ const projectDir = tempDir()
364
+ const dir = path.join(projectDir, PROJECT_NAME)
365
+ fs.mkdirSync(dir, { recursive: true })
366
+ const debugFile = path.join(dir, "Info.plist")
367
+ const releaseFile = path.join(dir, "Info-Release.plist")
368
+ if (debugPlist) fs.writeFileSync(debugFile, JSON.stringify(debugPlist))
369
+ if (releasePlist) fs.writeFileSync(releaseFile, JSON.stringify(releasePlist))
370
+ if (googleServices !== null) fs.writeFileSync(path.join(dir, "GoogleService-Info.plist"), googleServices)
371
+ const restored: string[] = []
372
+ const phase = createIosConfigPhase({
373
+ WEBVIEW_CONFIG,
374
+ iosConfig: {},
375
+ PROJECT_DIR: projectDir,
376
+ PROJECT_NAME,
377
+ isGoogleSignInEnabled,
378
+ restoreManagedFileFromBaseline: (file: string) => restored.push(file),
379
+ readPlistObject: (file: string) => JSON.parse(fs.readFileSync(file, "utf8")),
380
+ writePlistObject: (file: string, value: unknown) => fs.writeFileSync(file, JSON.stringify(value)),
381
+ })
382
+ const read = (file: string) => JSON.parse(fs.readFileSync(file, "utf8"))
383
+ return {
384
+ ...phase,
385
+ projectDir,
386
+ restored,
387
+ debugFile,
388
+ releaseFile,
389
+ debug: () => read(debugFile),
390
+ release: () => read(releaseFile),
391
+ }
392
+ }
393
+
394
+ const GOOGLE_SERVICES = `<?xml version="1.0" encoding="UTF-8"?>
395
+ <plist version="1.0">
396
+ <dict>
397
+ <key>CLIENT_ID</key>
398
+ <string>123-abc.apps.googleusercontent.com</string>
399
+ <key>REVERSED_CLIENT_ID</key>
400
+ <string>com.googleusercontent.apps.123-abc</string>
401
+ </dict>
402
+ </plist>`
403
+
404
+ describe("updateInfoPlist", () => {
405
+ it("sets the display name and versions with the defaults", async () => {
406
+ const phase = plistPhase()
407
+ await phase.updateInfoPlist()
408
+ const debug = phase.debug()
409
+ expect(debug.CFBundleDisplayName).toBe("Catalyst Application")
410
+ expect(debug.CFBundleShortVersionString).toBe("1.0.0")
411
+ expect(debug.CFBundleVersion).toBe("1")
412
+ expect(debug).not.toHaveProperty("CFBundleURLTypes")
413
+ expect(debug).not.toHaveProperty("LSApplicationQueriesSchemes")
414
+ expect(phase.restored).toEqual([phase.debugFile, phase.releaseFile])
415
+ })
416
+
417
+ it("skips a plist that does not exist", async () => {
418
+ const phase = plistPhase({ debugPlist: null })
419
+ await phase.updateInfoPlist()
420
+ expect(fs.existsSync(phase.debugFile)).toBe(false)
421
+ expect(phase.restored).toEqual([phase.releaseFile])
422
+ expect(phase.release().CFBundleShortVersionString).toBe("1.0.0")
423
+ })
424
+
425
+ it("takes the reversed client ID from GoogleService-Info.plist", async () => {
426
+ const phase = plistPhase({ isGoogleSignInEnabled: true, googleServices: GOOGLE_SERVICES })
427
+ await phase.updateInfoPlist()
428
+ for (const plist of [phase.debug(), phase.release()]) {
429
+ expect(plist.CFBundleURLTypes).toEqual([
430
+ {
431
+ CFBundleURLName: "googleSignIn",
432
+ CFBundleURLSchemes: ["com.googleusercontent.apps.123-abc"],
433
+ },
434
+ ])
435
+ expect(plist.LSApplicationQueriesSchemes).toEqual([
436
+ "google",
437
+ "com.googleusercontent.apps.123-abc",
438
+ ])
439
+ }
440
+ })
441
+
442
+ it("reverses CLIENT_ID from GoogleService-Info.plist when it has no REVERSED_CLIENT_ID", async () => {
443
+ const phase = plistPhase({
444
+ isGoogleSignInEnabled: true,
445
+ googleServices:
446
+ "<dict><key>CLIENT_ID</key><string>123-abc.apps.googleusercontent.com</string></dict>",
447
+ })
448
+ await phase.updateInfoPlist()
449
+ expect(phase.debug().CFBundleURLTypes[0].CFBundleURLSchemes).toEqual([
450
+ "com.googleusercontent.apps.123-abc",
451
+ ])
452
+ })
453
+
454
+ it("reverses the configured iOS client ID, before the web client ID", async () => {
455
+ const phase = plistPhase({
456
+ isGoogleSignInEnabled: true,
457
+ WEBVIEW_CONFIG: {
458
+ googleSignIn: { webClientId: "web.example.com", iosClientId: "ios.example.com" },
459
+ },
460
+ })
461
+ await phase.updateInfoPlist()
462
+ expect(phase.debug().CFBundleURLTypes[0].CFBundleURLSchemes).toEqual(["com.example.ios"])
463
+
464
+ const web = plistPhase({
465
+ isGoogleSignInEnabled: true,
466
+ WEBVIEW_CONFIG: { googleSignIn: { webClientId: "web.example.com" } },
467
+ })
468
+ await web.updateInfoPlist()
469
+ expect(web.debug().CFBundleURLTypes[0].CFBundleURLSchemes).toEqual(["com.example.web"])
470
+ })
471
+
472
+ it("fails when Google Sign-In is on but no client ID is found anywhere", async () => {
473
+ const phase = plistPhase({ isGoogleSignInEnabled: true, googleServices: "<dict></dict>" })
474
+ const error = await rejection(phase.updateInfoPlist())
475
+ expect(error.message).toBe("Google Sign-In enabled but no valid clientId found")
476
+ expect(phase.restored).toEqual([])
477
+ })
478
+
479
+ it("leaves the URL types alone when Google Sign-In is off, even with a client ID", async () => {
480
+ const phase = plistPhase({ WEBVIEW_CONFIG: { googleSignIn: { clientId: "a.b.c" } } })
481
+ await phase.updateInfoPlist()
482
+ expect(phase.debug()).not.toHaveProperty("CFBundleURLTypes")
483
+ })
484
+
485
+ it("merges plugin URL schemes into the existing ones without duplicates", async () => {
486
+ const phase = plistPhase({
487
+ debugPlist: {
488
+ CFBundleURLTypes: [
489
+ { CFBundleURLName: "app", CFBundleURLSchemes: ["acme", "acme"] },
490
+ { CFBundleURLName: "broken" },
491
+ ],
492
+ LSApplicationQueriesSchemes: ["tel"],
493
+ },
494
+ })
495
+ await phase.updateInfoPlist({
496
+ urlSchemes: [
497
+ { name: "app", schemes: ["acme", "acme-pay"] },
498
+ { name: "plugin", schemes: ["plug"] },
499
+ { schemes: ["anon"] },
500
+ ],
501
+ querySchemes: ["tel", "mailto", ""],
502
+ })
503
+ expect(phase.debug().CFBundleURLTypes).toEqual([
504
+ { CFBundleURLName: "app", CFBundleURLSchemes: ["acme", "acme-pay"] },
505
+ { CFBundleURLName: "broken", CFBundleURLSchemes: [] },
506
+ { CFBundleURLName: "plugin", CFBundleURLSchemes: ["plug"] },
507
+ { CFBundleURLName: null, CFBundleURLSchemes: ["anon"] },
508
+ ])
509
+ expect(phase.debug().LSApplicationQueriesSchemes).toEqual(["tel", "mailto"])
510
+ expect(phase.release().LSApplicationQueriesSchemes).toEqual(["tel", "mailto"])
511
+ })
512
+
513
+ it("merges plugin Info.plist arrays and dictionaries into the template", async () => {
514
+ const phase = plistPhase({
515
+ debugPlist: {
516
+ UIBackgroundModes: ["fetch"],
517
+ NSExtension: { NSExtensionAttributes: { A: 1 } },
518
+ UIRequiresFullScreen: true,
519
+ },
520
+ })
521
+ await phase.updateInfoPlist({
522
+ infoPlist: {
523
+ UIBackgroundModes: ["fetch", "remote-notification"],
524
+ NSExtension: { NSExtensionAttributes: { B: 2 }, NSExtensionPointIdentifier: "x" },
525
+ UIRequiresFullScreen: true,
526
+ NSCameraUsageDescription: "Scan",
527
+ },
528
+ })
529
+ const debug = phase.debug()
530
+ expect(debug.UIBackgroundModes).toEqual(["fetch", "remote-notification"])
531
+ expect(debug.NSExtension).toEqual({
532
+ NSExtensionAttributes: { A: 1, B: 2 },
533
+ NSExtensionPointIdentifier: "x",
534
+ })
535
+ expect(debug.UIRequiresFullScreen).toBe(true)
536
+ expect(debug.NSCameraUsageDescription).toBe("Scan")
537
+ })
538
+
539
+ it("refuses a plugin value that conflicts with the template, naming the field", async () => {
540
+ const phase = plistPhase({ debugPlist: { NSExtension: { Mode: "a" } } })
541
+ const error = await rejection(phase.updateInfoPlist({ infoPlist: { NSExtension: { Mode: "b" } } }))
542
+ expect(error.message).toBe(
543
+ "Conflicting values for 'ios.infoPlist.NSExtension.Mode' while composing iOS build metadata"
544
+ )
545
+ })
546
+ })
547
+
548
+ describe("updateEntitlements", () => {
549
+ function entitlementsFile(phase: ReturnType<typeof plistPhase>) {
550
+ return path.join(phase.projectDir, PROJECT_NAME, `${PROJECT_NAME}.entitlements`)
551
+ }
552
+
553
+ it("does nothing when the app has no entitlements file", async () => {
554
+ const phase = plistPhase()
555
+ await phase.updateEntitlements({ entitlements: { "com.apple.developer.x": true } })
556
+ expect(fs.existsSync(entitlementsFile(phase))).toBe(false)
557
+ expect(phase.restored).toEqual([])
558
+ })
559
+
560
+ it("merges plugin entitlements and drops a stale aps-environment", async () => {
561
+ const phase = plistPhase()
562
+ const file = entitlementsFile(phase)
563
+ fs.writeFileSync(
564
+ file,
565
+ JSON.stringify({
566
+ "aps-environment": "production",
567
+ "com.apple.developer.associated-domains": ["applinks:acme.com"],
568
+ })
569
+ )
570
+ await phase.updateEntitlements({
571
+ entitlements: {
572
+ "com.apple.developer.associated-domains": ["applinks:acme.com", "webcredentials:acme.com"],
573
+ "com.apple.developer.applesignin": ["Default"],
574
+ },
575
+ })
576
+ expect(JSON.parse(fs.readFileSync(file, "utf8"))).toEqual({
577
+ "com.apple.developer.associated-domains": ["applinks:acme.com", "webcredentials:acme.com"],
578
+ "com.apple.developer.applesignin": ["Default"],
579
+ })
580
+ expect(phase.restored).toEqual([file])
581
+ })
582
+
583
+ it("adds aps-environment only when notifications are on, defaulting to development", async () => {
584
+ const projectDir = tempDir()
585
+ const file = path.join(projectDir, PROJECT_NAME, `${PROJECT_NAME}.entitlements`)
586
+ fs.mkdirSync(path.dirname(file), { recursive: true })
587
+ fs.writeFileSync(file, JSON.stringify({}))
588
+ const phase = createIosConfigPhase({
589
+ WEBVIEW_CONFIG: { notifications: { enabled: true } },
590
+ iosConfig: {},
591
+ PROJECT_DIR: projectDir,
592
+ PROJECT_NAME,
593
+ restoreManagedFileFromBaseline: () => {},
594
+ readPlistObject: (f: string) => JSON.parse(fs.readFileSync(f, "utf8")),
595
+ writePlistObject: (f: string, value: unknown) => fs.writeFileSync(f, JSON.stringify(value)),
596
+ })
597
+ await phase.updateEntitlements()
598
+ expect(JSON.parse(fs.readFileSync(file, "utf8"))).toEqual({ "aps-environment": "development" })
599
+ })
600
+
601
+ it("refuses a plugin entitlement that conflicts with the template", async () => {
602
+ const phase = plistPhase()
603
+ fs.writeFileSync(entitlementsFile(phase), JSON.stringify({ "keychain-sharing": true }))
604
+ const error = await rejection(
605
+ phase.updateEntitlements({ entitlements: { "keychain-sharing": false } })
606
+ )
607
+ expect(error.message).toBe(
608
+ "Conflicting values for 'ios.entitlements.keychain-sharing' while composing iOS build metadata"
609
+ )
610
+ })
611
+ })