@flun/node-mobile-app 3.1.5 → 4.0.0

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