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.
- package/bin/catalyst.js +54 -6
- package/changelog.md +27 -1
- package/dist/errors/registry.js +306 -18
- package/dist/errors-index.json +273 -70
- package/dist/native/androidProject/app/build.gradle.kts +53 -79
- package/dist/native/androidProject/app/src/main/AndroidManifest.xml +1 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CatalystConstants.kt +3 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +32 -4
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +5 -1
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/ConfigMappingTest.kt +25 -0
- package/dist/native/androidSetup.js +76 -170
- package/dist/native/androidToolchain.js +642 -0
- package/dist/native/appIcon.js +100 -0
- package/dist/native/buildAndroid/assets.js +29 -5
- package/dist/native/buildAndroid/build.js +110 -223
- package/dist/native/buildAndroid/config.js +57 -65
- package/dist/native/buildAndroid/devices.js +176 -0
- package/dist/native/buildAndroid/index.js +271 -46
- package/dist/native/buildAndroid/output.js +43 -0
- package/dist/native/buildAndroid/signing.js +456 -0
- package/dist/native/buildAppAndroid.js +27 -7
- package/dist/native/buildAppIos.js +139 -20
- package/dist/native/buildIos/assets.js +76 -4
- package/dist/native/buildIos/build.js +31 -181
- package/dist/native/buildIos/config.js +126 -11
- package/dist/native/buildIos/index.js +354 -34
- package/dist/native/buildIos/localhostCert.js +87 -0
- package/dist/native/buildIos/output.js +28 -0
- package/dist/native/buildIos/signing.js +596 -0
- package/dist/native/credentials.js +309 -0
- package/dist/native/exportCredentials.js +51 -0
- package/dist/native/iosToolchain.js +583 -0
- package/dist/native/iosnativeWebView/Configurations/Shared.xcconfig +1 -3
- package/dist/native/iosnativeWebView/iosnativeWebView/Info-Release.plist +2 -0
- package/dist/native/iosnativeWebView/iosnativeWebView/PrivacyInfo.xcprivacy +40 -0
- package/dist/native/iosnativeWebView/iosnativeWebView/iosnativeWebView.entitlements +0 -2
- package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +4 -4
- package/dist/native/localState.js +148 -0
- package/dist/native/nativeConfig.js +621 -0
- package/dist/native/releaseOutput.js +53 -0
- package/dist/native/renameAndroidProject.js +154 -369
- package/dist/native/setupEmulatorIos.js +58 -275
- package/dist/native/setupServer.js +520 -124
- package/dist/native/utils.js +418 -115
- package/dist/vite/resolveDevServerConfig.js +21 -0
- package/mcp_v2/knowledge-base.json +13 -11
- package/mcp_v2/test/config.test.ts +40 -0
- package/mcp_v2/tools/build.js +80 -104
- package/mcp_v2/tools/config.js +181 -174
- package/package.json +1 -1
- package/test/cli/nativeFlags.test.ts +97 -0
- package/test/native/androidDevices.test.ts +230 -0
- package/test/native/androidElfAlignment.test.ts +148 -0
- package/test/native/androidSigning.test.ts +646 -0
- package/test/native/androidToolchain.test.ts +766 -0
- package/test/native/appIcon.test.ts +124 -0
- package/test/native/credentials.test.ts +241 -0
- package/test/native/interaction.test.ts +179 -0
- package/test/native/iosConfigPhase.test.ts +611 -0
- package/test/native/iosSigning.test.ts +1060 -0
- package/test/native/iosToolchain.test.ts +821 -0
- package/test/native/localState.test.ts +80 -0
- package/test/native/nativeConfig.test.ts +602 -0
- package/test/native/setupServer.test.ts +529 -0
- package/test/native/utils.test.ts +102 -127
- package/test/vite/resolveDevServerConfig.test.js +25 -0
- package/dist/native/androidProject/keystore/Untitled +0 -0
- package/dist/native/androidProject/keystore/release-key.jks +0 -0
- package/dist/native/iosnativeWebView/iosnativeWebView/localhost.p12 +0 -0
|
@@ -1,165 +1,561 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.DEFAULT_HMR_PORT = void 0;
|
|
7
|
+
exports.devServerPorts = devServerPorts;
|
|
8
|
+
exports.ensureDevServer = ensureDevServer;
|
|
9
|
+
exports.getDeviceReachableHost = getDeviceReachableHost;
|
|
10
|
+
exports.getLocalIPAddress = getLocalIPAddress;
|
|
11
|
+
exports.isProcessAlive = isProcessAlive;
|
|
12
|
+
exports.isServerAnswering = isServerAnswering;
|
|
13
|
+
exports.migrateLanAddresses = migrateLanAddresses;
|
|
14
|
+
exports.planLanAddressMigration = planLanAddressMigration;
|
|
15
|
+
exports.readHmrPort = readHmrPort;
|
|
16
|
+
exports.readLocalHostName = readLocalHostName;
|
|
17
|
+
exports.startDevServerProcess = startDevServerProcess;
|
|
18
|
+
var _child_process = require("child_process");
|
|
19
|
+
var _fs = _interopRequireDefault(require("fs"));
|
|
20
|
+
var _http = _interopRequireDefault(require("http"));
|
|
21
|
+
var _net = _interopRequireDefault(require("net"));
|
|
22
|
+
var _os = _interopRequireDefault(require("os"));
|
|
23
|
+
var _path = _interopRequireDefault(require("path"));
|
|
24
|
+
var _utils = require("./utils.js");
|
|
25
|
+
var _nativeConfig = require("./nativeConfig.js");
|
|
26
|
+
var _localState = require("./localState.js");
|
|
27
|
+
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
|
|
28
|
+
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
|
|
29
|
+
// The dev server for debug native builds. The CLI never rewrites
|
|
30
|
+
// config.json to reach it: Android emulators and USB phones get
|
|
31
|
+
// `adb reverse` on the ports below, the iOS simulator uses localhost, and a
|
|
32
|
+
// physical iPhone gets the Mac's Bonjour name (or LAN IP) injected into the
|
|
33
|
+
// build only. The server is always started through `catalyst start` with
|
|
34
|
+
// KEY=value overrides.
|
|
35
|
+
|
|
36
|
+
const DEFAULT_PORT = 3005;
|
|
37
|
+
// Vite's HMR websocket port when buildConfig.devServer.hmr sets none.
|
|
38
|
+
const DEFAULT_HMR_PORT = exports.DEFAULT_HMR_PORT = 24678;
|
|
39
|
+
|
|
40
|
+
/** Lower is better: the Wi-Fi/Ethernet a phone on the same network can reach. */
|
|
41
|
+
function interfaceRank(name) {
|
|
42
|
+
if (name === "en0") return 0;
|
|
43
|
+
if (/^(en\d+|wlan\d+|wl\w+)$/.test(name)) return 1;
|
|
44
|
+
if (/^(eth\d+|enp\w+|eno\w+)$/.test(name)) return 2;
|
|
45
|
+
return 3;
|
|
46
|
+
}
|
|
9
47
|
|
|
10
48
|
/**
|
|
11
|
-
*
|
|
49
|
+
* This machine's LAN address: the first non-internal IPv4, preferring en0 and
|
|
50
|
+
* Wi-Fi/Ethernet over VPN tunnels and VM bridges. Null when there is none.
|
|
51
|
+
*
|
|
52
|
+
* @param {ReturnType<typeof os.networkInterfaces>} [interfaces]
|
|
53
|
+
* @returns {string | null}
|
|
12
54
|
*/
|
|
13
|
-
function getLocalIPAddress() {
|
|
55
|
+
function getLocalIPAddress(interfaces = _os.default.networkInterfaces()) {
|
|
56
|
+
const candidates = [];
|
|
57
|
+
for (const [name, entries] of Object.entries(interfaces || {})) {
|
|
58
|
+
for (const entry of entries || []) {
|
|
59
|
+
// `family` is the number 4 on Node 18.0-18.3.
|
|
60
|
+
const v4 = entry.family === "IPv4" || entry.family === 4;
|
|
61
|
+
if (v4 && !entry.internal) candidates.push({
|
|
62
|
+
name,
|
|
63
|
+
address: entry.address
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
candidates.sort((a, b) => interfaceRank(a.name) - interfaceRank(b.name));
|
|
68
|
+
return candidates.length ? candidates[0].address : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The host a physical device on the same network reaches this Mac on:
|
|
73
|
+
* `<LocalHostName>.local` (Bonjour, survives DHCP changes), else the LAN IP.
|
|
74
|
+
* Null when neither is available.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} [options]
|
|
77
|
+
* @param {() => string} [options.localHostName] - returns `scutil --get LocalHostName`
|
|
78
|
+
* @param {ReturnType<typeof os.networkInterfaces>} [options.interfaces]
|
|
79
|
+
* @returns {{ host: string, source: "bonjour" | "lan" } | null}
|
|
80
|
+
*/
|
|
81
|
+
function getDeviceReachableHost({
|
|
82
|
+
localHostName = readLocalHostName,
|
|
83
|
+
interfaces
|
|
84
|
+
} = {}) {
|
|
85
|
+
let name = "";
|
|
14
86
|
try {
|
|
15
|
-
|
|
16
|
-
return execSync(command).toString().trim();
|
|
87
|
+
name = String(localHostName() || "").trim();
|
|
17
88
|
} catch (error) {
|
|
18
|
-
|
|
19
|
-
|
|
89
|
+
// Not macOS, or scutil failed: fall back to the LAN IP.
|
|
90
|
+
}
|
|
91
|
+
if (/^[A-Za-z0-9-]+$/.test(name)) return {
|
|
92
|
+
host: `${name}.local`,
|
|
93
|
+
source: "bonjour"
|
|
94
|
+
};
|
|
95
|
+
const ip = getLocalIPAddress(interfaces);
|
|
96
|
+
return ip ? {
|
|
97
|
+
host: ip,
|
|
98
|
+
source: "lan"
|
|
99
|
+
} : null;
|
|
100
|
+
}
|
|
101
|
+
function readLocalHostName({
|
|
102
|
+
platform = process.platform,
|
|
103
|
+
exec = _child_process.execFileSync
|
|
104
|
+
} = {}) {
|
|
105
|
+
if (platform !== "darwin") return "";
|
|
106
|
+
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process - fixed argv.
|
|
107
|
+
return exec("scutil", ["--get", "LocalHostName"], {
|
|
108
|
+
encoding: "utf8",
|
|
109
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
110
|
+
timeout: 5000
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Vite's HMR port for the app: buildConfig.js `devServer.hmr.port`, else
|
|
116
|
+
* 24678. Best effort: an unreadable buildConfig gives the default.
|
|
117
|
+
*
|
|
118
|
+
* @param {object} [options]
|
|
119
|
+
* @param {string} [options.cwd]
|
|
120
|
+
* @returns {Promise<number>}
|
|
121
|
+
*/
|
|
122
|
+
async function readHmrPort({
|
|
123
|
+
cwd = process.cwd()
|
|
124
|
+
} = {}) {
|
|
125
|
+
for (const name of ["buildConfig.js", "webpackConfig.js"]) {
|
|
126
|
+
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - fixed file names in the app root.
|
|
127
|
+
const file = _path.default.join(cwd, name);
|
|
128
|
+
if (!_fs.default.existsSync(file)) continue;
|
|
129
|
+
try {
|
|
130
|
+
// A plain path, not a file:// URL: the published copy is compiled
|
|
131
|
+
// to CommonJS, where import() becomes require(), which loads the
|
|
132
|
+
// app's ESM buildConfig.js by path (Node 22.12+) but not by URL.
|
|
133
|
+
const mod = await (specifier => new Promise(r => r(`${specifier}`)).then(s => _interopRequireWildcard(require(s))))(file);
|
|
134
|
+
const port = Number((mod.default || mod)?.devServer?.hmr?.port);
|
|
135
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : DEFAULT_HMR_PORT;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
return DEFAULT_HMR_PORT;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return DEFAULT_HMR_PORT;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Every `localhost:<port>` / `127.0.0.1:<port>` in the config's string values. */
|
|
144
|
+
function localhostPortsIn(value, found = new Set()) {
|
|
145
|
+
if (typeof value === "string") {
|
|
146
|
+
for (const match of value.matchAll(/(?:localhost|127\.0\.0\.1|\[::1\]):(\d{1,5})\b/gi)) {
|
|
147
|
+
const port = Number(match[1]);
|
|
148
|
+
if (port > 0 && port < 65536) found.add(port);
|
|
149
|
+
}
|
|
150
|
+
} else if (value && typeof value === "object") {
|
|
151
|
+
for (const child of Object.values(value)) localhostPortsIn(child, found);
|
|
20
152
|
}
|
|
153
|
+
return found;
|
|
21
154
|
}
|
|
22
155
|
|
|
23
156
|
/**
|
|
24
|
-
*
|
|
157
|
+
* The ports an Android device needs reversed to reach the dev machine:
|
|
158
|
+
* NODE_SERVER_PORT, the HMR port, and every localhost:<port> in config.json
|
|
159
|
+
* (API_URL and the like). Sorted, no duplicates.
|
|
160
|
+
*
|
|
161
|
+
* @param {object} config - the parsed config.json
|
|
162
|
+
* @param {object} [options]
|
|
163
|
+
* @param {number} [options.hmrPort] - from readHmrPort(); 24678 when omitted
|
|
164
|
+
* @returns {number[]}
|
|
25
165
|
*/
|
|
26
|
-
|
|
166
|
+
function devServerPorts(config = {}, {
|
|
167
|
+
hmrPort = DEFAULT_HMR_PORT
|
|
168
|
+
} = {}) {
|
|
169
|
+
const ports = localhostPortsIn(config);
|
|
170
|
+
ports.add(Number(config.NODE_SERVER_PORT || config.WEBVIEW_CONFIG?.port || DEFAULT_PORT));
|
|
171
|
+
if (hmrPort) ports.add(Number(hmrPort));
|
|
172
|
+
return [...ports].filter(port => Number.isInteger(port) && port > 0).sort((a, b) => a - b);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Whether an HTTP server answers at host:port. Any status counts: a 500 from
|
|
177
|
+
* a broken page still means the server is up.
|
|
178
|
+
*
|
|
179
|
+
* @param {{ host?: string, port: number, timeoutMs?: number }} target
|
|
180
|
+
* @returns {Promise<boolean>}
|
|
181
|
+
*/
|
|
182
|
+
function isServerAnswering({
|
|
183
|
+
host = "localhost",
|
|
184
|
+
port,
|
|
185
|
+
timeoutMs = 2000
|
|
186
|
+
}) {
|
|
27
187
|
return new Promise(resolve => {
|
|
28
|
-
const
|
|
29
|
-
|
|
188
|
+
const request = _http.default.get({
|
|
189
|
+
host,
|
|
190
|
+
port,
|
|
191
|
+
path: "/",
|
|
192
|
+
timeout: timeoutMs
|
|
193
|
+
}, response => {
|
|
194
|
+
response.resume();
|
|
195
|
+
resolve(true);
|
|
196
|
+
});
|
|
197
|
+
request.on("timeout", () => {
|
|
198
|
+
request.destroy();
|
|
30
199
|
resolve(false);
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
execFile("lsof", ["-i", `:${normalizedPort}`], (error, stdout) => {
|
|
34
|
-
if (error) {
|
|
35
|
-
resolve(false);
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
resolve(stdout.includes("LISTEN"));
|
|
39
200
|
});
|
|
201
|
+
request.on("error", () => resolve(false));
|
|
40
202
|
});
|
|
41
203
|
}
|
|
42
204
|
|
|
205
|
+
/** The bin script of this catalyst-core, for `catalyst start`. */
|
|
206
|
+
function catalystBin() {
|
|
207
|
+
// dist/native/setupServer.js (and src/native in a checkout) -> bin/catalyst.js
|
|
208
|
+
// eslint-disable-next-line no-undef
|
|
209
|
+
const here = typeof __dirname !== "undefined" ? __dirname : process.cwd();
|
|
210
|
+
return _path.default.resolve(here, "..", "..", "bin", "catalyst.js");
|
|
211
|
+
}
|
|
212
|
+
|
|
43
213
|
/**
|
|
44
|
-
* Start
|
|
214
|
+
* Start `catalyst start KEY=value ...` detached, output in a log file. It has
|
|
215
|
+
* to outlive this process, so it gets no pipe back to it. The log goes to the
|
|
216
|
+
* app's .catalyst/ folder (owner only, kept out of git), not a shared temp
|
|
217
|
+
* directory where another user could plant a file or link at that name.
|
|
218
|
+
*
|
|
219
|
+
* @param {object} options
|
|
220
|
+
* @param {string} options.cwd - the app root
|
|
221
|
+
* @param {number} [options.port]
|
|
222
|
+
* @param {Record<string, string | number>} [options.overrides]
|
|
223
|
+
* @param {typeof spawn} [options.spawnProcess] - for tests
|
|
224
|
+
* @returns {{ child: import("child_process").ChildProcess, logFile: string }}
|
|
45
225
|
*/
|
|
46
|
-
function
|
|
47
|
-
|
|
226
|
+
function startDevServerProcess({
|
|
227
|
+
cwd,
|
|
228
|
+
port,
|
|
229
|
+
overrides = {},
|
|
230
|
+
spawnProcess = _child_process.spawn
|
|
231
|
+
}) {
|
|
232
|
+
const logFile = _path.default.join((0, _localState.ensureLocalStateDir)(cwd), `dev-server-${port || "default"}.log`);
|
|
233
|
+
const out = _fs.default.openSync(logFile, "a", 0o600);
|
|
234
|
+
const args = [catalystBin(), "start", ...Object.entries(overrides).map(([key, value]) => `${key}=${value}`)];
|
|
235
|
+
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process - fixed command with an argv array.
|
|
236
|
+
const child = spawnProcess(process.execPath, args, {
|
|
237
|
+
cwd,
|
|
48
238
|
detached: true,
|
|
49
|
-
stdio: "ignore"
|
|
239
|
+
stdio: ["ignore", out, out],
|
|
240
|
+
// `catalyst start` reads the app from PWD, not cwd.
|
|
241
|
+
env: {
|
|
242
|
+
...process.env,
|
|
243
|
+
PWD: cwd
|
|
244
|
+
}
|
|
50
245
|
});
|
|
51
|
-
|
|
52
|
-
|
|
246
|
+
child.on("error", () => {});
|
|
247
|
+
child.unref();
|
|
248
|
+
_fs.default.closeSync(out);
|
|
249
|
+
return {
|
|
250
|
+
child,
|
|
251
|
+
logFile
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const isLoopback = host => ["localhost", "127.0.0.1", "::1"].includes(String(host).toLowerCase());
|
|
255
|
+
|
|
256
|
+
/** Whether a process with this pid exists (EPERM: it does, owned by someone else). */
|
|
257
|
+
function isProcessAlive(pid) {
|
|
258
|
+
try {
|
|
259
|
+
process.kill(pid, 0);
|
|
260
|
+
return true;
|
|
261
|
+
} catch (error) {
|
|
262
|
+
return error.code === "EPERM";
|
|
53
263
|
}
|
|
54
264
|
}
|
|
55
265
|
|
|
56
266
|
/**
|
|
57
|
-
*
|
|
267
|
+
* The dev server this CLI started on `port` and that is still running, from
|
|
268
|
+
* .catalyst/local.json: { host, port, pid }. Null for a server the user
|
|
269
|
+
* started, or when the recorded process is gone.
|
|
58
270
|
*/
|
|
59
|
-
function
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Replace different IP addresses in URLs
|
|
74
|
-
const urlPattern = /http:\/\/(\d+\.\d+\.\d+\.\d+)/g;
|
|
75
|
-
const matches = [...value.matchAll(urlPattern)];
|
|
76
|
-
for (const match of matches) {
|
|
77
|
-
if (match[1] !== localIP) {
|
|
78
|
-
newValue = newValue.replace(match[1], localIP);
|
|
79
|
-
shouldUpdate = true;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
271
|
+
function startedDevServer({
|
|
272
|
+
cwd,
|
|
273
|
+
port,
|
|
274
|
+
isAlive
|
|
275
|
+
}) {
|
|
276
|
+
const record = (0, _localState.readLocalState)({
|
|
277
|
+
cwd
|
|
278
|
+
}).devServer;
|
|
279
|
+
if (!record || typeof record !== "object" || Number(record.port) !== port) return null;
|
|
280
|
+
if (!Number.isInteger(record.pid) || record.pid <= 0 || !isAlive(record.pid)) return null;
|
|
281
|
+
return record;
|
|
282
|
+
}
|
|
82
283
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
284
|
+
/**
|
|
285
|
+
* Make sure the dev server answers, starting it when it does not.
|
|
286
|
+
*
|
|
287
|
+
* `host` is the host the app will load the server on: "localhost" for
|
|
288
|
+
* emulators, simulators and adb-reversed phones; `<mac>.local` or the LAN IP
|
|
289
|
+
* for a physical iPhone. For a non-loopback host the server is started bound
|
|
290
|
+
* to all interfaces with PUBLIC_STATIC_ASSET_URL on that host, and a server
|
|
291
|
+
* already running on localhost only is an error (NATIVE-006) rather than a
|
|
292
|
+
* silent blank screen. A server this CLI started is recorded in
|
|
293
|
+
* .catalyst/local.json with the host it was started for; reusing it for a
|
|
294
|
+
* different host (an iPhone after the simulator, or back) is NATIVE-006 too,
|
|
295
|
+
* because its asset URL and bind address are for the other target.
|
|
296
|
+
*
|
|
297
|
+
* @param {object} options
|
|
298
|
+
* @param {string} [options.host] - default "localhost"
|
|
299
|
+
* @param {string} [options.cwd] - the app root
|
|
300
|
+
* @param {object} [options.config] - the parsed config.json; read from cwd when omitted
|
|
301
|
+
* @param {Record<string, string | number>} [options.overrides] - extra KEY=value for `catalyst start`
|
|
302
|
+
* @param {number} [options.timeoutMs] - default 120000
|
|
303
|
+
* @param {number} [options.intervalMs] - default 500
|
|
304
|
+
* @param {(message: string, type?: string) => void} [options.log]
|
|
305
|
+
* @param {typeof isServerAnswering} [options.probe] - for tests
|
|
306
|
+
* @param {typeof startDevServerProcess} [options.start] - for tests
|
|
307
|
+
* @param {(pid: number) => boolean} [options.isAlive] - for tests
|
|
308
|
+
* @returns {Promise<{ started: boolean, url: string, port: number, logFile?: string }>}
|
|
309
|
+
*/
|
|
310
|
+
async function ensureDevServer({
|
|
311
|
+
host = "localhost",
|
|
312
|
+
cwd = process.cwd(),
|
|
313
|
+
config,
|
|
314
|
+
overrides = {},
|
|
315
|
+
timeoutMs = 120000,
|
|
316
|
+
intervalMs = 500,
|
|
317
|
+
log = () => {},
|
|
318
|
+
probe = isServerAnswering,
|
|
319
|
+
start = startDevServerProcess,
|
|
320
|
+
isAlive = isProcessAlive
|
|
321
|
+
} = {}) {
|
|
322
|
+
const appConfig =
|
|
323
|
+
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - cwd is the app root; the file name is fixed.
|
|
324
|
+
config || JSON.parse(_fs.default.readFileSync(_path.default.join(cwd, "config", "config.json"), "utf8"));
|
|
325
|
+
const port = Number(appConfig.NODE_SERVER_PORT || appConfig.WEBVIEW_CONFIG?.port || DEFAULT_PORT);
|
|
326
|
+
const loopback = isLoopback(host);
|
|
327
|
+
const hostPart = String(host).includes(":") ? `[${host}]` : host;
|
|
328
|
+
const url = `http://${hostPart}:${port}`;
|
|
329
|
+
const answers = () => probe({
|
|
330
|
+
host: loopback ? "localhost" : host,
|
|
331
|
+
port
|
|
332
|
+
});
|
|
333
|
+
const hostKey = loopback ? "localhost" : String(host).toLowerCase();
|
|
334
|
+
if (await answers()) {
|
|
335
|
+
const ours = startedDevServer({
|
|
336
|
+
cwd,
|
|
337
|
+
port,
|
|
338
|
+
isAlive
|
|
339
|
+
});
|
|
340
|
+
if (ours && ours.host !== hostKey) {
|
|
341
|
+
throw (0, _utils.nativeError)("NATIVE-006", `the dev server on port ${port} was started for ${ours.host}, and this build needs ${url}`, {
|
|
342
|
+
hint: `Stop that dev server (kill ${ours.pid}) and run the build again, so Catalyst starts one for ${hostKey}.`
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
log(`Dev server already running at ${url}`, "info");
|
|
346
|
+
return {
|
|
347
|
+
started: false,
|
|
348
|
+
url,
|
|
349
|
+
port
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
if (!loopback && (await probe({
|
|
353
|
+
host: "localhost",
|
|
354
|
+
port
|
|
355
|
+
}))) {
|
|
356
|
+
throw (0, _utils.nativeError)("NATIVE-006", `a dev server is running on port ${port} but only for this machine, so ${url} does not answer`, {
|
|
357
|
+
hint: "Stop the running dev server (Ctrl+C in its terminal) and run the build again, so Catalyst starts one the phone can reach."
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
const startOverrides = {
|
|
361
|
+
...(loopback ? {} : {
|
|
362
|
+
NODE_SERVER_HOSTNAME: "0.0.0.0",
|
|
363
|
+
PUBLIC_STATIC_ASSET_URL: url
|
|
364
|
+
}),
|
|
365
|
+
// Vite 6 answers 403 to a Host header that is not localhost or an IP
|
|
366
|
+
// (server.allowedHosts), so a `<mac>.local` name has to be allowed.
|
|
367
|
+
...(loopback || _net.default.isIP(host) ? {} : {
|
|
368
|
+
CATALYST_DEV_ALLOWED_HOSTS: host
|
|
369
|
+
}),
|
|
370
|
+
...overrides
|
|
371
|
+
};
|
|
372
|
+
const {
|
|
373
|
+
child,
|
|
374
|
+
logFile
|
|
375
|
+
} = start({
|
|
376
|
+
cwd,
|
|
377
|
+
port,
|
|
378
|
+
overrides: startOverrides
|
|
379
|
+
});
|
|
380
|
+
log(`Starting the dev server at ${url} (output in ${logFile})`, "info");
|
|
381
|
+
if (Number.isInteger(child?.pid)) {
|
|
382
|
+
(0, _localState.writeLocalState)({
|
|
383
|
+
devServer: {
|
|
384
|
+
host: hostKey,
|
|
385
|
+
port,
|
|
386
|
+
pid: child.pid
|
|
93
387
|
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
388
|
+
}, {
|
|
389
|
+
cwd
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
let exited = null;
|
|
393
|
+
child?.on?.("exit", code => {
|
|
394
|
+
exited = code ?? "signal";
|
|
395
|
+
});
|
|
396
|
+
const deadline = Date.now() + timeoutMs;
|
|
397
|
+
while (Date.now() < deadline) {
|
|
398
|
+
if (await answers()) return {
|
|
399
|
+
started: true,
|
|
400
|
+
url,
|
|
401
|
+
port,
|
|
402
|
+
logFile
|
|
403
|
+
};
|
|
404
|
+
if (exited !== null) {
|
|
405
|
+
throw (0, _utils.nativeError)("NATIVE-006", `catalyst start exited (${exited}) before ${url} answered; output in ${logFile}`);
|
|
98
406
|
}
|
|
407
|
+
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
|
99
408
|
}
|
|
100
|
-
|
|
409
|
+
throw (0, _utils.nativeError)("NATIVE-006", `${url} did not answer within ${Math.round(timeoutMs / 1000)}s; output in ${logFile}`);
|
|
101
410
|
}
|
|
102
411
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// One-time migration: older setups wrote this machine's LAN address into
|
|
414
|
+
// config.json (every "localhost", and any http://<ip>), which makes Express
|
|
415
|
+
// bind only that address and breaks adb reverse. Put localhost back, but only
|
|
416
|
+
// for this machine's own addresses: a container IP, 0.0.0.0 or a loopback
|
|
417
|
+
// address is the app's own setting.
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
109
419
|
|
|
110
|
-
|
|
111
|
-
|
|
420
|
+
const MIGRATION_KEY = "lanAddresses";
|
|
421
|
+
const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
|
|
422
|
+
const isCI = env => {
|
|
423
|
+
const value = env.CI;
|
|
424
|
+
return value !== undefined && value !== "" && value !== "0" && String(value).toLowerCase() !== "false";
|
|
425
|
+
};
|
|
112
426
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
427
|
+
/**
|
|
428
|
+
* The changes the migration would make, without writing anything. Only a
|
|
429
|
+
* value equal to one of this machine's current IPv4 addresses, or to the
|
|
430
|
+
* private address in WEBVIEW_CONFIG.LOCAL_IP (what an earlier setup wrote
|
|
431
|
+
* there, even after DHCP moved the machine), or an http:// URL on one of
|
|
432
|
+
* those hosts, goes back to localhost. WEBVIEW_CONFIG.url is never touched,
|
|
433
|
+
* and nothing is when WEBVIEW_CONFIG.allowPrivateHost is true.
|
|
434
|
+
*
|
|
435
|
+
* @param {object} config - the parsed config.json (not modified)
|
|
436
|
+
* @param {object} [options]
|
|
437
|
+
* @param {Set<string>} [options.localHosts] - this machine's addresses; detected when omitted
|
|
438
|
+
* @returns {{ config: object, changes: { path: string, from: string, to: string }[] }}
|
|
439
|
+
*/
|
|
440
|
+
function planLanAddressMigration(config, {
|
|
441
|
+
localHosts = (0, _nativeConfig.localMachineHosts)()
|
|
442
|
+
} = {}) {
|
|
443
|
+
const changes = [];
|
|
444
|
+
const wv = config?.WEBVIEW_CONFIG;
|
|
445
|
+
// An app that opts into private hosts means its addresses.
|
|
446
|
+
if (wv?.allowPrivateHost === true) return {
|
|
447
|
+
config,
|
|
448
|
+
changes
|
|
449
|
+
};
|
|
450
|
+
// This machine's IPv4 addresses, without loopback and 0.0.0.0/8.
|
|
451
|
+
const saved = new Set([...localHosts].filter(host => IPV4.test(host) && !/^(?:127|0)\./.test(host)));
|
|
452
|
+
const localIp = typeof wv?.LOCAL_IP === "string" ? wv.LOCAL_IP.trim() : "";
|
|
453
|
+
if ((0, _nativeConfig.isPrivateIPv4)(localIp)) saved.add(localIp);
|
|
454
|
+
if (!saved.size) return {
|
|
455
|
+
config,
|
|
456
|
+
changes
|
|
457
|
+
};
|
|
458
|
+
const restore = value => {
|
|
459
|
+
if (saved.has(value.trim())) return "localhost";
|
|
460
|
+
return value.replace(/http:\/\/(\d{1,3}(?:\.\d{1,3}){3})(?=[:/?#]|$)/g, (match, ip) => saved.has(ip) ? "http://localhost" : match);
|
|
461
|
+
};
|
|
462
|
+
const walk = (value, at) => {
|
|
463
|
+
// WEBVIEW_CONFIG.url is new: no earlier setup ever wrote it.
|
|
464
|
+
if (at === "WEBVIEW_CONFIG.url") return value;
|
|
465
|
+
if (typeof value === "string") {
|
|
466
|
+
const next = restore(value);
|
|
467
|
+
if (next !== value) changes.push({
|
|
468
|
+
path: at,
|
|
469
|
+
from: value,
|
|
470
|
+
to: next
|
|
471
|
+
});
|
|
472
|
+
return next;
|
|
117
473
|
}
|
|
118
|
-
return
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
474
|
+
if (Array.isArray(value)) return value.map((item, index) => walk(item, `${at}[${index}]`));
|
|
475
|
+
if (value && typeof value === "object") {
|
|
476
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, walk(child, at ? `${at}.${key}` : key)]));
|
|
477
|
+
}
|
|
478
|
+
return value;
|
|
479
|
+
};
|
|
480
|
+
return {
|
|
481
|
+
config: walk(config, ""),
|
|
482
|
+
changes
|
|
483
|
+
};
|
|
123
484
|
}
|
|
124
485
|
|
|
125
486
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
487
|
+
* Run the LAN-address migration once per checkout (recorded in
|
|
488
|
+
* .catalyst/local.json). Rewrites config/config.json only when something
|
|
489
|
+
* changes, keeps its indentation, and logs each change. Never throws: a
|
|
490
|
+
* failure is logged as a warning and the build goes on. It never runs for a
|
|
491
|
+
* release (`--release` or the deprecated buildType "release") or in CI: a
|
|
492
|
+
* store build or a CI checkout must not rewrite the config it was given.
|
|
493
|
+
*
|
|
494
|
+
* @param {object} [options]
|
|
495
|
+
* @param {string} [options.cwd]
|
|
496
|
+
* @param {string} [options.configPath] - defaults to <cwd>/config/config.json
|
|
497
|
+
* @param {boolean} [options.force] - run even when already recorded
|
|
498
|
+
* @param {boolean} [options.release] - `--release`
|
|
499
|
+
* @param {"android"|"ios"} [options.platform] - whose buildType to check
|
|
500
|
+
* @param {Record<string, string | undefined>} [options.env]
|
|
501
|
+
* @param {Set<string>} [options.localHosts] - this machine's addresses; detected when omitted
|
|
502
|
+
* @param {(message: string, type?: string) => void} [options.log]
|
|
503
|
+
* @returns {{ ran: boolean, changes: { path: string, from: string, to: string }[] }}
|
|
135
504
|
*/
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (!running) {
|
|
150
|
-
log(`Starting server at ${serverURL}`);
|
|
151
|
-
startServerBackground();
|
|
152
|
-
// Give it a moment to start
|
|
153
|
-
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
154
|
-
} else {
|
|
155
|
-
log(`Server already running at ${serverURL}`);
|
|
156
|
-
}
|
|
157
|
-
return {
|
|
158
|
-
serverURL,
|
|
159
|
-
localIP,
|
|
160
|
-
port
|
|
505
|
+
function migrateLanAddresses({
|
|
506
|
+
cwd = process.cwd(),
|
|
507
|
+
configPath,
|
|
508
|
+
force = false,
|
|
509
|
+
release = false,
|
|
510
|
+
platform,
|
|
511
|
+
env = process.env,
|
|
512
|
+
localHosts,
|
|
513
|
+
log = () => {}
|
|
514
|
+
} = {}) {
|
|
515
|
+
const skipped = {
|
|
516
|
+
ran: false,
|
|
517
|
+
changes: []
|
|
161
518
|
};
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
519
|
+
if (release || isCI(env)) return skipped;
|
|
520
|
+
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - cwd is the app root; the file name is fixed.
|
|
521
|
+
const file = configPath || _path.default.join(cwd, "config", "config.json");
|
|
522
|
+
if (!force && (0, _localState.readLocalState)({
|
|
523
|
+
cwd
|
|
524
|
+
}).migrations?.[MIGRATION_KEY]) return skipped;
|
|
525
|
+
try {
|
|
526
|
+
const text = _fs.default.readFileSync(file, "utf8");
|
|
527
|
+
const parsed = JSON.parse(text);
|
|
528
|
+
const buildType = platform ? parsed?.WEBVIEW_CONFIG?.[platform]?.buildType : undefined;
|
|
529
|
+
if (String(buildType || "").toLowerCase() === "release") return skipped;
|
|
530
|
+
const {
|
|
531
|
+
config,
|
|
532
|
+
changes
|
|
533
|
+
} = planLanAddressMigration(parsed, localHosts ? {
|
|
534
|
+
localHosts
|
|
535
|
+
} : undefined);
|
|
536
|
+
if (changes.length) {
|
|
537
|
+
const indent = /^[ \t]+(?=")/m.exec(text)?.[0] || " ";
|
|
538
|
+
_fs.default.writeFileSync(file, JSON.stringify(config, null, indent) + (text.endsWith("\n") ? "\n" : ""));
|
|
539
|
+
log(`Put localhost back in config/config.json where an earlier setup saved this machine's LAN address:`, "info");
|
|
540
|
+
for (const change of changes) log(` ${change.path}: ${change.from} -> ${change.to}`, "info");
|
|
541
|
+
}
|
|
542
|
+
const state = (0, _localState.readLocalState)({
|
|
543
|
+
cwd
|
|
544
|
+
});
|
|
545
|
+
(0, _localState.writeLocalState)({
|
|
546
|
+
migrations: {
|
|
547
|
+
...(state.migrations || {}),
|
|
548
|
+
[MIGRATION_KEY]: new Date().toISOString()
|
|
549
|
+
}
|
|
550
|
+
}, {
|
|
551
|
+
cwd
|
|
552
|
+
});
|
|
553
|
+
return {
|
|
554
|
+
ran: true,
|
|
555
|
+
changes
|
|
556
|
+
};
|
|
557
|
+
} catch (error) {
|
|
558
|
+
log(`Could not check config/config.json for saved LAN addresses: ${error.message}`, "warning");
|
|
559
|
+
return skipped;
|
|
560
|
+
}
|
|
561
|
+
}
|