catalyst-core 1.0.2-canary.1 → 1.0.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 (90) hide show
  1. package/bin/catalyst.js +105 -1
  2. package/changelog.md +34 -0
  3. package/dist/cli/README.md +119 -0
  4. package/dist/cli/appConfig.js +51 -0
  5. package/dist/cli/buildErrors.js +170 -0
  6. package/dist/cli/buildWarnings.js +28 -0
  7. package/dist/cli/devOutput.js +524 -0
  8. package/dist/cli/diagnostic.js +388 -0
  9. package/dist/cli/hints.js +69 -0
  10. package/dist/cli/package.json +3 -0
  11. package/dist/cli/spinner.js +141 -0
  12. package/dist/cli/theme.js +155 -0
  13. package/dist/errors/index.js +7 -3
  14. package/dist/logger.js +61 -6
  15. package/dist/native/androidProject/app/build.gradle.kts +2 -1
  16. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +2 -0
  17. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +9 -0
  18. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +65 -34
  19. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashOverlay.kt +109 -0
  20. package/dist/native/androidSetup.js +46 -57
  21. package/dist/native/buildAndroid/assets.js +22 -13
  22. package/dist/native/buildAndroid/build.js +98 -26
  23. package/dist/native/buildAndroid/config.js +43 -12
  24. package/dist/native/buildAndroid/index.js +49 -191
  25. package/dist/native/buildAppAndroid.js +19 -8
  26. package/dist/native/buildAppIos.js +35 -16
  27. package/dist/native/buildIos/assets.js +1 -6
  28. package/dist/native/buildIos/build.js +66 -95
  29. package/dist/native/buildIos/config.js +26 -8
  30. package/dist/native/buildIos/index.js +143 -64
  31. package/dist/native/buildIos/plugins.js +12 -15
  32. package/dist/native/internalPluginUtils.js +0 -1
  33. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Constants/ConfigConstants.swift +2 -0
  34. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashHTMLView.swift +31 -0
  35. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashView.swift +17 -1
  36. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashViewModel.swift +41 -1
  37. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewModel.swift +2 -0
  38. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +3 -0
  39. package/dist/native/pluginComposerAndroid.js +2 -3
  40. package/dist/native/renameAndroidProject.js +42 -20
  41. package/dist/native/setupEmulatorIos.js +28 -49
  42. package/dist/native/setupServer.js +12 -15
  43. package/dist/native/terminalProgress.js +186 -147
  44. package/dist/native/utils.js +340 -54
  45. package/dist/otel.js +15 -9
  46. package/dist/scripts/build.js +313 -42
  47. package/dist/scripts/generateOfflineManifest.js +11 -1
  48. package/dist/scripts/loadEnvironmentVariables.js +24 -2
  49. package/dist/scripts/preServerInit.js +32 -10
  50. package/dist/scripts/preflight.js +35 -11
  51. package/dist/scripts/scriptUtils.js +34 -0
  52. package/dist/scripts/serve.js +64 -15
  53. package/dist/scripts/start.js +58 -21
  54. package/dist/server/expressServer.js +167 -32
  55. package/dist/server/renderer/handler.jsx +87 -11
  56. package/dist/server/utils/validator.js +14 -11
  57. package/dist/vite/manifest-categorization-plugin.js +7 -5
  58. package/dist/vite/resolveDevServerConfig.js +9 -2
  59. package/dist/vite/vite.config.client.js +18 -0
  60. package/dist/vite/vite.config.js +73 -13
  61. package/dist/vite/vite.config.server.js +21 -0
  62. package/dist/web-router/components/MetaTag.jsx +15 -2
  63. package/dist/web-router/components/MetaTag.test.jsx +71 -2
  64. package/dist/webmcp/WebMcpProvider.jsx +3 -2
  65. package/dist/webmcp/useTool.js +4 -1
  66. package/mcp_v2/conversion-tasks.json +6 -5
  67. package/mcp_v2/knowledge-base.json +124 -7
  68. package/mcp_v2/test/config.test.ts +59 -0
  69. package/mcp_v2/tools/build.js +6 -6
  70. package/mcp_v2/tools/config.js +35 -18
  71. package/mcp_v2/tools/conversion.js +7 -13
  72. package/package.json +5 -5
  73. package/test/cli/buildErrors.test.ts +173 -0
  74. package/test/cli/buildWarnings.test.cjs +62 -0
  75. package/test/cli/devOutput.test.ts +549 -0
  76. package/test/cli/diagnostic.test.ts +434 -0
  77. package/test/cli/output.test.cjs +90 -0
  78. package/test/cli/spinner.test.ts +177 -0
  79. package/test/cli/theme.test.ts +163 -0
  80. package/test/native/redactSecrets.test.ts +72 -0
  81. package/test/native/utils.test.ts +532 -0
  82. package/test/scripts/build.test.ts +275 -0
  83. package/test/scripts/serve.test.ts +102 -0
  84. package/test/server/fixtures/template/src/js/routes/index.jsx +8 -0
  85. package/test/server/handler.test.ts +17 -0
  86. package/test/server/logger.test.ts +185 -0
  87. package/test/server/otel.test.ts +556 -0
  88. package/test/server/validator.test.ts +19 -8
  89. package/dist/scripts/checkVersion.js +0 -3
  90. package/dist/scripts/cleanCache.js +0 -30
@@ -2,15 +2,25 @@
2
2
 
3
3
  const {
4
4
  execFile,
5
- execFileSync
5
+ execFileSync,
6
+ spawn
6
7
  } = require("child_process");
7
8
  const fs = require("fs");
8
9
  const path = require("path");
10
+ const {
11
+ displayPath
12
+ } = require("../../cli/theme.js");
9
13
  const TerminalProgress = require("../terminalProgress.js").default;
14
+ const {
15
+ buildFailure
16
+ } = require("../../cli/diagnostic.js");
10
17
  const createConfigPhase = require("./config.js");
11
18
  const createPluginsPhase = require("./plugins.js");
12
19
  const createAssetsPhase = require("./assets.js");
13
20
  const createBuildPhase = require("./build.js");
21
+
22
+ // Matches runInteractiveCommand: keep the tail, not the whole transcript.
23
+ const MAX_STREAMED_OUTPUT_BYTES = 1024 * 1024;
14
24
  const catalystCorePath = path.dirname(require.resolve("catalyst-core/package.json"));
15
25
  const pwd = path.join(catalystCorePath, "dist/native");
16
26
  const MANAGED_BASELINE_SUFFIX = ".catalyst-base";
@@ -34,46 +44,126 @@ function createIosBuild(config) {
34
44
  const PROJECT_NAME = path.basename(PROJECT_DIR);
35
45
  const IPHONE_MODEL = iosConfig.simulatorName;
36
46
  const steps = {
37
- config: "Generating Required Configuration for build",
38
- deviceDetection: "Detecting Physical Device",
39
- launchSimulator: "Launch iOS Simulator",
40
- clean: "Clean Build Artifacts",
41
- assets: "Process Notification Assets",
42
- build: "Build IOS Project",
43
- findApp: "Locate Built Application",
44
- install: "Install Application",
45
- launch: "Launch Application"
46
- };
47
- const progressConfig = {
48
- titlePaddingTop: 2,
49
- titlePaddingBottom: 1,
50
- stepPaddingLeft: 4,
51
- stepSpacing: 1,
52
- errorPaddingLeft: 6,
53
- bottomMargin: 2
47
+ config: "Generate build configuration",
48
+ deviceDetection: "Detect physical device",
49
+ launchSimulator: "Launch iOS simulator",
50
+ clean: "Clean build artifacts",
51
+ assets: "Process notification assets",
52
+ build: "Build iOS project",
53
+ findApp: "Locate built application",
54
+ install: "Install application",
55
+ launch: "Launch application"
54
56
  };
55
- const progress = new TerminalProgress(steps, "Catalyst iOS Build", progressConfig);
57
+ const progress = new TerminalProgress(steps, "catalyst buildApp", {
58
+ subject: "ios"
59
+ });
56
60
 
57
61
  // ─── Low-level helpers shared across modules ──────────────────────────────
58
62
 
59
63
  function runCommand(command, options = {}) {
60
64
  return new Promise((resolve, reject) => {
61
- execFile(shellCommand, shellArgs(command), {
62
- maxBuffer: 1024 * 1024 * 10,
65
+ execFile(shellCommand, shellArgs(command),
66
+ // Unbounded: a 10MB cap turned a large-but-successful build into
67
+ // a spurious ERR_CHILD_PROCESS_STDIO_MAXBUFFER failure.
68
+ {
69
+ maxBuffer: Infinity,
63
70
  ...options
64
- }, (error, stdout, stderr) => {
71
+ }, (error, stdout) => {
65
72
  if (error) {
66
- console.error(`Command failed: ${command}`);
67
- console.error(`Error: ${error.message}`);
68
- console.error(`stderr: ${stderr}`);
73
+ // Let one layer above print this once. It used to print
74
+ // three lines here and the callers printed the same
75
+ // failure again -- ~6 lines across 4 layers per error.
76
+ // execFile already appends stderr to error.message, so
77
+ // appending it again here showed it twice.
69
78
  reject(error);
70
79
  return;
71
80
  }
72
- if (stderr) console.warn(`Warning: ${stderr}`);
81
+ // Tools write plenty of non-fatal chatter to stderr, so a
82
+ // successful command reporting "Warning: ..." was noise.
73
83
  resolve(stdout.trim());
74
84
  });
75
85
  });
76
86
  }
87
+
88
+ /**
89
+ * The phase from an xcodebuild line, or null for lines worth ignoring.
90
+ * "CompileSwift normal arm64 /path/ContentView.swift" becomes
91
+ * "CompileSwift ContentView.swift".
92
+ */
93
+ function xcodePhase(line) {
94
+ const match = /^(CompileSwift|CompileC|Ld|CodeSign|ProcessInfoPlistFile|CpResource|Touch)\b(.*)$/.exec(line.trim());
95
+ if (!match) return null;
96
+ const file = (match[2].trim().split(/\s+/).pop() || "").split("/").pop();
97
+ return file ? `${match[1]} ${file}` : match[1];
98
+ }
99
+
100
+ /**
101
+ * Streaming sibling of runCommand, for the long xcodebuild calls.
102
+ *
103
+ * Buffered execFile means nothing appears until the build finishes -- for
104
+ * xcodebuild that is minutes of apparent hang. Rather than scroll its
105
+ * hundreds of lines (nobody reads them on success, and the error path
106
+ * re-derives what it needs from error.output), the current phase is shown
107
+ * on the spinner's own row. The full output is still resolved for callers
108
+ * that parse it.
109
+ */
110
+ function runCommandStreaming(command) {
111
+ return new Promise((resolve, reject) => {
112
+ progress.pause();
113
+
114
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process - Internal build command strings, same source as runCommand.
115
+ const child = spawn(shellCommand, shellArgs(command), {
116
+ stdio: ["ignore", "pipe", "pipe"]
117
+ });
118
+ let output = "";
119
+ const partial = {
120
+ stdout: "",
121
+ stderr: ""
122
+ };
123
+ const consume = (data, stream) => {
124
+ const text = data.toString();
125
+
126
+ // Same 1MB tail cap as runInteractiveCommand. Without it a long
127
+ // xcodebuild retained every byte it ever printed.
128
+ output += text;
129
+ if (output.length > MAX_STREAMED_OUTPUT_BYTES) {
130
+ output = output.slice(-MAX_STREAMED_OUTPUT_BYTES);
131
+ }
132
+ const lines = (partial[stream] + text).split("\n");
133
+ partial[stream] = lines.pop();
134
+ for (const line of lines) progress.status(xcodePhase(line));
135
+ };
136
+ const flush = () => {
137
+ for (const stream of ["stdout", "stderr"]) {
138
+ partial[stream] = "";
139
+ }
140
+ };
141
+ child.stdout.on("data", data => consume(data, "stdout"));
142
+ child.stderr.on("data", data => consume(data, "stderr"));
143
+ child.on("error", error => {
144
+ flush();
145
+ progress.resume();
146
+ reject(error);
147
+ });
148
+ child.on("close", code => {
149
+ flush();
150
+ progress.resume();
151
+ if (code === 0) {
152
+ resolve(output.trim());
153
+ return;
154
+ }
155
+
156
+ // Carry the whole output so the reporter can mine the real
157
+ // compiler errors out of it. A 5-line tail was arbitrary --
158
+ // xcodebuild puts the cause in the middle and summary noise
159
+ // at the end.
160
+ const error = new Error(`Command failed with exit code ${code}`);
161
+ error.code = code;
162
+ error.output = output;
163
+ reject(error);
164
+ });
165
+ });
166
+ }
77
167
  function getXcodeProjectFilePath() {
78
168
  return path.join(PROJECT_DIR, `${PROJECT_NAME}.xcodeproj`, "project.pbxproj");
79
169
  }
@@ -113,6 +203,8 @@ function createIosBuild(config) {
113
203
  const ctx = {
114
204
  WEBVIEW_CONFIG,
115
205
  BUILD_OUTPUT_PATH,
206
+ // Captured before the build chdirs into the Xcode project.
207
+ appRoot: process.cwd(),
116
208
  iosConfig,
117
209
  isGoogleSignInEnabled,
118
210
  PROJECT_DIR,
@@ -124,6 +216,7 @@ function createIosBuild(config) {
124
216
  PUBLIC_PATH,
125
217
  progress,
126
218
  runCommand,
219
+ runCommandStreaming,
127
220
  getXcodeProjectFilePath,
128
221
  ensureManagedBaseline,
129
222
  restoreManagedFileFromBaseline,
@@ -179,12 +272,11 @@ function createIosBuild(config) {
179
272
  await generateXCConfig();
180
273
  await copySplashscreenAssets();
181
274
  await copyAppIcon();
182
- progress.log("Changing directory to: " + PROJECT_DIR, "info");
183
275
  process.chdir(PROJECT_DIR);
184
276
  const physicalDevice = await detectPhysicalDevices();
185
277
  let APP_PATH, targetInfo;
186
278
  if (physicalDevice) {
187
- progress.log("🔥 Building for physical device workflow", "success");
279
+ progress.log("Building for physical device workflow", "success");
188
280
  targetInfo = {
189
281
  type: "physical",
190
282
  name: physicalDevice.name,
@@ -196,23 +288,10 @@ function createIosBuild(config) {
196
288
  await buildProjectForPhysicalDevice(SCHEME_NAME, APP_BUNDLE_ID, path.join(process.env.HOME, "Library/Developer/Xcode/DerivedData"), PROJECT_NAME, physicalDevice);
197
289
  progress.complete("build");
198
290
  } catch (error) {
199
- progress.fail("build", error.message);
200
- progress.printTreeContent("Physical Device Build Failed", ["Build failed. Please check:", {
201
- text: "Code signing certificates are properly installed",
202
- indent: 1,
203
- prefix: "├─ ",
204
- color: "yellow"
205
- }, {
206
- text: "Provisioning profile matches your bundle ID",
207
- indent: 1,
208
- prefix: "├─ ",
209
- color: "yellow"
210
- }, {
211
- text: "Device is connected and trusted",
212
- indent: 1,
213
- prefix: "└─ ",
214
- color: "yellow"
215
- }]);
291
+ // The diagnostic in the catch below shows what xcodebuild
292
+ // said, with a signing hint when that is the cause; a fixed
293
+ // checklist here only guessed.
294
+ error.stage = error.stage || "Building for the device";
216
295
  throw error;
217
296
  }
218
297
  progress.start("findApp");
@@ -221,12 +300,11 @@ function createIosBuild(config) {
221
300
  progress.log("Found app at: " + APP_PATH, "success");
222
301
  progress.complete("findApp");
223
302
  } catch (error) {
224
- progress.fail("findApp", error.message);
303
+ error.stage = error.stage || "Locating the built app";
225
304
  throw error;
226
305
  }
227
306
  await installAndLaunchOnPhysicalDevice(APP_PATH, physicalDevice);
228
307
  } else {
229
- progress.log("📱 Building for simulator workflow", "info");
230
308
  targetInfo = {
231
309
  type: "simulator",
232
310
  name: IPHONE_MODEL
@@ -240,34 +318,35 @@ function createIosBuild(config) {
240
318
  const MOVED_APP_PATH = await moveAppToBuildOutput(APP_PATH);
241
319
  APP_PATH = MOVED_APP_PATH;
242
320
  }
243
- progress.printTreeContent("Build Summary", ["Build completed successfully:", {
244
- text: `Target: ${targetInfo.type === "physical" ? "📱 Physical Device" : "📱 Simulator"}`,
245
- indent: 1,
246
- prefix: "├─ ",
247
- color: "green"
248
- }, {
249
- text: `Device: ${targetInfo.name}`,
250
- indent: 1,
251
- prefix: "├─ ",
321
+ // Same aligned key/value shape as the Android summary and the
322
+ // Config tree the setup commands print.
323
+ const deviceName = `${targetInfo.name}${targetInfo.type === "physical" ? "" : " simulator"}`;
324
+ progress.printTreeContent("Build", [{
325
+ text: `device ${deviceName}`,
252
326
  color: "gray"
253
327
  }, {
254
- text: `App Path: ${APP_PATH}`,
255
- indent: 1,
256
- prefix: "├─ ",
328
+ text: `artifact ${displayPath(APP_PATH, originalDir)}`,
257
329
  color: "gray"
258
330
  }, {
259
- text: `URL: ${url}`,
260
- indent: 1,
261
- prefix: "└─ ",
331
+ text: `loads ${url}`,
262
332
  color: "gray"
263
333
  }]);
334
+ progress.summary(`Installed on ${deviceName}`, "Run catalyst start to serve the app");
264
335
  return {
265
336
  success: true,
266
337
  targetInfo,
267
338
  appPath: APP_PATH
268
339
  };
269
340
  } catch (error) {
270
- progress.log("Build failed: " + error.message, "error");
341
+ // Mark the step, then show what actually broke -- once. The ✗ line
342
+ // carries no message: the diagnostic below is the message.
343
+ if (progress.currentStep) progress.fail(progress.currentStep.id);
344
+ process.stderr.write(buildFailure({
345
+ error,
346
+ stage: error.stage || "iOS build",
347
+ code: "IOS-000",
348
+ cwd: originalDir
349
+ }));
271
350
  throw error;
272
351
  } finally {
273
352
  process.chdir(originalDir);
@@ -306,7 +385,7 @@ function createIosBuild(config) {
306
385
  throw buildError;
307
386
  }
308
387
  }
309
- progress.log("✅ build-for-testing complete — test bundle ready for xctest runner", "success");
388
+ progress.log("build-for-testing complete — test bundle ready for xctest runner", "success");
310
389
  return {
311
390
  success: true
312
391
  };
@@ -127,7 +127,7 @@ module.exports = function createPluginsPhase(ctx) {
127
127
  coreDependencies.forEach(d => mergePackageDependency(packageDependencyMap, d, "core target"));
128
128
  notificationsDependencies.forEach(d => mergePackageDependency(packageDependencyMap, d, "notifications target"));
129
129
  const packageDependencies = [...packageDependencyMap.values()].sort((l, r) => l.url.localeCompare(r.url));
130
- progress.log(`🔧 Generating Package.swift (notifications: ${isNotificationsEnabled})`, "info");
130
+ progress.log(`Generating Package.swift (notifications: ${isNotificationsEnabled})`, "info");
131
131
  const configHash = crypto.createHash("md5").update(JSON.stringify({
132
132
  notifications: isNotificationsEnabled,
133
133
  googleSignIn: isGoogleSignInEnabled,
@@ -152,7 +152,6 @@ module.exports = function createPluginsPhase(ctx) {
152
152
  progress.log("Package.swift missing, will generate it now", "info");
153
153
  }
154
154
  if (shouldUpdate) {
155
- progress.log("Generating Package.swift dynamically", "info");
156
155
  let packageContent = `// swift-tools-version: 5.9
157
156
  // Auto-generated Package.swift - DO NOT EDIT MANUALLY
158
157
  // Generated based on config: notifications.enabled = ${isNotificationsEnabled}
@@ -222,24 +221,25 @@ ${formatSwiftProductEntries(notificationsDependencies).join(",\n")}
222
221
  stdio: "ignore"
223
222
  }); // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
224
223
  const projectPath = path.join(PROJECT_DIR, `${PROJECT_NAME}.xcodeproj`); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
224
+ // Piped, not inherited: inherited stdio put xcodebuild's
225
+ // 19-line package graph on screen at column 0, outside the
226
+ // gutter. Captured instead, so a failure can still show it.
225
227
  execSync(`cd "${PROJECT_DIR}" && xcodebuild -resolvePackageDependencies -project "${projectPath}" -scheme "${SCHEME_NAME}"`,
226
228
  // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process - PROJECT_DIR, projectPath, and SCHEME_NAME are generated from trusted iOS project config.
227
229
  {
228
- stdio: "inherit"
230
+ stdio: ["ignore", "pipe", "pipe"]
229
231
  });
230
- progress.log("Package dependencies resolved successfully", "success");
231
232
  } catch (error) {
232
233
  if (isNotificationsEnabled) {
233
- progress.log(`❌ CRITICAL: Package resolution failed. Firebase dependencies required for notifications could not be resolved.`, "error");
234
+ progress.log(`CRITICAL: Package resolution failed. Firebase dependencies required for notifications could not be resolved.`, "error");
234
235
  throw new Error(`Package resolution failed: ${error.message}. This is required when notifications are enabled.`);
235
236
  } else {
236
237
  progress.log(`Warning: Package resolution may have failed: ${error.message}`, "warning");
237
238
  }
238
239
  }
239
240
  }
240
- progress.log("✅ Package.swift ready", "success");
241
241
  } catch (error) {
242
- progress.log(`❌ Failed to generate Package.swift: ${error.message}`, "error");
242
+ progress.log(`Failed to generate Package.swift: ${error.message}`, "error");
243
243
  throw error;
244
244
  }
245
245
  }
@@ -248,7 +248,7 @@ ${formatSwiftProductEntries(notificationsDependencies).join(",\n")}
248
248
  const isNotificationsEnabled = WEBVIEW_CONFIG.notifications?.enabled ?? false;
249
249
  const projectFilePath = path.join(PROJECT_DIR, `${PROJECT_NAME}.xcodeproj`, "project.pbxproj"); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
250
250
 
251
- progress.log(`🔧 Updating Xcode package dependencies (notifications: ${isNotificationsEnabled})`, "info");
251
+ progress.log(`Updating Xcode package dependencies (notifications: ${isNotificationsEnabled})`, "info");
252
252
  let projectContent;
253
253
  try {
254
254
  projectContent = fs.readFileSync(projectFilePath, "utf8");
@@ -265,7 +265,7 @@ ${formatSwiftProductEntries(notificationsDependencies).join(",\n")}
265
265
  projectContent = projectContent.replace(/(packageProductDependencies = \(\s*C99974332E97D56900C25611 \/\* CatalystCore \*\/,)/, `$1\n\t\t\t\t${NOTIF_PRODUCT_ID} /* CatalystNotifications */,`);
266
266
  projectContent = projectContent.replace(/(\/\* End XCSwiftPackageProductDependency section \*\/)/, `\t\t${NOTIF_PRODUCT_ID} /* CatalystNotifications */ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tpackage = C99974322E97D56900C25611 /* XCLocalSwiftPackageReference "." */;\n\t\t\tproductName = CatalystNotifications;\n\t\t};\n$1`);
267
267
  fs.writeFileSync(projectFilePath, projectContent, "utf8");
268
- progress.log("✅ CatalystNotifications added to Xcode project", "success");
268
+ progress.log("CatalystNotifications added to Xcode project", "success");
269
269
  } else if (!isNotificationsEnabled && hasNotifications) {
270
270
  progress.log("Removing CatalystNotifications from Xcode project", "info");
271
271
  projectContent = projectContent.replace(/\t\t[A-F0-9]+ \/\* CatalystNotifications in Frameworks \*\/ = {isa = PBXBuildFile; productRef = [A-F0-9]+ \/\* CatalystNotifications \*\/; };\n/g, "");
@@ -273,12 +273,10 @@ ${formatSwiftProductEntries(notificationsDependencies).join(",\n")}
273
273
  projectContent = projectContent.replace(/\t\t\t\t[A-F0-9]+ \/\* CatalystNotifications \*\/,\n/g, "");
274
274
  projectContent = projectContent.replace(/\t\t[A-F0-9]+ \/\* CatalystNotifications \*\/ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tpackage = [A-F0-9]+ \/\* XCLocalSwiftPackageReference "." \*\/;\n\t\t\tproductName = CatalystNotifications;\n\t\t};\n/g, "");
275
275
  fs.writeFileSync(projectFilePath, projectContent, "utf8");
276
- progress.log("✅ CatalystNotifications removed from Xcode project", "success");
277
- } else {
278
- progress.log("Package dependencies already correct", "info");
276
+ progress.log("CatalystNotifications removed from Xcode project", "success");
279
277
  }
280
278
  } catch (error) {
281
- progress.log(`❌ Failed to update package dependencies: ${error.message}`, "error");
279
+ progress.log(`Failed to update package dependencies: ${error.message}`, "error");
282
280
  throw error;
283
281
  }
284
282
  }
@@ -349,7 +347,6 @@ ${formatSwiftProductEntries(notificationsDependencies).join(",\n")}
349
347
  });
350
348
  await removePluginResourcesFromXcodeProject();
351
349
  if (resources.length === 0) {
352
- progress.log("No managed plugin resources to sync", "info");
353
350
  return;
354
351
  }
355
352
  for (const resource of resources) {
@@ -371,7 +368,7 @@ ${formatSwiftProductEntries(notificationsDependencies).join(",\n")}
371
368
  await addPluginResourcesToXcodeProject(resources);
372
369
  progress.log(`Synced ${resources.length} managed plugin resource(s)`, "success");
373
370
  } catch (error) {
374
- progress.log(`❌ Failed to sync plugin resources: ${error.message}`, "error");
371
+ progress.log(`Failed to sync plugin resources: ${error.message}`, "error");
375
372
  throw error;
376
373
  }
377
374
  }
@@ -245,7 +245,6 @@ function discoverInternalPlugins(corePluginsRoot, log = () => {}) {
245
245
  plugins.push(parsed);
246
246
  }
247
247
  }
248
- log(`Discovered ${plugins.length} internal plugin manifest(s)`, "info");
249
248
  return plugins;
250
249
  }
251
250
  function resolveInternalPluginsRoot(packageRoot) {
@@ -51,6 +51,8 @@ public enum ConfigConstants {
51
51
  public static let splashScreenEnabled = false
52
52
  public static let splashScreenDuration: TimeInterval? = nil
53
53
  public static let splashScreenBackgroundColor = "#ffffff"
54
+ public static let splashScreenHtml: String? = nil
55
+ public static let splashScreenMinDuration: TimeInterval = 0
54
56
 
55
57
  #if canImport(UIKit)
56
58
  public static let splashScreenImageWidth: CGFloat = 400
@@ -0,0 +1,31 @@
1
+ import SwiftUI
2
+ import WebKit
3
+ import CatalystCoreLogic
4
+
5
+ /// Decoded public/splashscreen.html, embedded as base64 by the iOS build.
6
+ /// nil when the app has no HTML splash (or the payload can't be decoded).
7
+ enum SplashHTML {
8
+ static let content: String? = ConfigConstants.splashScreenHtml
9
+ .flatMap { Data(base64Encoded: $0) }
10
+ .flatMap { String(data: $0, encoding: .utf8) }
11
+ }
12
+
13
+ /// Non-interactive, transparent WKWebView that renders the HTML splash above the native splash image.
14
+ struct SplashHTMLView: UIViewRepresentable {
15
+ func makeUIView(context: Context) -> WKWebView {
16
+ let webView = WKWebView(frame: .zero)
17
+ webView.isOpaque = false
18
+ webView.backgroundColor = .clear
19
+ webView.scrollView.backgroundColor = .clear
20
+ webView.scrollView.isScrollEnabled = false
21
+ webView.scrollView.contentInsetAdjustmentBehavior = .never
22
+ webView.isUserInteractionEnabled = false
23
+
24
+ if let html = SplashHTML.content {
25
+ webView.loadHTMLString(html, baseURL: nil)
26
+ }
27
+ return webView
28
+ }
29
+
30
+ func updateUIView(_ uiView: WKWebView, context: Context) {}
31
+ }
@@ -30,15 +30,31 @@ public struct SplashView: View {
30
30
  }
31
31
  }
32
32
  )
33
+ .overlay(
34
+ // HTML splash from public/splashscreen.html, drawn above the native image
35
+ Group {
36
+ if splashViewModel.isHTMLMode {
37
+ SplashHTMLView()
38
+ .ignoresSafeArea(.all)
39
+ }
40
+ }
41
+ )
33
42
  .onReceive(webViewModel.$loadingProgress) { progress in
43
+ // HTML splash dismisses on the initial load only, not on progress
44
+ guard !splashViewModel.isHTMLMode else { return }
34
45
  splashViewModel.updateProgress(progress)
35
46
  }
36
47
  .onReceive(webViewModel.$isLoading) { isLoading in
37
48
  // If WebView stops loading and no duration is set, dismiss splash
38
- if !isLoading && ConfigConstants.splashScreenDuration == nil {
49
+ if !isLoading && ConfigConstants.splashScreenDuration == nil && !splashViewModel.isHTMLMode {
39
50
  splashViewModel.forceDismiss()
40
51
  }
41
52
  }
53
+ .onReceive(webViewModel.$hasFinishedInitialLoad) { finished in
54
+ if finished {
55
+ splashViewModel.initialLoadFinished()
56
+ }
57
+ }
42
58
  }
43
59
  }
44
60
 
@@ -13,13 +13,24 @@ public class SplashViewModel: ObservableObject {
13
13
  private var durationTimer: Timer?
14
14
  private var hasWebViewLoaded = false
15
15
 
16
+ // HTML splash (public/splashscreen.html): ignores duration and progress, dismisses after the
17
+ // first page load once splashScreenMinDuration has elapsed, capped at htmlSplashMaxDuration.
18
+ let isHTMLMode = SplashHTML.content != nil
19
+ private static let htmlSplashMaxDuration: TimeInterval = 10
20
+ private var minDurationTimer: Timer?
21
+
16
22
  public init() {
17
23
  logger.info("SplashViewModel initialized")
18
- startDurationTimer()
24
+ if isHTMLMode {
25
+ startHTMLSplashCapTimer()
26
+ } else {
27
+ startDurationTimer()
28
+ }
19
29
  }
20
30
 
21
31
  deinit {
22
32
  durationTimer?.invalidate()
33
+ minDurationTimer?.invalidate()
23
34
  }
24
35
 
25
36
  private func startDurationTimer() {
@@ -39,6 +50,33 @@ public class SplashViewModel: ObservableObject {
39
50
  }
40
51
  }
41
52
 
53
+ private func startHTMLSplashCapTimer() {
54
+ logger.info("HTML splash: waiting for initial page load (cap \(Self.htmlSplashMaxDuration) seconds)")
55
+
56
+ durationTimer = Timer.scheduledTimer(withTimeInterval: Self.htmlSplashMaxDuration, repeats: false) { [weak self] _ in
57
+ DispatchQueue.main.async {
58
+ self?.dismissSplash()
59
+ }
60
+ }
61
+ }
62
+
63
+ func initialLoadFinished() {
64
+ guard isHTMLMode, shouldShowSplash, minDurationTimer == nil else { return }
65
+
66
+ let remaining = ConfigConstants.splashScreenMinDuration - Date().timeIntervalSince(startTime)
67
+ guard remaining > 0 else {
68
+ dismissSplash()
69
+ return
70
+ }
71
+
72
+ logger.info("HTML splash: initial load finished, holding for \(remaining) seconds")
73
+ minDurationTimer = Timer.scheduledTimer(withTimeInterval: remaining, repeats: false) { [weak self] _ in
74
+ DispatchQueue.main.async {
75
+ self?.dismissSplash()
76
+ }
77
+ }
78
+ }
79
+
42
80
  func updateProgress(_ progress: Double) {
43
81
  logger.debug("WebView progress updated: \(progress)")
44
82
 
@@ -73,6 +111,8 @@ public class SplashViewModel: ObservableObject {
73
111
 
74
112
  durationTimer?.invalidate()
75
113
  durationTimer = nil
114
+ minDurationTimer?.invalidate()
115
+ minDurationTimer = nil
76
116
 
77
117
  withAnimation(.easeOut(duration: 0.3)) {
78
118
  shouldShowSplash = false
@@ -12,6 +12,8 @@ public class WebViewModel: ObservableObject {
12
12
  @Published public var loadingProgress: Double = 0.0
13
13
  @Published public var lastLoadedURL: URL?
14
14
  @Published public var isLoadingFromCache: Bool = false
15
+ // One-shot: true after the first page (or offline page) finishes loading; never reset
16
+ @Published public var hasFinishedInitialLoad = false
15
17
 
16
18
  // Safe area insets
17
19
  @Published public var safeAreaInsets: SafeAreaInsets = .zero
@@ -339,6 +339,9 @@ class WebViewNavigationDelegate: NSObject, WKNavigationDelegate {
339
339
  viewModel.canGoBack = webView.canGoBack
340
340
  viewModel.setLoading(false, fromCache: false)
341
341
  }
342
+ if !viewModel.hasFinishedInitialLoad {
343
+ viewModel.hasFinishedInitialLoad = true
344
+ }
342
345
  }
343
346
  }
344
347
 
@@ -226,7 +226,7 @@ function copyAndroidPluginSources(plugins, javaRoot, log) {
226
226
  }
227
227
  log(`Copied ${copiedCount} Android plugin source file(s)`, "info");
228
228
  }
229
- function copyPluginAssets(plugins, androidProjectPath, log) {
229
+ function copyPluginAssets(plugins, androidProjectPath) {
230
230
  // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - The destination path components are fixed.
231
231
  const baseAssetsDir = path.join(androidProjectPath, "app", "src", "main", "assets", "plugins");
232
232
  fs.rmSync(baseAssetsDir, {
@@ -242,7 +242,6 @@ function copyPluginAssets(plugins, androidProjectPath, log) {
242
242
  // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - These are fixed asset directories beneath the discovered internal plugin directory.
243
243
  copyTree(path.join(plugin.pluginDir, "assets", "android"), path.join(pluginAssetsDir, "android"));
244
244
  }
245
- log("Plugin assets copied to app/src/main/assets/plugins", "info");
246
245
  }
247
246
  function formatKotlinMap(entries, emptyLiteral = "emptyMap()") {
248
247
  return entries.length === 0 ? emptyLiteral : `mapOf(\n${entries.join(",\n")}\n )`;
@@ -396,7 +395,7 @@ function composeAndroidPlugins({
396
395
  // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - These are fixed paths within the generated Android project.
397
396
  const proguardPath = path.join(androidProjectPath, "app", "proguard-rules.pro");
398
397
  copyAndroidPluginSources(selected, javaRoot, log);
399
- copyPluginAssets(selected, androidProjectPath, log);
398
+ copyPluginAssets(selected, androidProjectPath);
400
399
  generatePluginRegistryFiles(selected, javaRoot);
401
400
  updateAndroidManifestPermissions(manifestPath, selected.flatMap(plugin => plugin.android?.permissions || []), discovered.flatMap(plugin => plugin.android?.permissions || []));
402
401
  updateGradleDependencies(gradlePath, selected.flatMap(plugin => plugin.android?.dependencies || []), discovered.flatMap(plugin => plugin.android?.dependencies || []));