@flun/node-mobile-app 4.0.7 → 4.0.9

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/src/patcher.js CHANGED
@@ -1,394 +1,394 @@
1
- import path from 'path';
2
- import fs from 'fs';
3
- import { writeIfChanged } from './utils.js';
4
- import chalk from 'chalk';
5
-
6
- // 把字符串中所有非 ASCII 字符转成 \uXXXX(Java Properties 规范)
7
- // gradle.properties 按 ISO-8859-1 读取,无法改为 UTF-8
8
- const escapeProps = s =>
9
- String(s).replace(/[^\x00-\x7F]/g, ch => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0')),
10
- /**
11
- * 解析最终 appName:
12
- * 1. config.appName
13
- * 2. 用户项目 package.json 的 name
14
- * 3. 'MyApp'
15
- */
16
- resolveAppName = (userProjectDir, config) => {
17
- if (config.appName) return String(config.appName);
18
- const pkgPath = path.join(userProjectDir, 'package.json');
19
- if (fs.existsSync(pkgPath)) {
20
- try {
21
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
22
- if (pkg.name) return String(pkg.name);
23
- } catch { }
24
- }
25
- return 'MyApp';
26
- },
27
- /** 通用字符串转义基础:\\, ", \n, \r, \t */
28
- escapeStringBase = s => String(s)
29
- .replace(/\\/g, '\\\\')
30
- .replace(/"/g, '\\"')
31
- .replace(/\n/g, '\\n')
32
- .replace(/\r/g, '\\r')
33
- .replace(/\t/g, '\\t'),
34
- /** Kotlin 字符串字面量转义:基础 + 转义 $ */
35
- escapeKotlinString = s => escapeStringBase(s).replace(/\$/g, '\\$'),
36
- /** Swift 字符串字面量转义:基础 + 转义 \( */
37
- escapeSwiftString = s => escapeStringBase(s).replace(/\(/g, '\\('),
38
- escapeXml = s => {
39
- return String(s)
40
- .replace(/&/g, '&')
41
- .replace(/</g, '&lt;')
42
- .replace(/>/g, '&gt;')
43
- .replace(/"/g, '&quot;')
44
- .replace(/'/g, '&apos;');
45
- },
46
- /**
47
- * 把 mobileAppConfig.js 里的应用信息写入 buildDir 的 RN 工程
48
- * - android/app/build.gradle: namespace / applicationId / versionCode / versionName
49
- * - app.json: name / displayName
50
- * - res/values/strings.xml: app_name
51
- * - MainActivity.kt / MainApplication.kt: package 声明
52
- */
53
- patchAppConfig = (buildDir, userProjectDir, config) => {
54
- const appName = resolveAppName(userProjectDir, config), appId = config.appId,
55
- versionCode = config.versionCode, versionName = config.versionName,
56
- gradleFile = path.join(buildDir, 'android', 'app', 'build.gradle'), appJsonFile = path.join(buildDir, 'app.json'),
57
- stringsFile = path.join(buildDir, 'android', 'app', 'src', 'main', 'res', 'values', 'strings.xml'),
58
- ktFiles = [
59
- path.join(buildDir, 'android', 'app', 'src', 'main', 'java', 'com', 'myapp', 'MainActivity.kt'),
60
- path.join(buildDir, 'android', 'app', 'src', 'main', 'java', 'com', 'myapp', 'MainApplication.kt')
61
- ];
62
- // 1. build.gradle
63
- if (fs.existsSync(gradleFile)) {
64
- let c = fs.readFileSync(gradleFile, 'utf-8');
65
- c = c.replace(/namespace\s+"[^"]+"/, 'namespace "' + appId + '"');
66
- c = c.replace(/applicationId\s+"[^"]+"/, 'applicationId "' + appId + '"');
67
- c = c.replace(/versionCode\s+\d+/, 'versionCode ' + versionCode);
68
- c = c.replace(/versionName\s+"[^"]+"/, 'versionName "' + versionName + '"');
69
- writeIfChanged(gradleFile, c);
70
- }
71
- // 2. app.json
72
- if (fs.existsSync(appJsonFile)) {
73
- const obj = { name: appName, displayName: appName };
74
- writeIfChanged(appJsonFile, JSON.stringify(obj, null, 2) + '\n');
75
- }
76
- // 3. strings.xml
77
- if (fs.existsSync(stringsFile)) {
78
- let c = fs.readFileSync(stringsFile, 'utf-8');
79
- c = c.replace(/(<string name="app_name">)[^<]*(<\/string>)/, '$1' + escapeXml(appName) + '$2');
80
- writeIfChanged(stringsFile, c);
81
- }
82
- // 4. MainActivity.kt / MainApplication.kt 的 package 声明
83
- for (const f of ktFiles) {
84
- if (!fs.existsSync(f)) continue;
85
- let c = fs.readFileSync(f, 'utf-8');
86
- c = c.replace(/^package\s+[\w.]+/m, 'package ' + appId);
87
- // MainActivity.kt: getMainComponentName 要和 app.json.name 一致
88
- c = c.replace(
89
- /(override\s+fun\s+getMainComponentName\(\)\s*:\s*String\s*=\s*)"[^"]*"/, `$1"${escapeKotlinString(appName)}"`
90
- );
91
- writeIfChanged(f, c);
92
- }
93
-
94
- console.log(chalk.green(` ✓ 应用信息:${appName} (${appId}) v${versionName}(${versionCode})`));
95
- },
96
- /**
97
- * 把 mobileAppConfig.js 里的 Android 编译配置写入 buildDir:
98
- * - android/build.gradle 的 ext 块:minSdkVersion / targetSdkVersion / compileSdkVersion / buildToolsVersion
99
- * - android/app/build.gradle 的 manifestPlaceholders:usesCleartextTraffic
100
- * - AndroidManifest.xml:permissions(去重,始终含 INTERNET)
101
- */
102
- patchAndroidConfig = (buildDir, config) => {
103
- const a = config.android || {}, rootGradle = path.join(buildDir, 'android', 'build.gradle'),
104
- manifestFile = path.join(buildDir, 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
105
- // 1. 根 build.gradle 的 ext 块
106
- if (fs.existsSync(rootGradle)) {
107
- let c = fs.readFileSync(rootGradle, 'utf-8');
108
- if (Number.isInteger(a.minSdkVersion))
109
- c = c.replace(/minSdkVersion\s*=\s*\d+/, 'minSdkVersion = ' + a.minSdkVersion);
110
- if (Number.isInteger(a.targetSdkVersion))
111
- c = c.replace(/targetSdkVersion\s*=\s*\d+/, 'targetSdkVersion = ' + a.targetSdkVersion);
112
- if (Number.isInteger(a.compileSdkVersion))
113
- c = c.replace(/compileSdkVersion\s*=\s*\d+/, 'compileSdkVersion = ' + a.compileSdkVersion);
114
- if (a.buildToolsVersion)
115
- c = c.replace(/buildToolsVersion\s*=\s*"[^"]+"/, 'buildToolsVersion = "' + a.buildToolsVersion + '"');
116
- writeIfChanged(rootGradle, c);
117
- }
118
- // 2. AndroidManifest.xml: 直接写死 usesCleartextTraffic + tools:replace
119
- // 不用占位符,因为 AGP release 构建在 targetSdk>=28 时会强制 false
120
- if (fs.existsSync(manifestFile)) {
121
- let c = fs.readFileSync(manifestFile, 'utf-8');
122
- const ct = a.usesCleartextTraffic !== false ? 'true' : 'false';
123
-
124
- // manifest 根标签加 xmlns:tools
125
- if (!c.includes('xmlns:tools=')) {
126
- c = c.replace(
127
- /<manifest\s+xmlns:android="[^"]*"/,
128
- '<manifest xmlns:android="http://schemas.android.com/apk/res/android" ' +
129
- 'xmlns:tools="http://schemas.android.com/tools"'
130
- );
131
- }
132
- // 替换占位符值为实际值
133
- c = c.replace(/android:usesCleartextTraffic="\$\{usesCleartextTraffic\}"/, `android:usesCleartextTraffic="${ct}"`);
134
- // application 标签加 tools:replace
135
- if (!/tools:replace="[^"]*android:usesCleartextTraffic/.test(c))
136
- c = c.replace(/(<application)/, '$1\n tools:replace="android:usesCleartextTraffic"');
137
-
138
- writeIfChanged(manifestFile, c);
139
- }
140
- // 3. AndroidManifest.xml 权限
141
- if (fs.existsSync(manifestFile)) {
142
- let c = fs.readFileSync(manifestFile, 'utf-8');
143
- const perms = new Set(['android.permission.INTERNET']);
144
- for (const p of a.permissions || []) perms.add(p);
145
-
146
- // 删掉现有的所有 <uses-permission ... /> 行
147
- c = c.replace(/^\s*<uses-permission[^>]*\/>\s*\r?\n/gm, '');
148
- // 在 <application 前插入
149
- const block = Array.from(perms).map((p) => ` <uses-permission android:name="${p}" />`).join('\n');
150
- c = c.replace(/(\r?\n)(\s*<application)/, `\n${block}\n\n$2`);
151
- writeIfChanged(manifestFile, c);
152
- }
153
-
154
- console.log(chalk.green(` ✓ Android 配置:SDK ${a.minSdkVersion}/${a.targetSdkVersion}/${a.compileSdkVersion},
155
- 权限 ${new Set(['android.permission.INTERNET', ...(a.permissions || [])]).size} 项`));
156
- },
157
- /**
158
- * 处理 Release 签名:
159
- * - config.android.signing 有值 → 写 keystore 路径到 gradle.properties,
160
- * 加 signingConfigs.release 块,release buildType 用它
161
- * - 没值 → 不动(release 沿用 debug,可装但不可发布)
162
- *
163
- * keystore 路径以用户项目根为基准;
164
- */
165
- patchSigning = (buildDir, userProjectDir, config) => {
166
- const signing = config.android && config.android.signing;
167
- if (!signing || !signing.keystore) return; // 未配置签名 → 保持默认
168
-
169
- // 启用签名后 4 项均为必填,缺项时提前报错,避免 Gradle 报"password incorrect"误导排查
170
- const _missing = ['storePassword', 'keyAlias', 'keyPassword'].filter((k) => !signing[k]);
171
- if (_missing.length > 0) {
172
- throw new Error(
173
- 'android.signing.keystore 已配置,但以下字段为空:' + _missing.join(', ') + '\n' +
174
- ' 启用签名后,keystore / storePassword / keyAlias / keyPassword 四项均为必填;\n' +
175
- ' 如仅需本地测试(不签名上架),可注释掉整个 signing 块--模板默认用 debug keystore 签名;'
176
- );
177
- }
178
-
179
- const keystoreAbs = path.resolve(userProjectDir, signing.keystore);
180
- if (!fs.existsSync(keystoreAbs))
181
- return console.warn(chalk.yellow(' ⚠️ 签名 keystore 不存在,跳过签名配置: ' + keystoreAbs));
182
-
183
- // 1. 写 gradle.properties(追加或覆盖 MYAPP_RELEASE_*)
184
- const propsFile = path.join(buildDir, 'android', 'gradle.properties');
185
- if (fs.existsSync(propsFile)) {
186
- let c = fs.readFileSync(propsFile, 'utf-8');
187
- // 删掉旧的 4 行
188
- c = c.replace(/^\s*MYAPP_RELEASE_STORE_FILE=.*\r?\n/gm, '');
189
- c = c.replace(/^\s*MYAPP_RELEASE_STORE_PASSWORD=.*\r?\n/gm, '');
190
- c = c.replace(/^\s*MYAPP_RELEASE_KEY_ALIAS=.*\r?\n/gm, '');
191
- c = c.replace(/^\s*MYAPP_RELEASE_KEY_PASSWORD=.*\r?\n/gm, '');
192
- // 追加新的
193
- // gradle.properties 按 ISO-8859-1 读取,非 ASCII 字符必须转 \uXXXX 转义,
194
- // 否则中文路径(如"桌面")会被读成乱码,导致 keystore 找不到;
195
- const esc = escapeProps(keystoreAbs.replace(/\\/g, '/'));
196
- c += `\nMYAPP_RELEASE_STORE_FILE=${esc}\n`;
197
- c += `MYAPP_RELEASE_STORE_PASSWORD=${escapeProps(signing.storePassword || '')}\n`;
198
- c += `MYAPP_RELEASE_KEY_ALIAS=${escapeProps(signing.keyAlias || '')}\n`;
199
- c += `MYAPP_RELEASE_KEY_PASSWORD=${escapeProps(signing.keyPassword || '')}\n`;
200
- writeIfChanged(propsFile, c);
201
- }
202
-
203
- // 2. app/build.gradle: 加 signingConfigs.release,并让 buildTypes.release 用它
204
- const gradleFile = path.join(buildDir, 'android', 'app', 'build.gradle');
205
- if (fs.existsSync(gradleFile)) {
206
- let c = fs.readFileSync(gradleFile, 'utf-8');
207
-
208
- // 加 release 签名块(先删旧的,避免重复)
209
- c = c.replace(/\s*release\s*\{\s*storeFile[\s\S]*?\}\r?\n(\s*\}\s*\r?\n)/, '$1');
210
- // 在 signingConfigs { 之后插入
211
- c = c.replace(/(signingConfigs\s*\{)/,
212
- `$1
213
- release {
214
- storeFile file(MYAPP_RELEASE_STORE_FILE)
215
- storePassword MYAPP_RELEASE_STORE_PASSWORD
216
- keyAlias MYAPP_RELEASE_KEY_ALIAS
217
- keyPassword MYAPP_RELEASE_KEY_PASSWORD
218
- }`
219
- );
220
-
221
- // 让 release buildType 用 signingConfigs.release
222
- // 只替换 release { 之后的第一个 signingConfig signingConfigs.debug
223
- c = c.replace(/(release\s*\{\s*\n\s*signingConfig\s+)signingConfigs\.debug/, '$1signingConfigs.release');
224
- writeIfChanged(gradleFile, c);
225
- }
226
-
227
- console.log(chalk.green(' ✓ Release 签名已配置: ' + path.basename(keystoreAbs)));
228
- },
229
- /**
230
- * 应用图标:把用户提供的 PNG 缩放到 5 个 Android 密度,覆盖各 mipmap 目录下的 ic_launcher 与 ic_launcher_round
231
- * 未配置或文件不存在时保持默认
232
- */
233
- patchIcon = async (buildDir, userProjectDir, config) => {
234
- const iconPath = config.android && config.android.icon;
235
- if (!iconPath) return;
236
-
237
- const iconAbs = path.resolve(userProjectDir, iconPath);
238
- if (!fs.existsSync(iconAbs)) return console.warn(chalk.yellow(' ⚠️ 图标文件不存在,保持默认: ' + iconAbs));
239
-
240
- const { Jimp } = await import('jimp'),
241
- densities = [
242
- { dir: 'mipmap-mdpi', size: 48 },
243
- { dir: 'mipmap-hdpi', size: 72 },
244
- { dir: 'mipmap-xhdpi', size: 96 },
245
- { dir: 'mipmap-xxhdpi', size: 144 },
246
- { dir: 'mipmap-xxxhdpi', size: 192 }
247
- ], resDir = path.join(buildDir, 'android', 'app', 'src', 'main', 'res'), source = await Jimp.read(iconAbs);
248
-
249
- for (const { dir, size } of densities) {
250
- const targetDir = path.join(resDir, dir);
251
- if (!fs.existsSync(targetDir)) continue;
252
-
253
- const img = source.clone();
254
- img.resize({ w: size, h: size });
255
- const buf = await img.getBuffer('image/png');
256
-
257
- fs.writeFileSync(path.join(targetDir, 'ic_launcher.png'), buf);
258
- fs.writeFileSync(path.join(targetDir, 'ic_launcher_round.png'), buf);
259
- }
260
-
261
- console.log(chalk.green(` ✓ 图标已应用: ${path.basename(iconAbs)}`));
262
- },
263
- /**
264
- * 写入 iOS 工程配置:
265
- * - Info.plist: CFBundleDisplayName
266
- * - project.pbxproj: PRODUCT_BUNDLE_IDENTIFIER /
267
- * MARKETING_VERSION / CURRENT_PROJECT_VERSION / IPHONEOS_DEPLOYMENT_TARGET
268
- * 只在 buildDir 里存在 ios/ 时执行(其余平台跳过);
269
- */
270
- patchIosConfig = (buildDir, userProjectDir, config) => {
271
- const iosDir = path.join(buildDir, 'ios');
272
- if (!fs.existsSync(iosDir)) return;
273
-
274
- const appName = resolveAppName(userProjectDir, config), appId = config.appId, versionName = config.versionName,
275
- versionCode = config.versionCode, deploymentTarget = (config.ios?.deploymentTarget) || '15.1',
276
- plist = path.join(iosDir, 'MyApp', 'Info.plist'), pbx = path.join(iosDir, 'MyApp.xcodeproj', 'project.pbxproj'),
277
- appDelegate = path.join(iosDir, 'MyApp', 'AppDelegate.swift');
278
- // 1. Info.plist:CFBundleDisplayName + ATS (对应 Android 的 usesCleartextTraffic)
279
- if (fs.existsSync(plist)) {
280
- let c = fs.readFileSync(plist, 'utf-8');
281
-
282
- // CFBundleDisplayName
283
- c = c.replace(/(<key>CFBundleDisplayName<\/key>\s*<string>)[^<]*(<\/string>)/, '$1' + escapeXml(appName) + '$2');
284
- // ATS: usesCleartextTraffic=true → 放行本地 HTTP; false → 全部禁用
285
- const allowCleartext = config.android.usesCleartextTraffic !== false, tab = '\t', atsBlock = allowCleartext
286
- ? [
287
- '<key>NSAppTransportSecurity</key>',
288
- tab + '<dict>',
289
- tab + tab + '<key>NSAllowsArbitraryLoads</key>',
290
- tab + tab + '<false/>',
291
- tab + tab + '<key>NSAllowsLocalNetworking</key>',
292
- tab + tab + '<true/>',
293
- tab + tab + '<key>NSExceptionDomains</key>',
294
- tab + tab + '<dict>',
295
- tab + tab + tab + '<key>localhost</key>',
296
- tab + tab + tab + '<dict>',
297
- tab + tab + tab + tab + '<key>NSExceptionAllowsInsecureHTTPLoads</key>',
298
- tab + tab + tab + tab + '<true/>',
299
- tab + tab + tab + tab + '<key>NSIncludesSubdomains</key>',
300
- tab + tab + tab + tab + '<true/>',
301
- tab + tab + tab + '</dict>',
302
- tab + tab + tab + '<key>127.0.0.1</key>',
303
- tab + tab + tab + '<dict>',
304
- tab + tab + tab + tab + '<key>NSExceptionAllowsInsecureHTTPLoads</key>',
305
- tab + tab + tab + tab + '<true/>',
306
- tab + tab + tab + '</dict>',
307
- tab + tab + '</dict>',
308
- tab + '</dict>',
309
- ].join('\n')
310
- : [
311
- '<key>NSAppTransportSecurity</key>',
312
- tab + '<dict>',
313
- tab + tab + '<key>NSAllowsArbitraryLoads</key>',
314
- tab + tab + '<false/>',
315
- tab + tab + '<key>NSAllowsLocalNetworking</key>',
316
- tab + tab + '<false/>',
317
- tab + '</dict>',
318
- ].join('\n');
319
-
320
- c = c.replace(
321
- /<key>NSAppTransportSecurity<\/key>[\s\S]*?(?=<key>NSLocationWhenInUseUsageDescription<\/key>)/, atsBlock + '\n\t'
322
- );
323
- writeIfChanged(plist, c);
324
- }
325
-
326
- // 2. project.pbxproj
327
- if (fs.existsSync(pbx)) {
328
- let c = fs.readFileSync(pbx, 'utf-8');
329
- c = c.replace(/PRODUCT_BUNDLE_IDENTIFIER = "[^"]+";/g, `PRODUCT_BUNDLE_IDENTIFIER = "${appId}";`);
330
- c = c.replace(/MARKETING_VERSION = [^;]+;/g, `MARKETING_VERSION = ${versionName};`);
331
- c = c.replace(/CURRENT_PROJECT_VERSION = [^;]+;/g, `CURRENT_PROJECT_VERSION = ${versionCode};`);
332
- c = c.replace(/IPHONEOS_DEPLOYMENT_TARGET = [^;]+;/g, `IPHONEOS_DEPLOYMENT_TARGET = ${deploymentTarget};`);
333
- writeIfChanged(pbx, c);
334
- }
335
-
336
- // 3. AppDelegate.swift: withModuleName 要和 app.json.name 一致
337
- if (fs.existsSync(appDelegate)) {
338
- let c = fs.readFileSync(appDelegate, 'utf-8');
339
- c = c.replace(/(withModuleName:\s*)"[^"]*"/, `$1"${escapeSwiftString(appName)}"`);
340
- writeIfChanged(appDelegate, c);
341
- }
342
-
343
- console.log(chalk.green(` ✓ iOS 配置:${appName} (${appId}) v${versionName}(${versionCode})` +
344
- `, target ${deploymentTarget}`));
345
- },
346
- /**
347
- * iOS 应用图标:把用户提供的 PNG 缩放到 9 个尺寸,写入 AppIcon.appiconset
348
- * 同时改写 Contents.json 加 filename 字段
349
- * 未配置或文件不存在时保持默认(无图标,Xcode 会用系统占位)
350
- */
351
- patchIosIcon = async (buildDir, userProjectDir, config) => {
352
- const iconPath = config.ios && config.ios.icon;
353
- if (!iconPath) return;
354
-
355
- const iconAbs = path.resolve(userProjectDir, iconPath);
356
- if (!fs.existsSync(iconAbs)) return console.warn(chalk.yellow(' ⚠️ iOS 图标文件不存在,保持默认: ' + iconAbs));
357
-
358
- const iconDir = path.join(buildDir, 'ios', 'MyApp', 'Images.xcassets', 'AppIcon.appiconset');
359
- if (!fs.existsSync(iconDir)) return;
360
-
361
- const { Jimp } = await import('jimp'), source = await Jimp.read(iconAbs),
362
- // Apple 规范:每项 (idiom, size, scale) 对应的像素尺寸与文件名
363
- variants = [
364
- { idiom: 'iphone', size: '20x20', scale: '2x', px: 40, name: 'Icon-App-20x20@2x.png' },
365
- { idiom: 'iphone', size: '20x20', scale: '3x', px: 60, name: 'Icon-App-20x20@3x.png' },
366
- { idiom: 'iphone', size: '29x29', scale: '2x', px: 58, name: 'Icon-App-29x29@2x.png' },
367
- { idiom: 'iphone', size: '29x29', scale: '3x', px: 87, name: 'Icon-App-29x29@3x.png' },
368
- { idiom: 'iphone', size: '40x40', scale: '2x', px: 80, name: 'Icon-App-40x40@2x.png' },
369
- { idiom: 'iphone', size: '40x40', scale: '3x', px: 120, name: 'Icon-App-40x40@3x.png' },
370
- { idiom: 'iphone', size: '60x60', scale: '2x', px: 120, name: 'Icon-App-60x60@2x.png' },
371
- { idiom: 'iphone', size: '60x60', scale: '3x', px: 180, name: 'Icon-App-60x60@3x.png' },
372
- { idiom: 'ios-marketing', size: '1024x1024', scale: '1x', px: 1024, name: 'Icon-App-1024x1024@1x.png' },
373
- ], contentsFile = path.join(iconDir, 'Contents.json'),
374
- contents = {
375
- images: variants.map(v => ({ idiom: v.idiom, scale: v.scale, size: v.size, filename: v.name, })),
376
- info: { author: 'xcode', version: 1 }
377
- };
378
-
379
- for (const v of variants) {
380
- const img = source.clone();
381
- img.resize({ w: v.px, h: v.px });
382
- const buf = await img.getBuffer('image/png');
383
- fs.writeFileSync(path.join(iconDir, v.name), buf);
384
- }
385
-
386
- // 改写 Contents.json,给每项加 filename
387
- writeIfChanged(contentsFile, JSON.stringify(contents, null, 2) + '\n');
388
- console.log(chalk.green(` ✓ iOS 图标已应用: ${path.basename(iconAbs)}`));
389
- }
390
-
391
- export {
392
- resolveAppName, escapeKotlinString, escapeSwiftString, patchAppConfig, patchAndroidConfig, patchSigning,
393
- patchIcon, patchIosConfig, patchIosIcon
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+ import { writeIfChanged } from './utils.js';
4
+ import chalk from 'chalk';
5
+
6
+ // 把字符串中所有非 ASCII 字符转成 \uXXXX(Java Properties 规范)
7
+ // gradle.properties 按 ISO-8859-1 读取,无法改为 UTF-8
8
+ const escapeProps = s =>
9
+ String(s).replace(/[^\x00-\x7F]/g, ch => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0')),
10
+ /**
11
+ * 解析最终 appName:
12
+ * 1. config.appName
13
+ * 2. 用户项目 package.json 的 name
14
+ * 3. 'MyApp'
15
+ */
16
+ resolveAppName = (userProjectDir, config) => {
17
+ if (config.appName) return String(config.appName);
18
+ const pkgPath = path.join(userProjectDir, 'package.json');
19
+ if (fs.existsSync(pkgPath)) {
20
+ try {
21
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
22
+ if (pkg.name) return String(pkg.name);
23
+ } catch { }
24
+ }
25
+ return 'MyApp';
26
+ },
27
+ /** 通用字符串转义基础:\\, ", \n, \r, \t */
28
+ escapeStringBase = s => String(s)
29
+ .replace(/\\/g, '\\\\')
30
+ .replace(/"/g, '\\"')
31
+ .replace(/\n/g, '\\n')
32
+ .replace(/\r/g, '\\r')
33
+ .replace(/\t/g, '\\t'),
34
+ /** Kotlin 字符串字面量转义:基础 + 转义 $ */
35
+ escapeKotlinString = s => escapeStringBase(s).replace(/\$/g, '\\$'),
36
+ /** Swift 字符串字面量转义:基础 + 转义 \( */
37
+ escapeSwiftString = s => escapeStringBase(s).replace(/\(/g, '\\('),
38
+ escapeXml = s => {
39
+ return String(s)
40
+ .replace(/&/g, '&amp;')
41
+ .replace(/</g, '&lt;')
42
+ .replace(/>/g, '&gt;')
43
+ .replace(/"/g, '&quot;')
44
+ .replace(/'/g, '&apos;');
45
+ },
46
+ /**
47
+ * 把 mobileAppConfig.js 里的应用信息写入 buildDir 的 RN 工程
48
+ * - android/app/build.gradle: namespace / applicationId / versionCode / versionName
49
+ * - app.json: name / displayName
50
+ * - res/values/strings.xml: app_name
51
+ * - MainActivity.kt / MainApplication.kt: package 声明
52
+ */
53
+ patchAppConfig = (buildDir, userProjectDir, config) => {
54
+ const appName = resolveAppName(userProjectDir, config), appId = config.appId,
55
+ versionCode = config.versionCode, versionName = config.versionName,
56
+ gradleFile = path.join(buildDir, 'android', 'app', 'build.gradle'), appJsonFile = path.join(buildDir, 'app.json'),
57
+ stringsFile = path.join(buildDir, 'android', 'app', 'src', 'main', 'res', 'values', 'strings.xml'),
58
+ ktFiles = [
59
+ path.join(buildDir, 'android', 'app', 'src', 'main', 'java', 'com', 'myapp', 'MainActivity.kt'),
60
+ path.join(buildDir, 'android', 'app', 'src', 'main', 'java', 'com', 'myapp', 'MainApplication.kt')
61
+ ];
62
+ // 1. build.gradle
63
+ if (fs.existsSync(gradleFile)) {
64
+ let c = fs.readFileSync(gradleFile, 'utf-8');
65
+ c = c.replace(/namespace\s+"[^"]+"/, 'namespace "' + appId + '"');
66
+ c = c.replace(/applicationId\s+"[^"]+"/, 'applicationId "' + appId + '"');
67
+ c = c.replace(/versionCode\s+\d+/, 'versionCode ' + versionCode);
68
+ c = c.replace(/versionName\s+"[^"]+"/, 'versionName "' + versionName + '"');
69
+ writeIfChanged(gradleFile, c);
70
+ }
71
+ // 2. app.json
72
+ if (fs.existsSync(appJsonFile)) {
73
+ const obj = { name: appName, displayName: appName };
74
+ writeIfChanged(appJsonFile, JSON.stringify(obj, null, 2) + '\n');
75
+ }
76
+ // 3. strings.xml
77
+ if (fs.existsSync(stringsFile)) {
78
+ let c = fs.readFileSync(stringsFile, 'utf-8');
79
+ c = c.replace(/(<string name="app_name">)[^<]*(<\/string>)/, '$1' + escapeXml(appName) + '$2');
80
+ writeIfChanged(stringsFile, c);
81
+ }
82
+ // 4. MainActivity.kt / MainApplication.kt 的 package 声明
83
+ for (const f of ktFiles) {
84
+ if (!fs.existsSync(f)) continue;
85
+ let c = fs.readFileSync(f, 'utf-8');
86
+ c = c.replace(/^package\s+[\w.]+/m, 'package ' + appId);
87
+ // MainActivity.kt: getMainComponentName 要和 app.json.name 一致
88
+ c = c.replace(
89
+ /(override\s+fun\s+getMainComponentName\(\)\s*:\s*String\s*=\s*)"[^"]*"/, `$1"${escapeKotlinString(appName)}"`
90
+ );
91
+ writeIfChanged(f, c);
92
+ }
93
+
94
+ console.log(chalk.green(` ✓ 应用信息:${appName} (${appId}) v${versionName}(${versionCode})`));
95
+ },
96
+ /**
97
+ * 把 mobileAppConfig.js 里的 Android 编译配置写入 buildDir:
98
+ * - android/build.gradle 的 ext 块:minSdkVersion / targetSdkVersion / compileSdkVersion / buildToolsVersion
99
+ * - android/app/build.gradle 的 manifestPlaceholders:usesCleartextTraffic
100
+ * - AndroidManifest.xml:permissions(去重,始终含 INTERNET)
101
+ */
102
+ patchAndroidConfig = (buildDir, config) => {
103
+ const a = config.android || {}, rootGradle = path.join(buildDir, 'android', 'build.gradle'),
104
+ manifestFile = path.join(buildDir, 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
105
+ // 1. 根 build.gradle 的 ext 块
106
+ if (fs.existsSync(rootGradle)) {
107
+ let c = fs.readFileSync(rootGradle, 'utf-8');
108
+ if (Number.isInteger(a.minSdkVersion))
109
+ c = c.replace(/minSdkVersion\s*=\s*\d+/, 'minSdkVersion = ' + a.minSdkVersion);
110
+ if (Number.isInteger(a.targetSdkVersion))
111
+ c = c.replace(/targetSdkVersion\s*=\s*\d+/, 'targetSdkVersion = ' + a.targetSdkVersion);
112
+ if (Number.isInteger(a.compileSdkVersion))
113
+ c = c.replace(/compileSdkVersion\s*=\s*\d+/, 'compileSdkVersion = ' + a.compileSdkVersion);
114
+ if (a.buildToolsVersion)
115
+ c = c.replace(/buildToolsVersion\s*=\s*"[^"]+"/, 'buildToolsVersion = "' + a.buildToolsVersion + '"');
116
+ writeIfChanged(rootGradle, c);
117
+ }
118
+ // 2. AndroidManifest.xml: 直接写死 usesCleartextTraffic + tools:replace
119
+ // 不用占位符,因为 AGP release 构建在 targetSdk>=28 时会强制 false
120
+ if (fs.existsSync(manifestFile)) {
121
+ let c = fs.readFileSync(manifestFile, 'utf-8');
122
+ const ct = a.usesCleartextTraffic !== false ? 'true' : 'false';
123
+
124
+ // manifest 根标签加 xmlns:tools
125
+ if (!c.includes('xmlns:tools=')) {
126
+ c = c.replace(
127
+ /<manifest\s+xmlns:android="[^"]*"/,
128
+ '<manifest xmlns:android="http://schemas.android.com/apk/res/android" ' +
129
+ 'xmlns:tools="http://schemas.android.com/tools"'
130
+ );
131
+ }
132
+ // 替换占位符值为实际值
133
+ c = c.replace(/android:usesCleartextTraffic="\$\{usesCleartextTraffic\}"/, `android:usesCleartextTraffic="${ct}"`);
134
+ // application 标签加 tools:replace
135
+ if (!/tools:replace="[^"]*android:usesCleartextTraffic/.test(c))
136
+ c = c.replace(/(<application)/, '$1\n tools:replace="android:usesCleartextTraffic"');
137
+
138
+ writeIfChanged(manifestFile, c);
139
+ }
140
+ // 3. AndroidManifest.xml 权限
141
+ if (fs.existsSync(manifestFile)) {
142
+ let c = fs.readFileSync(manifestFile, 'utf-8');
143
+ const perms = new Set(['android.permission.INTERNET']);
144
+ for (const p of a.permissions || []) perms.add(p);
145
+
146
+ // 删掉现有的所有 <uses-permission ... /> 行
147
+ c = c.replace(/^\s*<uses-permission[^>]*\/>\s*\r?\n/gm, '');
148
+ // 在 <application 前插入
149
+ const block = Array.from(perms).map((p) => ` <uses-permission android:name="${p}" />`).join('\n');
150
+ c = c.replace(/(\r?\n)(\s*<application)/, `\n${block}\n\n$2`);
151
+ writeIfChanged(manifestFile, c);
152
+ }
153
+
154
+ console.log(chalk.green(` ✓ Android 配置:SDK ${a.minSdkVersion}/${a.targetSdkVersion}/${a.compileSdkVersion},
155
+ 权限 ${new Set(['android.permission.INTERNET', ...(a.permissions || [])]).size} 项`));
156
+ },
157
+ /**
158
+ * 处理 Release 签名:
159
+ * - config.android.signing 有值 → 写 keystore 路径到 gradle.properties,
160
+ * 加 signingConfigs.release 块,release buildType 用它
161
+ * - 没值 → 不动(release 沿用 debug,可装但不可发布)
162
+ *
163
+ * keystore 路径以用户项目根为基准;
164
+ */
165
+ patchSigning = (buildDir, userProjectDir, config) => {
166
+ const signing = config.android && config.android.signing;
167
+ if (!signing || !signing.keystore) return; // 未配置签名 → 保持默认
168
+
169
+ // 启用签名后 4 项均为必填,缺项时提前报错,避免 Gradle 报"password incorrect"误导排查
170
+ const _missing = ['storePassword', 'keyAlias', 'keyPassword'].filter((k) => !signing[k]);
171
+ if (_missing.length > 0) {
172
+ throw new Error(
173
+ 'android.signing.keystore 已配置,但以下字段为空:' + _missing.join(', ') + '\n' +
174
+ ' 启用签名后,keystore / storePassword / keyAlias / keyPassword 四项均为必填;\n' +
175
+ ' 如仅需本地测试(不签名上架),可注释掉整个 signing 块--模板默认用 debug keystore 签名;'
176
+ );
177
+ }
178
+
179
+ const keystoreAbs = path.resolve(userProjectDir, signing.keystore);
180
+ if (!fs.existsSync(keystoreAbs))
181
+ return console.warn(chalk.yellow(' ⚠️ 签名 keystore 不存在,跳过签名配置: ' + keystoreAbs));
182
+
183
+ // 1. 写 gradle.properties(追加或覆盖 MYAPP_RELEASE_*)
184
+ const propsFile = path.join(buildDir, 'android', 'gradle.properties');
185
+ if (fs.existsSync(propsFile)) {
186
+ let c = fs.readFileSync(propsFile, 'utf-8');
187
+ // 删掉旧的 4 行
188
+ c = c.replace(/^\s*MYAPP_RELEASE_STORE_FILE=.*\r?\n/gm, '');
189
+ c = c.replace(/^\s*MYAPP_RELEASE_STORE_PASSWORD=.*\r?\n/gm, '');
190
+ c = c.replace(/^\s*MYAPP_RELEASE_KEY_ALIAS=.*\r?\n/gm, '');
191
+ c = c.replace(/^\s*MYAPP_RELEASE_KEY_PASSWORD=.*\r?\n/gm, '');
192
+ // 追加新的
193
+ // gradle.properties 按 ISO-8859-1 读取,非 ASCII 字符必须转 \uXXXX 转义,
194
+ // 否则中文路径(如"桌面")会被读成乱码,导致 keystore 找不到;
195
+ const esc = escapeProps(keystoreAbs.replace(/\\/g, '/'));
196
+ c += `\nMYAPP_RELEASE_STORE_FILE=${esc}\n`;
197
+ c += `MYAPP_RELEASE_STORE_PASSWORD=${escapeProps(signing.storePassword || '')}\n`;
198
+ c += `MYAPP_RELEASE_KEY_ALIAS=${escapeProps(signing.keyAlias || '')}\n`;
199
+ c += `MYAPP_RELEASE_KEY_PASSWORD=${escapeProps(signing.keyPassword || '')}\n`;
200
+ writeIfChanged(propsFile, c);
201
+ }
202
+
203
+ // 2. app/build.gradle: 加 signingConfigs.release,并让 buildTypes.release 用它
204
+ const gradleFile = path.join(buildDir, 'android', 'app', 'build.gradle');
205
+ if (fs.existsSync(gradleFile)) {
206
+ let c = fs.readFileSync(gradleFile, 'utf-8');
207
+
208
+ // 加 release 签名块(先删旧的,避免重复)
209
+ c = c.replace(/\s*release\s*\{\s*storeFile[\s\S]*?\}\r?\n(\s*\}\s*\r?\n)/, '$1');
210
+ // 在 signingConfigs { 之后插入
211
+ c = c.replace(/(signingConfigs\s*\{)/,
212
+ `$1
213
+ release {
214
+ storeFile file(MYAPP_RELEASE_STORE_FILE)
215
+ storePassword MYAPP_RELEASE_STORE_PASSWORD
216
+ keyAlias MYAPP_RELEASE_KEY_ALIAS
217
+ keyPassword MYAPP_RELEASE_KEY_PASSWORD
218
+ }`
219
+ );
220
+
221
+ // 让 release buildType 用 signingConfigs.release
222
+ // 只替换 release { 之后的第一个 signingConfig signingConfigs.debug
223
+ c = c.replace(/(release\s*\{\s*\n\s*signingConfig\s+)signingConfigs\.debug/, '$1signingConfigs.release');
224
+ writeIfChanged(gradleFile, c);
225
+ }
226
+
227
+ console.log(chalk.green(' ✓ Release 签名已配置: ' + path.basename(keystoreAbs)));
228
+ },
229
+ /**
230
+ * 应用图标:把用户提供的 PNG 缩放到 5 个 Android 密度,覆盖各 mipmap 目录下的 ic_launcher 与 ic_launcher_round
231
+ * 未配置或文件不存在时保持默认
232
+ */
233
+ patchIcon = async (buildDir, userProjectDir, config) => {
234
+ const iconPath = config.android && config.android.icon;
235
+ if (!iconPath) return;
236
+
237
+ const iconAbs = path.resolve(userProjectDir, iconPath);
238
+ if (!fs.existsSync(iconAbs)) return console.warn(chalk.yellow(' ⚠️ 图标文件不存在,保持默认: ' + iconAbs));
239
+
240
+ const { Jimp } = await import('jimp'),
241
+ densities = [
242
+ { dir: 'mipmap-mdpi', size: 48 },
243
+ { dir: 'mipmap-hdpi', size: 72 },
244
+ { dir: 'mipmap-xhdpi', size: 96 },
245
+ { dir: 'mipmap-xxhdpi', size: 144 },
246
+ { dir: 'mipmap-xxxhdpi', size: 192 }
247
+ ], resDir = path.join(buildDir, 'android', 'app', 'src', 'main', 'res'), source = await Jimp.read(iconAbs);
248
+
249
+ for (const { dir, size } of densities) {
250
+ const targetDir = path.join(resDir, dir);
251
+ if (!fs.existsSync(targetDir)) continue;
252
+
253
+ const img = source.clone();
254
+ img.resize({ w: size, h: size });
255
+ const buf = await img.getBuffer('image/png');
256
+
257
+ fs.writeFileSync(path.join(targetDir, 'ic_launcher.png'), buf);
258
+ fs.writeFileSync(path.join(targetDir, 'ic_launcher_round.png'), buf);
259
+ }
260
+
261
+ console.log(chalk.green(` ✓ 图标已应用: ${path.basename(iconAbs)}`));
262
+ },
263
+ /**
264
+ * 写入 iOS 工程配置:
265
+ * - Info.plist: CFBundleDisplayName
266
+ * - project.pbxproj: PRODUCT_BUNDLE_IDENTIFIER /
267
+ * MARKETING_VERSION / CURRENT_PROJECT_VERSION / IPHONEOS_DEPLOYMENT_TARGET
268
+ * 只在 buildDir 里存在 ios/ 时执行(其余平台跳过);
269
+ */
270
+ patchIosConfig = (buildDir, userProjectDir, config) => {
271
+ const iosDir = path.join(buildDir, 'ios');
272
+ if (!fs.existsSync(iosDir)) return;
273
+
274
+ const appName = resolveAppName(userProjectDir, config), appId = config.appId, versionName = config.versionName,
275
+ versionCode = config.versionCode, deploymentTarget = (config.ios?.deploymentTarget) || '15.1',
276
+ plist = path.join(iosDir, 'MyApp', 'Info.plist'), pbx = path.join(iosDir, 'MyApp.xcodeproj', 'project.pbxproj'),
277
+ appDelegate = path.join(iosDir, 'MyApp', 'AppDelegate.swift');
278
+ // 1. Info.plist:CFBundleDisplayName + ATS (对应 Android 的 usesCleartextTraffic)
279
+ if (fs.existsSync(plist)) {
280
+ let c = fs.readFileSync(plist, 'utf-8');
281
+
282
+ // CFBundleDisplayName
283
+ c = c.replace(/(<key>CFBundleDisplayName<\/key>\s*<string>)[^<]*(<\/string>)/, '$1' + escapeXml(appName) + '$2');
284
+ // ATS: usesCleartextTraffic=true → 放行本地 HTTP; false → 全部禁用
285
+ const allowCleartext = config.android.usesCleartextTraffic !== false, tab = '\t', atsBlock = allowCleartext
286
+ ? [
287
+ '<key>NSAppTransportSecurity</key>',
288
+ tab + '<dict>',
289
+ tab + tab + '<key>NSAllowsArbitraryLoads</key>',
290
+ tab + tab + '<false/>',
291
+ tab + tab + '<key>NSAllowsLocalNetworking</key>',
292
+ tab + tab + '<true/>',
293
+ tab + tab + '<key>NSExceptionDomains</key>',
294
+ tab + tab + '<dict>',
295
+ tab + tab + tab + '<key>localhost</key>',
296
+ tab + tab + tab + '<dict>',
297
+ tab + tab + tab + tab + '<key>NSExceptionAllowsInsecureHTTPLoads</key>',
298
+ tab + tab + tab + tab + '<true/>',
299
+ tab + tab + tab + tab + '<key>NSIncludesSubdomains</key>',
300
+ tab + tab + tab + tab + '<true/>',
301
+ tab + tab + tab + '</dict>',
302
+ tab + tab + tab + '<key>127.0.0.1</key>',
303
+ tab + tab + tab + '<dict>',
304
+ tab + tab + tab + tab + '<key>NSExceptionAllowsInsecureHTTPLoads</key>',
305
+ tab + tab + tab + tab + '<true/>',
306
+ tab + tab + tab + '</dict>',
307
+ tab + tab + '</dict>',
308
+ tab + '</dict>',
309
+ ].join('\n')
310
+ : [
311
+ '<key>NSAppTransportSecurity</key>',
312
+ tab + '<dict>',
313
+ tab + tab + '<key>NSAllowsArbitraryLoads</key>',
314
+ tab + tab + '<false/>',
315
+ tab + tab + '<key>NSAllowsLocalNetworking</key>',
316
+ tab + tab + '<false/>',
317
+ tab + '</dict>',
318
+ ].join('\n');
319
+
320
+ c = c.replace(
321
+ /<key>NSAppTransportSecurity<\/key>[\s\S]*?(?=<key>NSLocationWhenInUseUsageDescription<\/key>)/, atsBlock + '\n\t'
322
+ );
323
+ writeIfChanged(plist, c);
324
+ }
325
+
326
+ // 2. project.pbxproj
327
+ if (fs.existsSync(pbx)) {
328
+ let c = fs.readFileSync(pbx, 'utf-8');
329
+ c = c.replace(/PRODUCT_BUNDLE_IDENTIFIER = "[^"]+";/g, `PRODUCT_BUNDLE_IDENTIFIER = "${appId}";`);
330
+ c = c.replace(/MARKETING_VERSION = [^;]+;/g, `MARKETING_VERSION = ${versionName};`);
331
+ c = c.replace(/CURRENT_PROJECT_VERSION = [^;]+;/g, `CURRENT_PROJECT_VERSION = ${versionCode};`);
332
+ c = c.replace(/IPHONEOS_DEPLOYMENT_TARGET = [^;]+;/g, `IPHONEOS_DEPLOYMENT_TARGET = ${deploymentTarget};`);
333
+ writeIfChanged(pbx, c);
334
+ }
335
+
336
+ // 3. AppDelegate.swift: withModuleName 要和 app.json.name 一致
337
+ if (fs.existsSync(appDelegate)) {
338
+ let c = fs.readFileSync(appDelegate, 'utf-8');
339
+ c = c.replace(/(withModuleName:\s*)"[^"]*"/, `$1"${escapeSwiftString(appName)}"`);
340
+ writeIfChanged(appDelegate, c);
341
+ }
342
+
343
+ console.log(chalk.green(` ✓ iOS 配置:${appName} (${appId}) v${versionName}(${versionCode})` +
344
+ `, target ${deploymentTarget}`));
345
+ },
346
+ /**
347
+ * iOS 应用图标:把用户提供的 PNG 缩放到 9 个尺寸,写入 AppIcon.appiconset
348
+ * 同时改写 Contents.json 加 filename 字段
349
+ * 未配置或文件不存在时保持默认(无图标,Xcode 会用系统占位)
350
+ */
351
+ patchIosIcon = async (buildDir, userProjectDir, config) => {
352
+ const iconPath = config.ios && config.ios.icon;
353
+ if (!iconPath) return;
354
+
355
+ const iconAbs = path.resolve(userProjectDir, iconPath);
356
+ if (!fs.existsSync(iconAbs)) return console.warn(chalk.yellow(' ⚠️ iOS 图标文件不存在,保持默认: ' + iconAbs));
357
+
358
+ const iconDir = path.join(buildDir, 'ios', 'MyApp', 'Images.xcassets', 'AppIcon.appiconset');
359
+ if (!fs.existsSync(iconDir)) return;
360
+
361
+ const { Jimp } = await import('jimp'), source = await Jimp.read(iconAbs),
362
+ // Apple 规范:每项 (idiom, size, scale) 对应的像素尺寸与文件名
363
+ variants = [
364
+ { idiom: 'iphone', size: '20x20', scale: '2x', px: 40, name: 'Icon-App-20x20@2x.png' },
365
+ { idiom: 'iphone', size: '20x20', scale: '3x', px: 60, name: 'Icon-App-20x20@3x.png' },
366
+ { idiom: 'iphone', size: '29x29', scale: '2x', px: 58, name: 'Icon-App-29x29@2x.png' },
367
+ { idiom: 'iphone', size: '29x29', scale: '3x', px: 87, name: 'Icon-App-29x29@3x.png' },
368
+ { idiom: 'iphone', size: '40x40', scale: '2x', px: 80, name: 'Icon-App-40x40@2x.png' },
369
+ { idiom: 'iphone', size: '40x40', scale: '3x', px: 120, name: 'Icon-App-40x40@3x.png' },
370
+ { idiom: 'iphone', size: '60x60', scale: '2x', px: 120, name: 'Icon-App-60x60@2x.png' },
371
+ { idiom: 'iphone', size: '60x60', scale: '3x', px: 180, name: 'Icon-App-60x60@3x.png' },
372
+ { idiom: 'ios-marketing', size: '1024x1024', scale: '1x', px: 1024, name: 'Icon-App-1024x1024@1x.png' },
373
+ ], contentsFile = path.join(iconDir, 'Contents.json'),
374
+ contents = {
375
+ images: variants.map(v => ({ idiom: v.idiom, scale: v.scale, size: v.size, filename: v.name, })),
376
+ info: { author: 'xcode', version: 1 }
377
+ };
378
+
379
+ for (const v of variants) {
380
+ const img = source.clone();
381
+ img.resize({ w: v.px, h: v.px });
382
+ const buf = await img.getBuffer('image/png');
383
+ fs.writeFileSync(path.join(iconDir, v.name), buf);
384
+ }
385
+
386
+ // 改写 Contents.json,给每项加 filename
387
+ writeIfChanged(contentsFile, JSON.stringify(contents, null, 2) + '\n');
388
+ console.log(chalk.green(` ✓ iOS 图标已应用: ${path.basename(iconAbs)}`));
389
+ }
390
+
391
+ export {
392
+ resolveAppName, escapeKotlinString, escapeSwiftString, patchAppConfig, patchAndroidConfig, patchSigning,
393
+ patchIcon, patchIosConfig, patchIosIcon
394
394
  };