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