catalyst-core 1.0.1 → 1.0.2-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.
- package/bin/catalyst.js +105 -1
- package/dist/cli/README.md +119 -0
- package/dist/cli/appConfig.js +50 -0
- package/dist/cli/buildErrors.js +170 -0
- package/dist/cli/buildWarnings.js +28 -0
- package/dist/cli/devOutput.js +524 -0
- package/dist/cli/diagnostic.js +388 -0
- package/dist/cli/hints.js +69 -0
- package/dist/cli/package.json +3 -0
- package/dist/cli/spinner.js +141 -0
- package/dist/cli/theme.js +155 -0
- package/dist/errors/index.js +7 -3
- package/dist/errors/registry.js +8 -0
- package/dist/errors-index.json +7 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.jsx +1 -1
- package/dist/logger.js +61 -6
- package/dist/native/androidProject/app/build.gradle.kts +2 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +2 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +9 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +65 -34
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashOverlay.kt +109 -0
- package/dist/native/androidSetup.js +46 -57
- package/dist/native/buildAndroid/assets.js +22 -13
- package/dist/native/buildAndroid/build.js +98 -26
- package/dist/native/buildAndroid/config.js +43 -12
- package/dist/native/buildAndroid/index.js +49 -191
- package/dist/native/buildAppAndroid.js +19 -8
- package/dist/native/buildAppIos.js +35 -16
- package/dist/native/buildIos/assets.js +1 -6
- package/dist/native/buildIos/build.js +61 -94
- package/dist/native/buildIos/config.js +26 -8
- package/dist/native/buildIos/index.js +143 -64
- package/dist/native/buildIos/plugins.js +12 -15
- package/dist/native/internalPluginUtils.js +0 -1
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Constants/ConfigConstants.swift +2 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashHTMLView.swift +31 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashView.swift +17 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashViewModel.swift +41 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewModel.swift +2 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +3 -0
- package/dist/native/pluginComposerAndroid.js +2 -3
- package/dist/native/renameAndroidProject.js +42 -20
- package/dist/native/setupEmulatorIos.js +28 -49
- package/dist/native/setupServer.js +12 -15
- package/dist/native/terminalProgress.js +186 -147
- package/dist/native/utils.js +335 -54
- package/dist/otel.js +15 -9
- package/dist/scripts/build.js +314 -42
- package/dist/scripts/generateOfflineManifest.js +11 -1
- package/dist/scripts/loadEnvironmentVariables.js +24 -2
- package/dist/scripts/preServerInit.js +32 -10
- package/dist/scripts/preflight.js +115 -11
- package/dist/scripts/scriptUtils.js +34 -0
- package/dist/scripts/serve.js +65 -9
- package/dist/scripts/start.js +58 -21
- package/dist/server/expressServer.js +167 -32
- package/dist/server/renderer/document/Body.d.ts +0 -11
- package/dist/server/renderer/document/Body.jsx +0 -10
- package/dist/server/renderer/document/Head.d.ts +0 -13
- package/dist/server/renderer/document/Head.jsx +0 -12
- package/dist/server/renderer/handler.jsx +87 -11
- package/dist/server/utils/validator.js +14 -11
- package/dist/vite/manifest-categorization-plugin.js +7 -5
- package/dist/vite/resolveDevServerConfig.js +9 -2
- package/dist/vite/vite.config.client.js +19 -1
- package/dist/vite/vite.config.js +96 -19
- package/dist/vite/vite.config.server.js +21 -0
- package/dist/web-router/components/MetaTag.jsx +20 -14
- package/dist/web-router/components/MetaTag.test.jsx +108 -32
- package/dist/web-router/components/RouterDataProvider.d.ts +25 -1
- package/dist/web-router/components/RouterDataProvider.jsx +35 -1
- package/dist/web-router/components/RouterDataProvider.test.jsx +44 -0
- package/dist/web-router/context.d.ts +15 -1
- package/dist/web-router/context.jsx +11 -0
- package/dist/webmcp/WebMcpProvider.jsx +234 -0
- package/dist/webmcp/declarative.js +207 -0
- package/dist/webmcp/declarative.test.js +122 -0
- package/dist/webmcp/errors.js +59 -0
- package/dist/webmcp/errors.test.js +47 -0
- package/dist/webmcp/index.js +40 -0
- package/dist/webmcp/native.js +37 -0
- package/dist/webmcp/native.test.js +41 -0
- package/dist/webmcp/pageInfo.js +119 -0
- package/dist/webmcp/pageInfo.test.js +85 -0
- package/dist/webmcp/registry.dom.test.js +175 -0
- package/dist/webmcp/registry.js +540 -0
- package/dist/webmcp/registry.test.js +228 -0
- package/dist/webmcp/routeContext.js +11 -0
- package/dist/webmcp/shim-entry.js +8 -0
- package/dist/webmcp/shim.js +149 -0
- package/dist/webmcp/useTool.js +124 -0
- package/package.json +4 -7
- package/test/cli/buildErrors.test.ts +173 -0
- package/test/cli/buildWarnings.test.cjs +62 -0
- package/test/cli/devOutput.test.ts +549 -0
- package/test/cli/diagnostic.test.ts +434 -0
- package/test/cli/output.test.cjs +90 -0
- package/test/cli/spinner.test.ts +177 -0
- package/test/cli/theme.test.ts +163 -0
- package/test/native/redactSecrets.test.ts +72 -0
- package/test/native/utils.test.ts +532 -0
- package/test/scripts/build.test.ts +275 -0
- package/test/scripts/preflight.test.ts +100 -2
- package/test/scripts/serve.test.ts +102 -0
- package/test/server/fixtures/template/src/js/routes/index.jsx +8 -0
- package/test/server/handler.test.ts +17 -0
- package/test/server/logger.test.ts +185 -0
- package/test/server/manifestCache.test.ts +13 -0
- package/test/server/otel.test.ts +556 -0
- package/test/server/validator.test.ts +19 -8
- package/vitest.config.ts +4 -0
- package/dist/scripts/checkVersion.js +0 -3
- 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: "
|
|
38
|
-
deviceDetection: "
|
|
39
|
-
launchSimulator: "Launch iOS
|
|
40
|
-
clean: "Clean
|
|
41
|
-
assets: "Process
|
|
42
|
-
build: "Build
|
|
43
|
-
findApp: "Locate
|
|
44
|
-
install: "Install
|
|
45
|
-
launch: "Launch
|
|
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, "
|
|
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
|
-
|
|
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
|
|
71
|
+
}, (error, stdout) => {
|
|
65
72
|
if (error) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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: `
|
|
255
|
-
indent: 1,
|
|
256
|
-
prefix: "├─ ",
|
|
328
|
+
text: `artifact ${displayPath(APP_PATH, originalDir)}`,
|
|
257
329
|
color: "gray"
|
|
258
330
|
}, {
|
|
259
|
-
text: `
|
|
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
|
-
|
|
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("
|
|
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(
|
|
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: "
|
|
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(
|
|
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(
|
|
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(
|
|
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("
|
|
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("
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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 || []));
|